diff --git a/backend/app/api/env_detect.py b/backend/app/api/env_detect.py index 5368d25f..97a54c04 100644 --- a/backend/app/api/env_detect.py +++ b/backend/app/api/env_detect.py @@ -319,7 +319,7 @@ async def list_env_detect_records( query = apply_data_scope(query, BizEnvDetectRecord, user, db, tenant_no) total = query.count() items = ( - query.order_by(BizEnvDetectRecord.create_time.desc()) + query.order_by(BizEnvDetectRecord.detect_time.desc()) .offset((page - 1) * pageSize) .limit(pageSize) .all() diff --git a/backend/app/crud/env_detect_record.py b/backend/app/crud/env_detect_record.py index 94b84095..7a125f76 100644 --- a/backend/app/crud/env_detect_record.py +++ b/backend/app/crud/env_detect_record.py @@ -170,7 +170,7 @@ def list_env_detect_records( page_size = min(max(1, page_size), 100) rows = ( _filtered() - .order_by(BizEnvDetectRecord.create_time.desc()) + .order_by(BizEnvDetectRecord.detect_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() diff --git a/backend/app/crud/qc_inbound_lab_storage_sort.py b/backend/app/crud/qc_inbound_lab_storage_sort.py new file mode 100644 index 00000000..76f0a931 --- /dev/null +++ b/backend/app/crud/qc_inbound_lab_storage_sort.py @@ -0,0 +1,81 @@ +"""入库质检列表:按实验室入库编码排序的辅助逻辑。""" +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import and_, func, literal, select +from sqlalchemy.sql import ColumnElement + +from app.core.db_collation import eq_unicode, unicode_ci +from app.models.models import BizQcWorkOrder, BizSampleReception + +_INBOUND_LAB_SORT_SOURCES = frozenset({"cell_storage", "research_donation"}) + + +def uses_inbound_lab_storage_list_sort(reception_source: Optional[str]) -> bool: + return (reception_source or "").strip() in _INBOUND_LAB_SORT_SOURCES + + +def compare_lab_storage_then_create_time_desc( + code_a: Optional[str], + time_a: Optional[str], + code_b: Optional[str], + time_b: Optional[str], +) -> int: + """列表序比较:编码 DESC,同编码时间 DESC。返回负值表示 a 应排在 b 前。""" + ca = (code_a or "").strip() + cb = (code_b or "").strip() + if ca != cb: + # DESC: 较大字符串在前 + return -1 if ca > cb else 1 + ta = (time_a or "").strip() + tb = (time_b or "").strip() + if ta != tb: + return -1 if ta > tb else 1 + return 0 + + +def lab_storage_code_expr_for_work_order() -> ColumnElement: + """相关子查询:优先 reception_id,否则同租户 sample_code 最新一条接收记录的编码。 + + 子查询结果与空串字面量统一 utf8mb4_unicode_ci,避免 ORDER BY COALESCE + 触发 Illegal mix of collations(unicode_ci vs general_ci)。 + """ + by_id = ( + select(unicode_ci(BizSampleReception.lab_storage_code)) + .where( + and_( + eq_unicode(BizSampleReception.id, BizQcWorkOrder.reception_id), + BizSampleReception.del_flag == 0, + ) + ) + .correlate(BizQcWorkOrder) + .scalar_subquery() + ) + by_sample = ( + select(unicode_ci(BizSampleReception.lab_storage_code)) + .where( + and_( + eq_unicode(BizSampleReception.sample_code, BizQcWorkOrder.sample_code), + eq_unicode(BizSampleReception.tenant_no, BizQcWorkOrder.tenant_no), + BizSampleReception.del_flag == 0, + BizQcWorkOrder.sample_code.isnot(None), + unicode_ci(BizQcWorkOrder.sample_code) != "", + ) + ) + .order_by(BizSampleReception.create_time.desc()) + .limit(1) + .correlate(BizQcWorkOrder) + .scalar_subquery() + ) + empty = unicode_ci(literal("")) + return func.coalesce(by_id, by_sample, empty) + + +def inbound_lab_storage_order_by_clauses(): + """供 Query.order_by(*clauses) 使用。""" + code = lab_storage_code_expr_for_work_order() + return ( + code.desc(), + BizQcWorkOrder.create_time.desc(), + ) diff --git a/backend/app/crud/qc_task.py b/backend/app/crud/qc_task.py index 45abb090..1369e23d 100644 --- a/backend/app/crud/qc_task.py +++ b/backend/app/crud/qc_task.py @@ -24,6 +24,10 @@ from app.crud.qc_work_order import ( from app.core.db_collation import eq_unicode, unicode_ci from app.utils.sample_special import apply_special_to_item_from_samples from app.crud import qc_task_assignee as assignee_svc +from app.crud.qc_inbound_lab_storage_sort import ( + inbound_lab_storage_order_by_clauses, + uses_inbound_lab_storage_list_sort, +) from app.models.models import ( BizAttachment, BizCollectionProtocol, @@ -633,13 +637,21 @@ def list_mine_tasks( total = _filtered().order_by(None).with_entities(func.count(BizQcTask.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) - rows = ( - _filtered() - .order_by( + q_page = _filtered() + if uses_inbound_lab_storage_list_sort(reception_source): + q_page = q_page.order_by( + *inbound_lab_storage_order_by_clauses(), + BizQcWorkOrder.work_order_no.desc(), + BizQcTask.task_no.asc(), + ) + else: + q_page = q_page.order_by( BizQcTask.create_time.desc(), BizQcWorkOrder.work_order_no.desc(), BizQcTask.task_no.asc(), ) + rows = ( + q_page .offset((page - 1) * page_size) .limit(page_size) .all() diff --git a/backend/app/crud/qc_work_order.py b/backend/app/crud/qc_work_order.py index 1c55ac48..15bdd91e 100644 --- a/backend/app/crud/qc_work_order.py +++ b/backend/app/crud/qc_work_order.py @@ -16,6 +16,10 @@ from app.crud import qc_task as task_svc from app.crud import qc_task_assignee as assignee_svc from app.crud import qc_third_party_item as third_item_svc from app.crud.qc_third_party_company import get_company +from app.crud.qc_inbound_lab_storage_sort import ( + inbound_lab_storage_order_by_clauses, + uses_inbound_lab_storage_list_sort, +) from app.crud.collection_protocol import urls_to_attachment_list from app.utils.sample_special import apply_special_to_item_from_samples from app.crud import sample_reception as sample_reception_svc @@ -1706,9 +1710,13 @@ def list_work_orders( total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) + q_page = _filtered() + if uses_inbound_lab_storage_list_sort(reception_source): + q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses()) + else: + q_page = q_page.order_by(BizQcWorkOrder.create_time.desc()) rows = ( - _filtered() - .order_by(BizQcWorkOrder.create_time.desc()) + q_page .offset((page - 1) * page_size) .limit(page_size) .all() diff --git a/backend/app/crud/storage_facility.py b/backend/app/crud/storage_facility.py index cc1be4b9..80020221 100644 --- a/backend/app/crud/storage_facility.py +++ b/backend/app/crud/storage_facility.py @@ -771,6 +771,38 @@ def _apply_slot_select_query_filters( return filtered.filter(BizStorageFacilitySlot.id.in_(matched_ids)) +def _load_slot_select_include_rows(query, include_slot_ids: Optional[List[str]]): + """按传入顺序加载需强制保留的储物格(编辑态当前选中格)。""" + include_ids = [ + str(x).strip() + for x in (include_slot_ids or []) + if str(x).strip() + ] + if not include_ids: + return [] + rows_by_id = { + row.id: row + for row in query.filter(BizStorageFacilitySlot.id.in_(include_ids)).all() + } + return [rows_by_id[sid] for sid in include_ids if sid in rows_by_id] + + +def _merge_slot_select_rows_with_includes( + page_rows: List[BizStorageFacilitySlot], + include_rows: List[BizStorageFacilitySlot], +) -> List[BizStorageFacilitySlot]: + """将 include 格置顶合并进当前页,避免分页把当前选中格挤出导致下拉显示 id。""" + merged: List[BizStorageFacilitySlot] = [] + seen: set[str] = set() + for row in list(include_rows or []) + list(page_rows or []): + sid = row.id + if not sid or sid in seen: + continue + seen.add(sid) + merged.append(row) + return merged + + def list_slot_select_options( db: Session, *, @@ -789,6 +821,7 @@ def list_slot_select_options( 1, ) need_count = page * page_size + include_rows = _load_slot_select_include_rows(query, include_slot_ids) filtered = _apply_slot_select_base_filters( query, @@ -811,11 +844,13 @@ def list_slot_select_options( ) total = len(matched_ids) page_ids = matched_ids[(page - 1) * page_size : page * page_size] - if not page_ids: - return [], total rows_by_id = { row.id: row - for row in filtered.filter(BizStorageFacilitySlot.id.in_(page_ids)).all() + for row in ( + filtered.filter(BizStorageFacilitySlot.id.in_(page_ids)).all() + if page_ids + else [] + ) } rows = [rows_by_id[sid] for sid in page_ids if sid in rows_by_id] else: @@ -832,6 +867,7 @@ def list_slot_select_options( .all() ) + rows = _merge_slot_select_rows_with_includes(rows, include_rows) if not rows: return [], int(total) diff --git a/backend/tests/test_qc_inbound_lab_storage_sort.py b/backend/tests/test_qc_inbound_lab_storage_sort.py new file mode 100644 index 00000000..9763fe5c --- /dev/null +++ b/backend/tests/test_qc_inbound_lab_storage_sort.py @@ -0,0 +1,45 @@ +"""入库质检列表:实验室入库编码排序语义。""" +import unittest + +from app.crud.qc_inbound_lab_storage_sort import ( + compare_lab_storage_then_create_time_desc, + uses_inbound_lab_storage_list_sort, +) + + +class TestUsesInboundLabStorageListSort(unittest.TestCase): + def test_cell_storage_and_research_donation(self): + self.assertTrue(uses_inbound_lab_storage_list_sort("cell_storage")) + self.assertTrue(uses_inbound_lab_storage_list_sort("research_donation")) + + def test_other_sources_false(self): + self.assertFalse(uses_inbound_lab_storage_list_sort("")) + self.assertFalse(uses_inbound_lab_storage_list_sort(None)) + self.assertFalse(uses_inbound_lab_storage_list_sort("temporary")) + + +class TestCompareLabStorageThenCreateTimeDesc(unittest.TestCase): + def test_code_desc_empty_as_empty_string(self): + # '' < 'B0000001' lexicographically → DESC 时有编码在前 + self.assertEqual( + compare_lab_storage_then_create_time_desc("B0000001", "2026-01-01", "", "2026-12-01"), + -1, + ) + self.assertEqual( + compare_lab_storage_then_create_time_desc("", "2026-12-01", "B0000001", "2026-01-01"), + 1, + ) + + def test_same_code_newer_create_time_first(self): + self.assertEqual( + compare_lab_storage_then_create_time_desc( + "B1", "2026-08-02 10:00:00", "B1", "2026-08-01 10:00:00" + ), + -1, + ) + + def test_higher_code_first(self): + self.assertEqual( + compare_lab_storage_then_create_time_desc("B2", "t", "B1", "t"), + -1, + ) diff --git a/docs/sql/alter-sys-user-wx-mp-openid.sql b/docs/sql/alter-sys-user-wx-mp-openid.sql deleted file mode 100644 index c37296c1..00000000 --- a/docs/sql/alter-sys-user-wx-mp-openid.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 员工微信服务号 openid / unionid 绑定字段(OA 审批通知) --- MySQL 5.7:唯一索引允许多个 NULL - -ALTER TABLE `sys_user` - ADD COLUMN `wx_mp_openid` varchar(64) DEFAULT NULL COMMENT '微信服务号 openid' AFTER `wx_openid`, - ADD COLUMN `wx_unionid` varchar(64) DEFAULT NULL COMMENT '微信开放平台 unionid' AFTER `wx_mp_openid`, - ADD COLUMN `wx_mp_bind_time` datetime DEFAULT NULL COMMENT '服务号绑定时间' AFTER `wx_unionid`; - -ALTER TABLE `sys_user` - ADD UNIQUE INDEX `uk_sys_user_wx_mp_openid` (`wx_mp_openid`); - -ALTER TABLE `sys_user` - ADD INDEX `idx_sys_user_wx_unionid` (`wx_unionid`); diff --git a/docs/sql/alter_biz_qc_inspection_item_template.sql b/docs/sql/alter_biz_qc_inspection_item_template.sql deleted file mode 100644 index 78900eac..00000000 --- a/docs/sql/alter_biz_qc_inspection_item_template.sql +++ /dev/null @@ -1,322 +0,0 @@ --- 质检:检测项目模版(建表 + C 菜单 + 4 F 按钮 + 角色授权) --- 幂等:可重复执行 --- 授权:已有【质检基础信息】QCB 下任一有效子菜单,或已有质检工单菜单的角色 --- → 授予 TOP + QCB + 本 C/F - -SET @tenant_no = CONVERT('zkxw' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @top_id = CONVERT('TOP00000000000000000000000000003' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @qcb_id = CONVERT('QCB00000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @wo_menu = CONVERT('70000000000000000000000000000007' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_c = CONVERT('95300000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_f_list = CONVERT('95300000000000000000000000000002' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_f_add = CONVERT('95300000000000000000000000000003' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_f_edit = CONVERT('95300000000000000000000000000004' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_f_remove = CONVERT('95300000000000000000000000000005' USING utf8mb4) COLLATE utf8mb4_unicode_ci; - --- 0) 建表 -CREATE TABLE IF NOT EXISTS `biz_qc_inspection_item_template` ( - `id` varchar(64) NOT NULL COMMENT '主键ID', - `template_name` varchar(200) NOT NULL COMMENT '模版名称', - `lab_item_ids` varchar(2000) NOT NULL DEFAULT '' COMMENT '实验室项目ID逗号分隔', - `third_party_item_ids` varchar(2000) NOT NULL DEFAULT '' COMMENT '第三方项目ID逗号分隔', - `status` char(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用', - `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', - `order_num` int(11) DEFAULT NULL COMMENT '排序号', - `remark` varchar(512) DEFAULT NULL COMMENT '备注', - `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id', - `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', - `create_time` datetime NOT NULL COMMENT '创建时间', - `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id', - `update_time` datetime NOT NULL COMMENT '更新时间', - `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记', - PRIMARY KEY (`id`) USING BTREE, - KEY `idx_qc_iit_tenant_status` (`tenant_no`,`del_flag`,`status`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质检检测项目模版'; - --- 1) C 菜单 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_c, - '检测项目模版', - @qcb_id, - 'C', - 'inspection-item-template', - '/quality-inspection/inspection-item-template/index', - 'biz:qcInspectionItemTemplate:list', - 1, '1', '1', @tenant_no, 5, - '质检基础信息:检测项目模版主数据', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_c -); - -UPDATE sys_menu -SET - menu_name = '检测项目模版', - parent_id = @qcb_id, - menu_type = 'C', - path = 'inspection-item-template', - component = '/quality-inspection/inspection-item-template/index', - perms = 'biz:qcInspectionItemTemplate:list', - visible = '1', - status = '1', - order_num = 5, - remark = '质检基础信息:检测项目模版主数据', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_c; - --- 2) F 按钮:list -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_f_list, - '检测项目模版查询', - @menu_c, - 'F', - '', - '', - 'biz:qcInspectionItemTemplate:list', - 1, '0', '1', @tenant_no, 1, - '检测项目模版列表查询', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_f_list -); - -UPDATE sys_menu -SET - menu_name = '检测项目模版查询', - parent_id = @menu_c, - menu_type = 'F', - path = '', - component = '', - perms = 'biz:qcInspectionItemTemplate:list', - visible = '0', - status = '1', - order_num = 1, - remark = '检测项目模版列表查询', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_f_list; - --- 2b) F 按钮:add -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_f_add, - '检测项目模版新增', - @menu_c, - 'F', - '', - '', - 'biz:qcInspectionItemTemplate:add', - 1, '0', '1', @tenant_no, 2, - '检测项目模版新增', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_f_add -); - -UPDATE sys_menu -SET - menu_name = '检测项目模版新增', - parent_id = @menu_c, - menu_type = 'F', - path = '', - component = '', - perms = 'biz:qcInspectionItemTemplate:add', - visible = '0', - status = '1', - order_num = 2, - remark = '检测项目模版新增', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_f_add; - --- 2c) F 按钮:edit -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_f_edit, - '检测项目模版修改', - @menu_c, - 'F', - '', - '', - 'biz:qcInspectionItemTemplate:edit', - 1, '0', '1', @tenant_no, 3, - '检测项目模版修改(含启停)', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_f_edit -); - -UPDATE sys_menu -SET - menu_name = '检测项目模版修改', - parent_id = @menu_c, - menu_type = 'F', - path = '', - component = '', - perms = 'biz:qcInspectionItemTemplate:edit', - visible = '0', - status = '1', - order_num = 3, - remark = '检测项目模版修改(含启停)', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_f_edit; - --- 2d) F 按钮:remove -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_f_remove, - '检测项目模版删除', - @menu_c, - 'F', - '', - '', - 'biz:qcInspectionItemTemplate:remove', - 1, '0', '1', @tenant_no, 4, - '检测项目模版删除', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_f_remove -); - -UPDATE sys_menu -SET - menu_name = '检测项目模版删除', - parent_id = @menu_c, - menu_type = 'F', - path = '', - component = '', - perms = 'biz:qcInspectionItemTemplate:remove', - visible = '0', - status = '1', - order_num = 4, - remark = '检测项目模版删除', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_f_remove; - --- 3) 目标角色:已有 QCB 下任一有效子菜单,或已有质检工单菜单 --- MySQL 5.7:UPDATE 目标表不可在同句 FROM/EXISTS 中直接引用自身,须用派生表 - --- 3a) 恢复软删的 TOP / QCB / C / F 授权 -UPDATE sys_role_menu rm -INNER JOIN sys_role r - ON r.id COLLATE utf8mb4_unicode_ci = rm.role_id COLLATE utf8mb4_unicode_ci - AND r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no -INNER JOIN ( - SELECT DISTINCT rm2.role_id AS role_id - FROM sys_role_menu rm2 - INNER JOIN sys_menu m2 - ON m2.id COLLATE utf8mb4_unicode_ci = rm2.menu_id COLLATE utf8mb4_unicode_ci - AND m2.del_flag = 0 - AND ( - m2.parent_id COLLATE utf8mb4_unicode_ci = @qcb_id - OR m2.id COLLATE utf8mb4_unicode_ci = @wo_menu - ) - WHERE rm2.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm2.del_flag = 0 -) eligible - ON eligible.role_id COLLATE utf8mb4_unicode_ci = r.id COLLATE utf8mb4_unicode_ci -SET - rm.del_flag = 0, - rm.update_time = NOW(), - rm.remark = 'grant qc inspection item template to QCB/WO roles' -WHERE rm.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm.del_flag = 1 - AND rm.menu_id COLLATE utf8mb4_unicode_ci IN ( - @top_id, @qcb_id, @menu_c, - @menu_f_list, @menu_f_add, @menu_f_edit, @menu_f_remove - ); - --- 3b) 插入缺失授权(TOP + QCB + C + 4 F) -INSERT INTO sys_role_menu ( - id, role_id, menu_id, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - UPPER(MD5(CONCAT(src.role_id, '|', src.menu_id))), - src.role_id, - src.menu_id, - @tenant_no, - 0, - 'grant qc inspection item template to QCB/WO roles', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM ( - SELECT - r.id AS role_id, - x.menu_id AS menu_id - FROM sys_role r - INNER JOIN ( - SELECT @top_id AS menu_id - UNION ALL SELECT @qcb_id - UNION ALL SELECT @menu_c - UNION ALL SELECT @menu_f_list - UNION ALL SELECT @menu_f_add - UNION ALL SELECT @menu_f_edit - UNION ALL SELECT @menu_f_remove - ) x - INNER JOIN ( - SELECT DISTINCT rm2.role_id AS role_id - FROM sys_role_menu rm2 - INNER JOIN sys_menu m2 - ON m2.id COLLATE utf8mb4_unicode_ci = rm2.menu_id COLLATE utf8mb4_unicode_ci - AND m2.del_flag = 0 - AND ( - m2.parent_id COLLATE utf8mb4_unicode_ci = @qcb_id - OR m2.id COLLATE utf8mb4_unicode_ci = @wo_menu - ) - WHERE rm2.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm2.del_flag = 0 - ) eligible - ON eligible.role_id COLLATE utf8mb4_unicode_ci = r.id COLLATE utf8mb4_unicode_ci - INNER JOIN sys_menu m - ON m.id COLLATE utf8mb4_unicode_ci = x.menu_id - AND m.del_flag = 0 - WHERE r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no -) src -LEFT JOIN sys_role_menu existing - ON existing.role_id COLLATE utf8mb4_unicode_ci = src.role_id COLLATE utf8mb4_unicode_ci - AND existing.menu_id COLLATE utf8mb4_unicode_ci = src.menu_id COLLATE utf8mb4_unicode_ci - AND existing.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND existing.del_flag = 0 -WHERE existing.id IS NULL; diff --git a/docs/sql/alter_sys_menu_add_production_pre_transit_sample.sql b/docs/sql/alter_sys_menu_add_production_pre_transit_sample.sql deleted file mode 100644 index 84272731..00000000 --- a/docs/sql/alter_sys_menu_add_production_pre_transit_sample.sql +++ /dev/null @@ -1,166 +0,0 @@ --- 生产管理:预产及在途样本(独立页面 + 独立权限码) --- 幂等:可重复执行 --- 授权:已有【生产管理】PRM 下任一有效子菜单的角色 → 授予本 C/F,并补授 PRM 祖先 - -SET @tenant_no = CONVERT('zkxw' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @prm_id = CONVERT('PRM00000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_c = CONVERT('95200000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @menu_f = CONVERT('95200000000000000000000000000002' USING utf8mb4) COLLATE utf8mb4_unicode_ci; - --- 1) C 菜单 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_c, - '预产及在途样本', - @prm_id, - 'C', - 'pre-transit-sample', - '/production/pre-transit-sample/index', - 'production:preTransitSample:list', - 1, '1', '1', @tenant_no, 0, - '生产看板:在途样本与预产协议(协议单维度只读;与质检页代码独立)', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_c -); - -UPDATE sys_menu -SET - menu_name = '预产及在途样本', - parent_id = @prm_id, - menu_type = 'C', - path = 'pre-transit-sample', - component = '/production/pre-transit-sample/index', - perms = 'production:preTransitSample:list', - visible = '1', - status = '1', - order_num = 0, - remark = '生产看板:在途样本与预产协议(协议单维度只读;与质检页代码独立)', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_c; - --- 2) F 按钮 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - @menu_f, - '预产及在途查询', - @menu_c, - 'F', - '', - '', - 'production:preTransitSample:list', - 1, '0', '1', @tenant_no, 1, - '生产侧预产及在途样本列表查询', - NULL, '', NOW(), NULL, NOW(), 0 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_f -); - -UPDATE sys_menu -SET - menu_name = '预产及在途查询', - parent_id = @menu_c, - menu_type = 'F', - path = '', - component = '', - perms = 'production:preTransitSample:list', - visible = '0', - status = '1', - order_num = 1, - remark = '生产侧预产及在途样本列表查询', - del_flag = 0, - update_time = NOW() -WHERE id COLLATE utf8mb4_unicode_ci = @menu_f; - --- 3) 目标角色:已有 PRM 下任一有效子菜单授权 --- MySQL 5.7:UPDATE 目标表不可在同句 FROM/EXISTS 中直接引用自身,须用派生表 - --- 3a) 恢复软删的 PRM / C / F 授权 -UPDATE sys_role_menu rm -INNER JOIN sys_role r - ON r.id COLLATE utf8mb4_unicode_ci = rm.role_id COLLATE utf8mb4_unicode_ci - AND r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no -INNER JOIN ( - SELECT DISTINCT rm2.role_id AS role_id - FROM sys_role_menu rm2 - INNER JOIN sys_menu m2 - ON m2.id COLLATE utf8mb4_unicode_ci = rm2.menu_id COLLATE utf8mb4_unicode_ci - AND m2.del_flag = 0 - AND m2.parent_id COLLATE utf8mb4_unicode_ci = @prm_id - WHERE rm2.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm2.del_flag = 0 -) eligible - ON eligible.role_id COLLATE utf8mb4_unicode_ci = r.id COLLATE utf8mb4_unicode_ci -SET - rm.del_flag = 0, - rm.update_time = NOW(), - rm.remark = 'grant production pre-transit to PRM roles' -WHERE rm.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm.del_flag = 1 - AND rm.menu_id COLLATE utf8mb4_unicode_ci IN (@prm_id, @menu_c, @menu_f); - --- 3b) 插入缺失授权(PRM + C + F) -INSERT INTO sys_role_menu ( - id, role_id, menu_id, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - UPPER(MD5(CONCAT(src.role_id, '|', src.menu_id))), - src.role_id, - src.menu_id, - @tenant_no, - 0, - 'grant production pre-transit to PRM roles', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM ( - SELECT - r.id AS role_id, - x.menu_id AS menu_id - FROM sys_role r - INNER JOIN ( - SELECT @prm_id AS menu_id - UNION ALL SELECT @menu_c - UNION ALL SELECT @menu_f - ) x - INNER JOIN ( - SELECT DISTINCT rm2.role_id AS role_id - FROM sys_role_menu rm2 - INNER JOIN sys_menu m2 - ON m2.id COLLATE utf8mb4_unicode_ci = rm2.menu_id COLLATE utf8mb4_unicode_ci - AND m2.del_flag = 0 - AND m2.parent_id COLLATE utf8mb4_unicode_ci = @prm_id - WHERE rm2.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm2.del_flag = 0 - ) eligible - ON eligible.role_id COLLATE utf8mb4_unicode_ci = r.id COLLATE utf8mb4_unicode_ci - INNER JOIN sys_menu m - ON m.id COLLATE utf8mb4_unicode_ci = x.menu_id - AND m.del_flag = 0 - WHERE r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no -) src -LEFT JOIN sys_role_menu existing - ON existing.role_id COLLATE utf8mb4_unicode_ci = src.role_id COLLATE utf8mb4_unicode_ci - AND existing.menu_id COLLATE utf8mb4_unicode_ci = src.menu_id COLLATE utf8mb4_unicode_ci - AND existing.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND existing.del_flag = 0 -WHERE existing.id IS NULL; diff --git a/docs/sql/grant_wechat_notify_menu_to_all_roles.sql b/docs/sql/grant_wechat_notify_menu_to_all_roles.sql deleted file mode 100644 index 5a2216c6..00000000 --- a/docs/sql/grant_wechat_notify_menu_to_all_roles.sql +++ /dev/null @@ -1,57 +0,0 @@ --- 将「开启微信通知」(OA007) 授予全部未删除角色 --- 前置:已执行 docs/sql/mobile_menu_oa_wechat_notify.sql(菜单须已存在) --- 幂等:可重复执行;已有有效授权跳过;软删则恢复 --- 说明: --- 1) 用户变量显式 utf8mb4_unicode_ci,避免 collation 混用报错 1267 --- 2) 主键用 MD5(role_id|menu_id),避免 INSERT…SELECT 中 UUID() 只算一次导致重复主键 - -SET @menu_id = CONVERT('MINI000000000000000000000000OA007' USING utf8mb4) COLLATE utf8mb4_unicode_ci; -SET @tenant_no = CONVERT('zkxw' USING utf8mb4) COLLATE utf8mb4_unicode_ci; - --- 1) 恢复历史上已软删的授权(按 role_id + menu_id) -UPDATE sys_role_menu rm -INNER JOIN sys_role r - ON r.id COLLATE utf8mb4_unicode_ci = rm.role_id COLLATE utf8mb4_unicode_ci - AND r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no -SET - rm.del_flag = 0, - rm.update_time = NOW(), - rm.remark = 'grant wechat-notify to all roles' -WHERE rm.menu_id COLLATE utf8mb4_unicode_ci = @menu_id - AND rm.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm.del_flag = 1; - --- 2) 为尚未关联该菜单的角色新增授权 -INSERT INTO sys_role_menu ( - id, role_id, menu_id, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - UPPER(MD5(CONCAT(r.id, '|', @menu_id))), - r.id, - @menu_id, - @tenant_no, - 0, - 'grant wechat-notify to all roles', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM sys_role r -WHERE r.del_flag = 0 - AND r.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND EXISTS ( - SELECT 1 FROM sys_menu m - WHERE m.id COLLATE utf8mb4_unicode_ci = @menu_id - AND m.del_flag = 0 - ) - AND NOT EXISTS ( - SELECT 1 FROM sys_role_menu rm - WHERE rm.role_id COLLATE utf8mb4_unicode_ci = r.id COLLATE utf8mb4_unicode_ci - AND rm.menu_id COLLATE utf8mb4_unicode_ci = @menu_id - AND rm.tenant_no COLLATE utf8mb4_unicode_ci = @tenant_no - AND rm.del_flag = 0 - ); diff --git a/docs/sql/mobile_menu_oa_start.sql b/docs/sql/mobile_menu_oa_start.sql deleted file mode 100644 index 398312e4..00000000 --- a/docs/sql/mobile_menu_oa_start.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 小程序「发起审批」菜单种子(挂在「移动端菜单」根下) --- 执行前请确认移动端根存在;角色授权请在 Web「角色管理」勾选本菜单。 - -SET @mini_root = ( - SELECT id FROM sys_menu - WHERE del_flag = 0 AND parent_id = '0' AND menu_name LIKE '%移动端%' - LIMIT 1 -); - -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA001', - '发起审批', - @mini_root, - 'C', - '/pages/oa/start/index', - NULL, - 'mini:oa:start', - 1, - '1', - '1', - 'zkxw', - 10, - '小程序发起审批', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA001' AND del_flag = 0 - ); diff --git a/docs/sql/mobile_menu_oa_task_centers.sql b/docs/sql/mobile_menu_oa_task_centers.sql deleted file mode 100644 index 58bda120..00000000 --- a/docs/sql/mobile_menu_oa_task_centers.sql +++ /dev/null @@ -1,169 +0,0 @@ --- 小程序 OA 五中心菜单种子(挂在「移动端菜单」根下) --- 前置:docs/sql/mobile_menu_oa_start.sql(发起审批 OA001)可已执行 --- 角色授权请在 Web「角色管理」勾选本菜单。 - -SET @mini_root = ( - SELECT id FROM sys_menu - WHERE del_flag = 0 AND parent_id = '0' AND menu_name LIKE '%移动端%' - LIMIT 1 -); - --- 草稿箱 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA002', - '草稿箱', - @mini_root, - 'C', - '/pages/oa/drafts/index', - NULL, - 'mini:oa:drafts', - 1, - '1', - '1', - 'zkxw', - 20, - '小程序草稿箱', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA002' AND del_flag = 0 - ); - --- 待办中心 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA003', - '待办中心', - @mini_root, - 'C', - '/pages/oa/pending/index', - NULL, - 'mini:oa:pending', - 1, - '1', - '1', - 'zkxw', - 30, - '小程序待办中心', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA003' AND del_flag = 0 - ); - --- 已办中心 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA004', - '已办中心', - @mini_root, - 'C', - '/pages/oa/done/index', - NULL, - 'mini:oa:done', - 1, - '1', - '1', - 'zkxw', - 40, - '小程序已办中心', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA004' AND del_flag = 0 - ); - --- 我发起的 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA005', - '我发起的', - @mini_root, - 'C', - '/pages/oa/mine/index', - NULL, - 'mini:oa:mine', - 1, - '1', - '1', - 'zkxw', - 50, - '小程序我发起的', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA005' AND del_flag = 0 - ); - --- 抄送我的 -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA006', - '抄送我的', - @mini_root, - 'C', - '/pages/oa/cc/index', - NULL, - 'mini:oa:cc', - 1, - '1', - '1', - 'zkxw', - 60, - '小程序抄送我的', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA006' AND del_flag = 0 - ); diff --git a/docs/sql/mobile_menu_oa_wechat_notify.sql b/docs/sql/mobile_menu_oa_wechat_notify.sql deleted file mode 100644 index e190e1ed..00000000 --- a/docs/sql/mobile_menu_oa_wechat_notify.sql +++ /dev/null @@ -1,40 +0,0 @@ --- 小程序「开启微信通知」菜单(抄送我的之后) --- 前置:docs/sql/mobile_menu_oa_task_centers.sql(OA006)可已执行 --- 角色授权请在 Web「角色管理」勾选本菜单。 - -SET @mini_root = ( - SELECT id FROM sys_menu - WHERE del_flag = 0 AND parent_id = '0' AND menu_name LIKE '%移动端%' - LIMIT 1 -); - -INSERT INTO sys_menu ( - id, menu_name, parent_id, menu_type, path, component, perms, - is_frame, visible, status, tenant_no, order_num, remark, - create_by, create_dept_id, create_time, update_by, update_time, del_flag -) -SELECT - 'MINI000000000000000000000000OA007', - '开启微信通知', - @mini_root, - 'C', - '/pages/oa/wechat-notify/index', - NULL, - 'mini:oa:wechat-notify', - 1, - '1', - '1', - 'zkxw', - 70, - '小程序开启微信通知(关注+绑定)', - 'admin', - '', - NOW(), - NULL, - NOW(), - 0 -FROM DUAL -WHERE @mini_root IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM sys_menu WHERE id = 'MINI000000000000000000000000OA007' AND del_flag = 0 - ); diff --git a/docs/superpowers/plans/2026-08-17-qc-inbound-lab-storage-code-sort.md b/docs/superpowers/plans/2026-08-17-qc-inbound-lab-storage-code-sort.md new file mode 100644 index 00000000..83706c7a --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-qc-inbound-lab-storage-code-sort.md @@ -0,0 +1,614 @@ +# 入库质检列表按实验室入库编码排序 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 【质检工单】与【我的质检任务】的「细胞存储」「科研捐赠」Tab,按实验室入库编码倒序、同编码按创建时间倒序分页与展示,跨页一致。 + +**Architecture:** 后端在入库两 Tab 的列表查询上用相关子查询解析 `lab_storage_code`(优先 `reception_id`,否则按样本编码),`ORDER BY COALESCE(code,'') DESC, create_time DESC`;前端入库卡片与我的任务聚合排序对齐同一比较规则;出库组内排序不动。 + +**Tech Stack:** FastAPI / SQLAlchemy 2.0 / Vue 3 / TypeScript;设计依据 `docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md`。 + +**Spec:** `docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| Create: `backend/app/crud/qc_inbound_lab_storage_sort.py` | 判断是否启用编码排序、相关子查询表达式、`order_by` 元组 | +| Modify: `backend/app/crud/qc_work_order.py` | `list_work_orders` 入库两 Tab 使用新 `order_by` | +| Modify: `backend/app/crud/qc_task.py` | `list_mine_tasks` 入库两 Tab 使用新 `order_by` | +| Create: `backend/tests/test_qc_inbound_lab_storage_sort.py` | 纯函数:来源判断 + 排序键比较语义 | +| Create: `web-admin/src/utils/qc-inbound-lab-storage-sort.ts` | 前端统一比较:编码 DESC → 时间 DESC | +| Modify: `web-admin/src/utils/qc-work-order-groups.ts` | 仅入库组内改为时间倒序 | +| Modify: `web-admin/src/utils/qc-work-order-inbound-sample-cards.ts` | 卡片列表主序改为编码/时间 | +| Modify: `web-admin/src/utils/qc-mine-task-groups.ts` | 导出按编码排序的工单条目 | +| Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts` | mine 模式分页前调用排序 | + +--- + +### Task 1: 后端纯函数 — 来源判断与比较语义 + +**Files:** +- Create: `backend/app/crud/qc_inbound_lab_storage_sort.py` +- Create: `backend/tests/test_qc_inbound_lab_storage_sort.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_qc_inbound_lab_storage_sort.py +"""入库质检列表:实验室入库编码排序语义。""" +import unittest + +from app.crud.qc_inbound_lab_storage_sort import ( + compare_lab_storage_then_create_time_desc, + uses_inbound_lab_storage_list_sort, +) + + +class TestUsesInboundLabStorageListSort(unittest.TestCase): + def test_cell_storage_and_research_donation(self): + self.assertTrue(uses_inbound_lab_storage_list_sort("cell_storage")) + self.assertTrue(uses_inbound_lab_storage_list_sort("research_donation")) + + def test_other_sources_false(self): + self.assertFalse(uses_inbound_lab_storage_list_sort("")) + self.assertFalse(uses_inbound_lab_storage_list_sort(None)) + self.assertFalse(uses_inbound_lab_storage_list_sort("temporary")) + + +class TestCompareLabStorageThenCreateTimeDesc(unittest.TestCase): + def test_code_desc_empty_as_empty_string(self): + # '' < 'B0000001' lexicographically → DESC 时有编码在前 + self.assertEqual( + compare_lab_storage_then_create_time_desc("B0000001", "2026-01-01", "", "2026-12-01"), + -1, + ) + self.assertEqual( + compare_lab_storage_then_create_time_desc("", "2026-12-01", "B0000001", "2026-01-01"), + 1, + ) + + def test_same_code_newer_create_time_first(self): + self.assertEqual( + compare_lab_storage_then_create_time_desc( + "B1", "2026-08-02 10:00:00", "B1", "2026-08-01 10:00:00" + ), + -1, + ) + + def test_higher_code_first(self): + self.assertEqual( + compare_lab_storage_then_create_time_desc("B2", "t", "B1", "t"), + -1, + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_qc_inbound_lab_storage_sort.py -v +``` + +Expected: FAIL(模块不存在或导入失败) + +- [ ] **Step 3: Write minimal implementation** + +```python +# backend/app/crud/qc_inbound_lab_storage_sort.py +"""入库质检列表:按实验室入库编码排序的辅助逻辑。""" +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.sql import ColumnElement + +from app.models.models import BizQcWorkOrder, BizSampleReception + +_INBOUND_LAB_SORT_SOURCES = frozenset({"cell_storage", "research_donation"}) + + +def uses_inbound_lab_storage_list_sort(reception_source: Optional[str]) -> bool: + return (reception_source or "").strip() in _INBOUND_LAB_SORT_SOURCES + + +def compare_lab_storage_then_create_time_desc( + code_a: Optional[str], + time_a: Optional[str], + code_b: Optional[str], + time_b: Optional[str], +) -> int: + """列表序比较:编码 DESC,同编码时间 DESC。返回负值表示 a 应排在 b 前。""" + ca = (code_a or "").strip() + cb = (code_b or "").strip() + if ca != cb: + # DESC: 较大字符串在前 + return -1 if ca > cb else 1 + ta = (time_a or "").strip() + tb = (time_b or "").strip() + if ta != tb: + return -1 if ta > tb else 1 + return 0 + + +def lab_storage_code_expr_for_work_order() -> ColumnElement: + """相关子查询:优先 reception_id,否则同租户 sample_code 最新一条接收记录的编码。""" + by_id = ( + select(BizSampleReception.lab_storage_code) + .where( + and_( + BizSampleReception.id == BizQcWorkOrder.reception_id, + BizSampleReception.del_flag == 0, + ) + ) + .correlate(BizQcWorkOrder) + .scalar_subquery() + ) + by_sample = ( + select(BizSampleReception.lab_storage_code) + .where( + and_( + BizSampleReception.sample_code == BizQcWorkOrder.sample_code, + BizSampleReception.tenant_no == BizQcWorkOrder.tenant_no, + BizSampleReception.del_flag == 0, + BizQcWorkOrder.sample_code.isnot(None), + BizQcWorkOrder.sample_code != "", + ) + ) + .order_by(BizSampleReception.create_time.desc()) + .limit(1) + .correlate(BizQcWorkOrder) + .scalar_subquery() + ) + return func.coalesce(by_id, by_sample, "") + + +def inbound_lab_storage_order_by_clauses(): + """供 Query.order_by(*clauses) 使用。""" + code = lab_storage_code_expr_for_work_order() + return ( + code.desc(), + BizQcWorkOrder.create_time.desc(), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_qc_inbound_lab_storage_sort.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit**(仅当用户明确要求提交时执行) + +```bash +git add backend/app/crud/qc_inbound_lab_storage_sort.py backend/tests/test_qc_inbound_lab_storage_sort.py +git commit -m "$(cat <<'EOF' +feat: add inbound QC list sort helpers by lab storage code + +EOF +)" +``` + +--- + +### Task 2: `list_work_orders` 应用入库排序 + +**Files:** +- Modify: `backend/app/crud/qc_work_order.py`(`list_work_orders` 中约 1706–1715 行的 `order_by`) + +- [ ] **Step 1: Wire order_by** + +在 `list_work_orders` 文件顶部附近增加导入: + +```python +from app.crud.qc_inbound_lab_storage_sort import ( + inbound_lab_storage_order_by_clauses, + uses_inbound_lab_storage_list_sort, +) +``` + +将: + +```python + rows = ( + _filtered() + .order_by(BizQcWorkOrder.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) +``` + +替换为: + +```python + q_page = _filtered() + if uses_inbound_lab_storage_list_sort(reception_source): + q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses()) + else: + q_page = q_page.order_by(BizQcWorkOrder.create_time.desc()) + rows = ( + q_page + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) +``` + +- [ ] **Step 2: Sanity import check** + +```bash +cd backend && source venv/bin/activate && python -c "from app.crud.qc_work_order import list_work_orders; print('ok')" +``` + +Expected: `ok` + +- [ ] **Step 3: Commit**(仅当用户明确要求提交时执行) + +```bash +git add backend/app/crud/qc_work_order.py +git commit -m "$(cat <<'EOF' +feat: order inbound QC work orders by lab storage code then create time + +EOF +)" +``` + +--- + +### Task 3: `list_mine_tasks` 应用入库排序 + +**Files:** +- Modify: `backend/app/crud/qc_task.py`(`list_mine_tasks` 的 `order_by`,约 637–644 行) + +- [ ] **Step 1: Wire order_by** + +增加导入: + +```python +from app.crud.qc_inbound_lab_storage_sort import ( + inbound_lab_storage_order_by_clauses, + uses_inbound_lab_storage_list_sort, +) +``` + +将 `list_mine_tasks` 内: + +```python + rows = ( + _filtered() + .order_by( + BizQcTask.create_time.desc(), + BizQcWorkOrder.work_order_no.desc(), + BizQcTask.task_no.asc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) +``` + +替换为: + +```python + q_page = _filtered() + if uses_inbound_lab_storage_list_sort(reception_source): + q_page = q_page.order_by( + *inbound_lab_storage_order_by_clauses(), + BizQcWorkOrder.work_order_no.desc(), + BizQcTask.task_no.asc(), + ) + else: + q_page = q_page.order_by( + BizQcTask.create_time.desc(), + BizQcWorkOrder.work_order_no.desc(), + BizQcTask.task_no.asc(), + ) + rows = ( + q_page + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) +``` + +注意:确认 `list_mine_tasks` 形参名确为 `reception_source`(与现有签名一致)。 + +- [ ] **Step 2: Sanity import check** + +```bash +cd backend && source venv/bin/activate && python -c "from app.crud.qc_task import list_mine_tasks; print('ok')" +``` + +Expected: `ok` + +- [ ] **Step 3: Commit**(仅当用户明确要求提交时执行) + +```bash +git add backend/app/crud/qc_task.py +git commit -m "$(cat <<'EOF' +feat: order mine QC tasks by lab storage code on inbound tabs + +EOF +)" +``` + +--- + +### Task 4: 前端统一比较工具 + +**Files:** +- Create: `web-admin/src/utils/qc-inbound-lab-storage-sort.ts` + +web-admin 无单测 runner;用纯函数 + 节点内联断言冒烟(或手动对照设计文档)。 + +- [ ] **Step 1: Add compare helpers** + +```typescript +// web-admin/src/utils/qc-inbound-lab-storage-sort.ts + +/** 编码 DESC → 时间 DESC;负值表示 a 排在 b 前(与后端一致) */ +export function compareLabStorageThenCreateTimeDesc( + codeA: string | null | undefined, + timeA: string | null | undefined, + codeB: string | null | undefined, + timeB: string | null | undefined +): number { + const ca = String(codeA || '').trim() + const cb = String(codeB || '').trim() + if (ca !== cb) { + return ca > cb ? -1 : 1 + } + const ta = String(timeA || '').trim() + const tb = String(timeB || '').trim() + if (ta !== tb) { + return ta > tb ? -1 : 1 + } + return 0 +} + +export function resolveGroupLabStorageCode( + rows: { labStorageCode?: string | null }[] +): string { + for (const row of rows) { + const code = String(row.labStorageCode || '').trim() + if (code) return code + } + return '' +} + +export function resolveGroupMaxCreateTime( + rows: { createTime?: string | null }[] +): string { + let max = '' + for (const row of rows) { + const t = String(row.createTime || '') + if (t > max) max = t + } + return max +} +``` + +- [ ] **Step 2: Smoke-check in Node(可选)** + +在仓库根用短脚本确认比较方向(复制函数体或通过构建后的 bundle 非必须;优先在实现后浏览器验)。 + +- [ ] **Step 3: Commit**(仅当用户明确要求提交时执行) + +```bash +git add web-admin/src/utils/qc-inbound-lab-storage-sort.ts +git commit -m "$(cat <<'EOF' +feat: add frontend inbound lab storage sort compare helpers + +EOF +)" +``` + +--- + +### Task 5: 入库组内与样本卡片排序 + +**Files:** +- Modify: `web-admin/src/utils/qc-work-order-groups.ts` +- Modify: `web-admin/src/utils/qc-work-order-inbound-sample-cards.ts` + +- [ ] **Step 1: Inbound-only within-group sort** + +在 `qc-work-order-groups.ts`: + +保留现有 `sortWorkOrdersInGroup`(出库继续用)。新增: + +```typescript +function sortWorkOrdersInInboundGroup(rows: QcWorkOrder[]) { + return [...rows].sort((a, b) => + compareLabStorageThenCreateTimeDesc( + '', // 同组编码相同,仅比时间;传空避免干扰 + a.createTime, + '', + b.createTime + ) + ) +} +``` + +更直接: + +```typescript +function sortWorkOrdersInInboundGroup(rows: QcWorkOrder[]) { + return [...rows].sort((a, b) => + String(b.createTime || '').localeCompare(String(a.createTime || '')) + ) +} +``` + +将 `groupInboundQcWorkOrders` 内 `sortWorkOrdersInGroup(workOrders)` 改为 `sortWorkOrdersInInboundGroup(workOrders)`。 + +`groupOutboundQcWorkOrders` 仍调用 `sortWorkOrdersInGroup`。 + +顶部增加(若用 compare 工具则 import;若仅 localeCompare 可省略): + +```typescript +// 无需改 outbound +``` + +- [ ] **Step 2: Card list order in `buildInboundSampleListItems`** + +在 `qc-work-order-inbound-sample-cards.ts` 增加 import: + +```typescript +import { + compareLabStorageThenCreateTimeDesc, + resolveGroupLabStorageCode, + resolveGroupMaxCreateTime +} from '@/utils/qc-inbound-lab-storage-sort' +``` + +将末尾: + +```typescript + const sortedGroups = [...groups].sort((a, b) => { + const headA = a.workOrders[0] + const headB = b.workOrders[0] + if (!headA || !headB) return 0 + const protocolA = resolveInboundProtocolGroupKey(headA) + const protocolB = resolveInboundProtocolGroupKey(headB) + if (protocolA !== protocolB) { + return protocolSortTime(b).localeCompare(protocolSortTime(a)) + } + return (a.sampleCode || '').localeCompare(b.sampleCode || '') + }) +``` + +替换为: + +```typescript + const sortedGroups = [...groups].sort((a, b) => + compareLabStorageThenCreateTimeDesc( + resolveGroupLabStorageCode(a.workOrders), + resolveGroupMaxCreateTime(a.workOrders), + resolveGroupLabStorageCode(b.workOrders), + resolveGroupMaxCreateTime(b.workOrders) + ) + ) +``` + +若 `protocolSortTime` 不再被本文件使用,删除该死函数。 + +同协议胶囊内按 `sampleCode` 的排序(`buildLinksFromVisibleGroups` 等)**保持不变**。 + +- [ ] **Step 3: Commit**(仅当用户明确要求提交时执行) + +```bash +git add web-admin/src/utils/qc-work-order-groups.ts web-admin/src/utils/qc-work-order-inbound-sample-cards.ts +git commit -m "$(cat <<'EOF' +feat: sort inbound QC sample cards by lab storage code then create time + +EOF +)" +``` + +--- + +### Task 6: 我的质检任务分页前排序 + +**Files:** +- Modify: `web-admin/src/utils/qc-mine-task-groups.ts` +- Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts` + +- [ ] **Step 1: Export sorter** + +在 `qc-mine-task-groups.ts` 增加: + +```typescript +import { compareLabStorageThenCreateTimeDesc } from '@/utils/qc-inbound-lab-storage-sort' +import { isInboundSampleQcTab } from '@/utils/qc-work-order-tabs' + +export function sortMineEntriesForInboundLabStorage( + entries: MineWorkOrderEntry[] +): MineWorkOrderEntry[] { + return [...entries].sort((a, b) => + compareLabStorageThenCreateTimeDesc( + a.workOrder.labStorageCode, + a.workOrder.createTime, + b.workOrder.labStorageCode, + b.workOrder.createTime + ) + ) +} +``` + +确认 `isInboundSampleQcTab` 已从 tabs 导出;若已有同类 import,合并到现有 import 块。 + +- [ ] **Step 2: Call before paginate in mine `loadData`** + +在 `useQcWorkOrderTabPage.ts` 的 mine 分支,在 `filterMineEntriesByWorkOrderSearch` 之后、`paginateMineEntries` 之前: + +```typescript + let entries = groupMineTasksByWorkOrder(tasks) + entries = filterMineEntriesByWorkOrderSearch(entries, buildMineWorkOrderSearchFilters()) + if (isInboundSampleQcTab(tabKey.value)) { + entries = sortMineEntriesForInboundLabStorage(entries) + } + const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize) +``` + +补充 import:`sortMineEntriesForInboundLabStorage`、`isInboundSampleQcTab`(后者可能已导入)。 + +- [ ] **Step 3: Commit**(仅当用户明确要求提交时执行) + +```bash +git add web-admin/src/utils/qc-mine-task-groups.ts web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts +git commit -m "$(cat <<'EOF' +feat: sort mine inbound QC entries by lab storage code before paging + +EOF +)" +``` + +--- + +### Task 7: 图谱更新与手工验收 + +**Files:** +- 无业务代码;跑 `scripts/update-graph.sh` + +- [ ] **Step 1: Update knowledge graph** + +```bash +./scripts/update-graph.sh all +``` + +- [ ] **Step 2: Manual QA checklist** + +1. 打开 `/#/quality-group/quality-manage/qc-work-order?tab=inbound_cell_storage`:卡片标题(实验室入库编码)整体倒序;同编码时创建时间新的在上;翻页后仍符合该序。 +2. 切换 `inbound_research_donation`:同上。 +3. `/#/quality-group/quality-manage/my-qc-task` 对应两 Tab:同上。 +4. 无编码卡片按空串参与倒序(相对有编码的位置与字符串 DESC 一致)。 +5. 「全部 / 出库 / 临时」Tab 顺序与交互无明显回归;同协议样本胶囊顺序仍按样本编码。 + +- [ ] **Step 3: Mark design status(可选)** + +将 `docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md` 状态改为 `已实现`。 + +--- + +## Spec coverage (self-review) + +| Spec 项 | Task | +|---------|------| +| 仅两页两 Tab | Task 2/3 条件 `uses_inbound_*`;Task 5/6 仅 inbound | +| 编码 DESC + 时间 DESC | Task 1–6 | +| 空串参与倒序 | Task 1 测试 + COALESCE/COALESCE | +| 后端分页 | Task 2 | +| 我的任务 | Task 3 + Task 6 | +| 组内时间倒序、出库不变 | Task 5 | +| 互链不改 | Task 5 明确保留 | +| 无 DB 迁移 | 全程 JOIN/子查询 | + +无 TBD 占位;前后端比较函数命名与语义一致(负值 = a 在前)。 diff --git a/docs/superpowers/plans/2026-08-17-sample-reception-protocol-fee-trigger.md b/docs/superpowers/plans/2026-08-17-sample-reception-protocol-fee-trigger.md new file mode 100644 index 00000000..0336a7e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-sample-reception-protocol-fee-trigger.md @@ -0,0 +1,26 @@ +# 样本接收协议费用按需展示 Implementation Plan + +> **For agentic workers:** 单文件小改动,可直接按任务执行。 + +**Goal:** 样本接收卡片右上角「协议费用」按钮,点击弹窗查看完整费用。 + +**Architecture:** `SampleReceptionCardList` 标题行右侧按钮 + 单例 `el-dialog` 嵌入 `QcWorkOrderProtocolPaymentPanel`。 + +**Tech Stack:** Vue 3 + Element Plus(web-admin) + +--- + +### Task 1: 弹窗模式 + 右上角按钮 + +**Files:** +- Modify: `web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue` + +- [x] 标题行右上角「协议费用」按钮(`margin-left: auto`) +- [x] 点击打开弹窗,展示完整协议费用面板(`embedded`) +- [x] 移除卡片内联展开/收起逻辑 + +### Task 2: 手动验收 + +- [ ] 按钮在卡片右上角 +- [ ] 点击弹出费用明细;关闭后卡片无费用区域 +- [ ] 科研捐献卡片仍无协议费用入口 diff --git a/docs/superpowers/specs/2026-08-17-qc-batch-name-lab-storage-code-design.md b/docs/superpowers/specs/2026-08-17-qc-batch-name-lab-storage-code-design.md new file mode 100644 index 00000000..0a2e7308 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-qc-batch-name-lab-storage-code-design.md @@ -0,0 +1,38 @@ +# 质检工单配置检测 · 批次名称默认值 — 设计文档 + +> 日期:2026-08-17 +> 状态:已实现 +> 范围:`web-admin` 【质检工单】配置检测 / 调整检测弹窗 + +--- + +## 1. 背景 + +当前新增检测批次时,【批次名称】默认 `YYYYMMDD检测`(冲突加 `-2`、`-3`…)。 +细胞存储 / 科研捐赠质检希望默认使用工单上的**实验室入库编码**(如 `B0000090`、`BC0000015`)。 + +## 2. 已确认规则 + +| 条件 | 新增批次默认「批次名称」 | +|------|--------------------------| +| `receptionSource` 为 `cell_storage` 或 `research_donation`,且有 `labStorageCode` | 一律用该编码;多批次相同;允许用户再改 | +| 同上,但无 `labStorageCode` | 空字符串,用户自填 | +| 其它来源(临时 / 出库等) | 保持 `YYYYMMDD检测`,冲突加 `-2`、`-3`… | + +覆盖点:打开弹窗时的首个默认批次 +「添加批次」。 +不改已保存批次名;不改后端接口。 + +## 3. 实现要点(方案 1) + +1. 在 `qc-inspection-batch.ts` 抽取 `resolveDefaultQcBatchName(...)` 作为唯一默认名规则。 +2. `index.vue` 打开配置弹窗时记住当前工单的 `receptionSource`、`labStorageCode`,并传给 `QcWorkOrderConfigureBatches`;首个默认批次走同一规则。 +3. `QcWorkOrderConfigureBatches` 的「添加批次」调用同一规则(入库类型不做名称去重后缀)。 + +## 5. 批次负责人默认值(补充) + +点击「添加批次」时: + +1. 弹窗内已有批次 → 复制**列表最后一批**的 `assigneeUserIds`(多选原样带上) +2. 弹窗内无批次 → 仍为:当前登录用户在「质检专员」下拉里则默认自己,否则空 + +打开弹窗首个空默认批次(无历史)仍走规则 2。 diff --git a/docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md b/docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md new file mode 100644 index 00000000..6ffc07a2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-qc-inbound-lab-storage-code-sort-design.md @@ -0,0 +1,78 @@ +# 入库质检列表按实验室入库编码排序 — 设计文档 + +> 日期:2026-08-17 +> 状态:已实现 +> 范围:【质检工单】【我的质检任务】的「细胞存储」「科研捐赠」Tab + +--- + +## 1. 背景 + +当前入库列表后端按工单 `create_time DESC` 分页;前端卡片再按「协议组最新创建时间 + 样本编码」重排,组内工单另按触发来源权重 + 创建时间升序。用户希望细胞存储 / 科研捐赠统一按**实验室入库编码倒序**,同编码下按**创建时间倒序**,且跨页一致。 + +## 2. 已确认规则 + +| 项 | 约定 | +|----|------| +| 页面 | 仅【质检工单】+【我的质检任务】 | +| Tab | 仅 `inbound_cell_storage`、`inbound_research_donation` | +| 主排序 | `lab_storage_code` / `labStorageCode` 字符串倒序 | +| 次排序 | 工单 `create_time` / `createTime` 倒序 | +| 空编码 | 按空字符串 `''` 直接参与倒序比较 | +| 分页 | 后端按上述规则排序后再分页(跨页一致) | +| 其它 Tab | 「全部 / 出库 / 临时」等不改 | + +## 3. 方案(方案 1) + +后端入库两 Tab 专用 `ORDER BY`(JOIN 样本接收取编码)+ 前端展示排序对齐同一规则。 + +不采用:仅前端页内重排;工单表冗余 `lab_storage_code`。 + +## 4. 后端 + +### 4.1 编码来源 + +`lab_storage_code` 在 `biz_sample_reception`,不在 `biz_qc_work_order`。列表排序通过 **LEFT JOIN** 取得,`COALESCE(lab_storage_code, '')`。 + +JOIN 对齐现有 enrich 语义:优先 `BizQcWorkOrder.reception_id`;无接收 ID 时再按 `sample_code + tenant`(及必要的 `del_flag`)关联,避免生产触发等无 `reception_id` 的工单编码全空导致乱序。 + +### 4.2 质检工单 `list_work_orders` + +- 当 `reception_source ∈ {cell_storage, research_donation}`: + `ORDER BY COALESCE(lab_storage_code, '') DESC, BizQcWorkOrder.create_time DESC` +- 其它来源:保持 `create_time DESC` +- 不改筛选条件与返回字段结构 + +### 4.3 我的质检任务 `list_mine_tasks` + +- 查询已 JOIN 工单;当请求带入库两 Tab 的 `reception_source` 时: + `ORDER BY COALESCE(lab_storage_code, '') DESC, BizQcWorkOrder.create_time DESC`,并以 `work_order_no` / `task_no` 作为稳定次序 +- 其它 Tab:保持现有任务 `create_time DESC` 等规则 +- 前端仍 `fetchAllMineTasks` 拉全量;聚合后按同一规则排序再客户端分页 + +## 5. 前端 + +| 文件 | 改动 | +|------|------| +| `qc-work-order-inbound-sample-cards.ts` | `buildInboundSampleListItems`:卡片序改为编码倒序 → 组内最大 `createTime` 倒序;去掉「协议组再样本编码」主序 | +| `qc-work-order-groups.ts` | **仅入库路径**组内排序改为 `createTime` 倒序(取消触发来源权重);出库 `groupOutboundQcWorkOrders` 保持原组内规则不变 | +| `qc-mine-task-groups.ts` | 入库两 Tab:分页前对工单条目按编码 DESC → 工单创建时间 DESC 排序 | + +**不改**:同协议样本胶囊内按 `sampleCode` 的互链排序;全部 / 出库 / 临时展示逻辑。 + +卡片代表字段: + +- 编码:组内首个非空 `labStorageCode`,否则 `''` +- 时间:组内最大 `createTime`(同编码下新的在前) + +## 6. 验收 + +1. 细胞存储、科研捐赠:两页列表均为「编码倒序,同编码创建时间倒序」;翻页顺序连续。 +2. 无编码按空串参与倒序;同一样本多工单时间新的在上。 +3. 其它 Tab 无回归。 + +## 7. 非目标 + +- 数据库迁移 / 工单表冗余字段 +- 修改「全部」Tab 中入库卡片的全局排序策略 +- 改变同协议互链胶囊的展示顺序规则 diff --git a/docs/superpowers/specs/2026-08-17-sample-reception-protocol-fee-trigger-design.md b/docs/superpowers/specs/2026-08-17-sample-reception-protocol-fee-trigger-design.md new file mode 100644 index 00000000..8ed6a197 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-sample-reception-protocol-fee-trigger-design.md @@ -0,0 +1,18 @@ +# 样本接收 · 协议费用按需展示 + +## 背景 + +样本接收列表卡片默认展示整块「协议费用」占用空间。改为按需查看。 + +## 决策 + +仅在 `SampleReceptionCardList.vue` 改造: + +1. 「协议费用」按钮放在卡片标题行右上角(`margin-left: auto`)。 +2. 点击后以弹窗展示完整 `QcWorkOrderProtocolPaymentPanel`(非 compact、`embedded`,避免与弹窗标题重复)。 +3. 不改共享组件;质检工单等其它页保持原交互。 + +## 非目标 + +- 不改其它列表的协议费用交互。 +- 不新增后端字段或接口。 diff --git a/mobile-h5/static/menu-icons/start-approval.png b/mobile-h5/static/menu-icons/start-approval.png index 8101e5c2..f798a42b 100644 Binary files a/mobile-h5/static/menu-icons/start-approval.png and b/mobile-h5/static/menu-icons/start-approval.png differ diff --git a/web-admin/src/styles/_route-config.scss b/web-admin/src/styles/_route-config.scss index 1d553f88..61ea2825 100644 --- a/web-admin/src/styles/_route-config.scss +++ b/web-admin/src/styles/_route-config.scss @@ -237,22 +237,37 @@ } .route-config-step-actions { display: flex; - flex-direction: column; + flex-direction: row; + flex-wrap: wrap; align-items: center; - justify-content: flex-start; - gap: 2px; + justify-content: center; + gap: 0; flex-shrink: 0; + width: 48px; margin-left: auto; padding-top: 2px; .el-tag { margin: 0; + flex: 1 0 100%; + justify-content: center; + height: 18px; + padding: 0 3px; + font-size: 10px; + line-height: 16px; } .el-button.is-link { margin: 0; - padding: 2px; - height: auto; + padding: 0; + min-height: 20px; + min-width: 20px; + width: 20px; + height: 20px; + } + + .el-icon { + font-size: 13px; } } diff --git a/web-admin/src/utils/production-step-output.test.ts b/web-admin/src/utils/production-step-output.test.ts new file mode 100644 index 00000000..89c469fa --- /dev/null +++ b/web-admin/src/utils/production-step-output.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createEmptyStepOutputRow } from './production-step-output' + +describe('createEmptyStepOutputRow', () => { + it('defaults barcode to lab storage code', () => { + const row = createEmptyStepOutputRow('LAB-001') + assert.equal(row.outputBarcode, 'LAB-001') + assert.equal(row.outputTypeId, '') + assert.equal(row.outputName, '') + assert.equal(row.remark, '') + }) + + it('trims lab storage code', () => { + assert.equal(createEmptyStepOutputRow(' LAB-002 ').outputBarcode, 'LAB-002') + }) + + it('uses empty barcode when lab storage code is missing', () => { + assert.equal(createEmptyStepOutputRow('').outputBarcode, '') + assert.equal(createEmptyStepOutputRow(null).outputBarcode, '') + assert.equal(createEmptyStepOutputRow(undefined).outputBarcode, '') + }) +}) diff --git a/web-admin/src/utils/production-step-output.ts b/web-admin/src/utils/production-step-output.ts new file mode 100644 index 00000000..f5e64b0c --- /dev/null +++ b/web-admin/src/utils/production-step-output.ts @@ -0,0 +1,9 @@ +/** 工艺步骤「产出物」新增行:条码默认实验室入库编码。 */ +export function createEmptyStepOutputRow(labStorageCode?: string | null) { + return { + outputTypeId: '', + outputName: '', + outputBarcode: String(labStorageCode || '').trim(), + remark: '' + } +} diff --git a/web-admin/src/utils/qc-inbound-lab-storage-sort.ts b/web-admin/src/utils/qc-inbound-lab-storage-sort.ts new file mode 100644 index 00000000..1a0de93f --- /dev/null +++ b/web-admin/src/utils/qc-inbound-lab-storage-sort.ts @@ -0,0 +1,40 @@ +/** 编码 DESC → 时间 DESC;负值表示 a 排在 b 前(与后端一致) */ +export function compareLabStorageThenCreateTimeDesc( + codeA: string | null | undefined, + timeA: string | null | undefined, + codeB: string | null | undefined, + timeB: string | null | undefined +): number { + const ca = String(codeA || '').trim() + const cb = String(codeB || '').trim() + if (ca !== cb) { + return ca > cb ? -1 : 1 + } + const ta = String(timeA || '').trim() + const tb = String(timeB || '').trim() + if (ta !== tb) { + return ta > tb ? -1 : 1 + } + return 0 +} + +export function resolveGroupLabStorageCode( + rows: { labStorageCode?: string | null }[] +): string { + for (const row of rows) { + const code = String(row.labStorageCode || '').trim() + if (code) return code + } + return '' +} + +export function resolveGroupMaxCreateTime( + rows: { createTime?: string | null }[] +): string { + let max = '' + for (const row of rows) { + const t = String(row.createTime || '') + if (t > max) max = t + } + return max +} diff --git a/web-admin/src/utils/qc-inspection-batch.ts b/web-admin/src/utils/qc-inspection-batch.ts index 36c5219e..bdec8e29 100644 --- a/web-admin/src/utils/qc-inspection-batch.ts +++ b/web-admin/src/utils/qc-inspection-batch.ts @@ -162,16 +162,49 @@ export function resolveBatchSections( return [] } -export function createEmptyInspectionBatch(defaultAssigneeUserIds?: string[]): QcInspectionBatchConfigureItem { +/** 细胞存储 / 科研捐赠:批次名默认用实验室入库编码 */ +const INBOUND_LAB_CODE_BATCH_SOURCES = new Set(['cell_storage', 'research_donation']) + +export type ResolveDefaultQcBatchNameOpts = { + receptionSource?: string | null + labStorageCode?: string | null + /** 已占用名称;仅非入库类型做 YYYYMMDD检测 去重时使用 */ + usedNames?: Iterable +} + +/** + * 配置检测弹窗新增批次的默认名称。 + * - 细胞存储 / 科研捐赠:有实验室入库编码则用该编码(多批次相同);无则空串 + * - 其它类型:YYYYMMDD检测,冲突则 -2、-3… + */ +export function resolveDefaultQcBatchName(opts: ResolveDefaultQcBatchNameOpts = {}): string { + const source = String(opts.receptionSource || '').trim() + if (INBOUND_LAB_CODE_BATCH_SOURCES.has(source)) { + return String(opts.labStorageCode || '').trim() + } const today = new Date() const y = today.getFullYear() const m = String(today.getMonth() + 1).padStart(2, '0') const d = String(today.getDate()).padStart(2, '0') + const base = `${y}${m}${d}检测` + const used = new Set( + [...(opts.usedNames || [])].map((n) => String(n || '').trim()).filter(Boolean) + ) + if (!used.has(base)) return base + let n = 2 + while (used.has(`${base}-${n}`)) n += 1 + return `${base}-${n}` +} + +export function createEmptyInspectionBatch( + defaultAssigneeUserIds?: string[], + nameOpts?: ResolveDefaultQcBatchNameOpts +): QcInspectionBatchConfigureItem { const assigneeUserIds = (defaultAssigneeUserIds || []) .map((id) => String(id || '').trim()) .filter(Boolean) return { - batchName: `${y}${m}${d}检测`, + batchName: resolveDefaultQcBatchName(nameOpts), assigneeUserIds, hasLab: false, hasThirdParty: false, diff --git a/web-admin/src/utils/qc-mine-task-groups.ts b/web-admin/src/utils/qc-mine-task-groups.ts index 87603bcd..91ff54b3 100644 --- a/web-admin/src/utils/qc-mine-task-groups.ts +++ b/web-admin/src/utils/qc-mine-task-groups.ts @@ -18,6 +18,7 @@ import { workOrderMatchesCategory } from '@/utils/qc-work-order-category' import { matchesWorkOrderStatusFilter } from '@/utils/status-tag-registry' +import { compareLabStorageThenCreateTimeDesc } from '@/utils/qc-inbound-lab-storage-sort' export type QcTaskMineFilters = { keyword?: string @@ -173,6 +174,19 @@ export function paginateMineEntries(items: T[], page: number, pageSize: numbe } } +export function sortMineEntriesForInboundLabStorage( + entries: MineWorkOrderEntry[] +): MineWorkOrderEntry[] { + return [...entries].sort((a, b) => + compareLabStorageThenCreateTimeDesc( + a.workOrder.labStorageCode, + a.workOrder.createTime, + b.workOrder.labStorageCode, + b.workOrder.createTime + ) + ) +} + export function buildMineTableRows(pageEntries: MineWorkOrderEntry[]): { rows: QcWorkOrder[] presetTasksByWorkOrderId: Record diff --git a/web-admin/src/utils/qc-work-order-groups.ts b/web-admin/src/utils/qc-work-order-groups.ts index a150f785..9dbf0edb 100644 --- a/web-admin/src/utils/qc-work-order-groups.ts +++ b/web-admin/src/utils/qc-work-order-groups.ts @@ -23,6 +23,12 @@ function sortWorkOrdersInGroup(rows: QcWorkOrder[]) { }) } +function sortWorkOrdersInInboundGroup(rows: QcWorkOrder[]) { + return [...rows].sort((a, b) => + String(b.createTime || '').localeCompare(String(a.createTime || '')) + ) +} + /** 入库 Tab:按样本编码分组(前端分组,分页仍按工单条数) */ export function groupInboundQcWorkOrders(rows: QcWorkOrder[]): QcWorkOrderSampleGroupModel[] { const map = new Map() @@ -33,7 +39,7 @@ export function groupInboundQcWorkOrders(rows: QcWorkOrder[]): QcWorkOrderSample else map.set(key, [row]) } return Array.from(map.entries()).map(([groupKey, workOrders]) => { - const sorted = sortWorkOrdersInGroup(workOrders) + const sorted = sortWorkOrdersInInboundGroup(workOrders) return { groupKey, sampleCode: sorted[0]?.sampleCode || '', diff --git a/web-admin/src/utils/qc-work-order-inbound-sample-cards.ts b/web-admin/src/utils/qc-work-order-inbound-sample-cards.ts index 065b6e8b..40b4392f 100644 --- a/web-admin/src/utils/qc-work-order-inbound-sample-cards.ts +++ b/web-admin/src/utils/qc-work-order-inbound-sample-cards.ts @@ -1,4 +1,9 @@ import type { QcProtocolSiblingSample, QcWorkOrder } from '@/api/qc-work-order' +import { + compareLabStorageThenCreateTimeDesc, + resolveGroupLabStorageCode, + resolveGroupMaxCreateTime +} from '@/utils/qc-inbound-lab-storage-sort' import { groupInboundQcWorkOrders, resolveInboundProtocolGroupKey, @@ -33,15 +38,6 @@ function sampleTypeNameForGroup(group: QcWorkOrderSampleGroupModel) { return (gate?.sampleTypeName || group.workOrders[0]?.sampleTypeName || '').trim() } -function protocolSortTime(group: QcWorkOrderSampleGroupModel): string { - let max = '' - for (const wo of group.workOrders) { - const t = String(wo.createTime || '') - if (t > max) max = t - } - return max -} - function resolveProtocolSiblingSamplesForGroup( group: QcWorkOrderSampleGroupModel ): QcProtocolSiblingSample[] { @@ -140,17 +136,14 @@ export function buildInboundSampleListItems(rows: QcWorkOrder[]): QcInboundSampl } } - const sortedGroups = [...groups].sort((a, b) => { - const headA = a.workOrders[0] - const headB = b.workOrders[0] - if (!headA || !headB) return 0 - const protocolA = resolveInboundProtocolGroupKey(headA) - const protocolB = resolveInboundProtocolGroupKey(headB) - if (protocolA !== protocolB) { - return protocolSortTime(b).localeCompare(protocolSortTime(a)) - } - return (a.sampleCode || '').localeCompare(b.sampleCode || '') - }) + const sortedGroups = [...groups].sort((a, b) => + compareLabStorageThenCreateTimeDesc( + resolveGroupLabStorageCode(a.workOrders), + resolveGroupMaxCreateTime(a.workOrders), + resolveGroupLabStorageCode(b.workOrders), + resolveGroupMaxCreateTime(b.workOrders) + ) + ) return sortedGroups.map((group) => ({ group, diff --git a/web-admin/src/views/operations/storage-facility/index.vue b/web-admin/src/views/operations/storage-facility/index.vue index e5f19aaf..da192f8c 100644 --- a/web-admin/src/views/operations/storage-facility/index.vue +++ b/web-admin/src/views/operations/storage-facility/index.vue @@ -130,7 +130,7 @@ @click="handleBatchOutbound" > - 出库已选 + 批量出库 @@ -2538,6 +2538,7 @@ .shelf-scroll-wrapper { overflow: auto; max-height: 100%; + padding-top: 12px; } .shelf-container { diff --git a/web-admin/src/views/production/cell-product/index.vue b/web-admin/src/views/production/cell-product/index.vue index 7a5d9589..8527fc55 100644 --- a/web-admin/src/views/production/cell-product/index.vue +++ b/web-admin/src/views/production/cell-product/index.vue @@ -1406,6 +1406,14 @@ async function remoteSearchStorageSlots(query: string) { const token = ++storageSlotSearchToken const trimmed = (query ?? '').trim() + // 先记下当前选中格的展示名,避免远程结果分页未带回时 el-select 回退显示 id + const keepStubs = (storageSlotSearchOpts.value.includeSlotIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + .map((id) => { + const hit = storageSlotOptions.value.find((o) => o.id === id) + return { id, name: hit?.name } + }) if (trimmed) storageSlotOptions.value = [] storageSlotOptionsLoading.value = true try { @@ -1414,10 +1422,10 @@ includeSlotIds: storageSlotSearchOpts.value.includeSlotIds }) if (token !== storageSlotSearchToken) return - storageSlotOptions.value = list ?? [] + storageSlotOptions.value = ensureStorageSlotsInOptions(list ?? [], keepStubs) } catch { if (token !== storageSlotSearchToken) return - storageSlotOptions.value = [] + storageSlotOptions.value = ensureStorageSlotsInOptions([], keepStubs) } finally { if (token === storageSlotSearchToken) { storageSlotOptionsLoading.value = false diff --git a/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue b/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue index 2e9a417f..8a6f9d63 100644 --- a/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue +++ b/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue @@ -16,9 +16,6 @@ >
- 工单号:{{ workOrderRow?.workOrderNo }} 样本编码: + 实验室入库编码:{{ headerLabStorageCode }}
{ + const row = props.workOrderRow + if (!row) return '—' + const fromRow = String(row.labStorageCode || '').trim() + if (fromRow) return fromRow + const fromReception = String( + (row.reception as Record | undefined)?.labStorageCode || '' + ).trim() + return fromReception || '—' + }) + function resolveCreatedTaskId(task: Record | null | undefined) { const raw = task?.id ?? task?.taskId ?? task?.data?.id return String(raw || '').trim() diff --git a/web-admin/src/views/production/production-work-order/StepDetailPanel.vue b/web-admin/src/views/production/production-work-order/StepDetailPanel.vue index 5c96132f..977335a3 100644 --- a/web-admin/src/views/production/production-work-order/StepDetailPanel.vue +++ b/web-admin/src/views/production/production-work-order/StepDetailPanel.vue @@ -589,13 +589,13 @@
-
+
diff --git a/web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue b/web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue index d32df053..753a224e 100644 --- a/web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue +++ b/web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue @@ -429,6 +429,14 @@ async function remoteSearchStorageSlots(query: string) { const token = ++storageSlotSearchToken const trimmed = (query ?? '').trim() + // 先记下当前选中格的展示名,避免远程结果分页未带回时 el-select 回退显示 id + const keepStubs = (storageSlotSearchOpts.value.includeSlotIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + .map((id) => { + const hit = storageSlotOptions.value.find((o) => o.id === id) + return { id, name: hit?.name } + }) if (trimmed) storageSlotOptions.value = [] storageSlotOptionsLoading.value = true try { @@ -437,10 +445,10 @@ includeSlotIds: storageSlotSearchOpts.value.includeSlotIds }) if (token !== storageSlotSearchToken) return - storageSlotOptions.value = list ?? [] + storageSlotOptions.value = ensureStorageSlotsInOptions(list ?? [], keepStubs) } catch { if (token !== storageSlotSearchToken) return - storageSlotOptions.value = [] + storageSlotOptions.value = ensureStorageSlotsInOptions([], keepStubs) } finally { if (token === storageSlotSearchToken) { storageSlotOptionsLoading.value = false diff --git a/web-admin/src/views/production/production-work-order/composables/useProductionWorkOrderTabPage.ts b/web-admin/src/views/production/production-work-order/composables/useProductionWorkOrderTabPage.ts index 191d5a41..018e1e9d 100644 --- a/web-admin/src/views/production/production-work-order/composables/useProductionWorkOrderTabPage.ts +++ b/web-admin/src/views/production/production-work-order/composables/useProductionWorkOrderTabPage.ts @@ -31,6 +31,9 @@ import { computeWorkOrderPlannedStartTimeRange } from '@/utils/production-labels export type ProductionWorkOrderTabPageMode = 'manage' | 'mine' +/** 默认筛选:待配置 + 进行中(不含已完成 / 已作废) */ +const DEFAULT_WORK_ORDER_STATUS_FILTER = ['dpz', 'jxz'] + export function useProductionWorkOrderTabPage( tabKey: Ref, pageMode: Ref | ProductionWorkOrderTabPageMode @@ -43,7 +46,7 @@ export function useProductionWorkOrderTabPage( const searchForm = reactive({ productionCategory: '', keyword: '', - workOrderStatus: [] as string[], + workOrderStatus: [...DEFAULT_WORK_ORDER_STATUS_FILTER], taskStatus: '', excludeVoidProtocol: true }) @@ -182,7 +185,7 @@ export function useProductionWorkOrderTabPage( function handleReset() { searchForm.productionCategory = '' searchForm.keyword = '' - searchForm.workOrderStatus = [] + searchForm.workOrderStatus = [...DEFAULT_WORK_ORDER_STATUS_FILTER] searchForm.taskStatus = '' searchForm.excludeVoidProtocol = true plannedStartTimeRange.value = null diff --git a/web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue b/web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue index 83aed8c1..c3dbba70 100644 --- a/web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue +++ b/web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue @@ -30,8 +30,6 @@ placeholder="全部" clearable multiple - collapse-tags - collapse-tags-tooltip style="width: 100%" > - - - - - - - - - 停用 - - - - {{ item.itemName }} - - -

暂无启用的实验室项目

-
- -
-
{{ group.companyName || '未命名公司' }}
- - - {{ item.itemName }} - - -
-

暂无启用的第三方项目

-
+ +
+
+
+ 已选项目 + 共 {{ selectedItemCount }} 项 +
+
+
+ 实验室自检 +
+ + {{ item.itemName }} + +
+
+
+ 第三方检测 +
+
+ {{ + group.companyName || '未命名公司' + }} +
+ + {{ item.itemName }} + +
+
+
+
+
+

暂未选择检测项目

+
+ +
+
+
实验室自检项目
+
+ + + {{ item.itemName }} + + +

暂无启用的实验室项目

+
+
+ +
+
第三方检测项目
+
+ +

暂无启用的第三方项目

+
+
+
+
+