From 50679fa75e83a5744ec1a8d30eaa2425b5ed69fd Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 13 Nov 2026 11:01:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(production):=20UC=20=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=87=AA=E5=8A=A8=E6=B4=BE=E5=8F=91=E8=84=90?= =?UTF-8?q?=E5=B8=A6MSC=E4=BA=94=E6=AD=A5=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 样本接收 UC… 此前无内置匹配规则导致工单空任务;新增 5 步原代培养模版与回补脚本,且不影响 BC 六步模版。 Co-authored-by: Cursor --- .../app/services/uc_msc_builtin_template.py | 472 ++++++++++++++++++ .../app/utils/lab_storage_code_task_set.py | 16 +- .../backfill_uc_msc_production_tasks.py | 249 +++++++++ .../tests/test_lab_storage_code_task_set.py | 25 +- backend/tests/test_uc_msc_builtin_template.py | 41 ++ .../2026-11-13-uc-msc-builtin-task-set.sql | 263 ++++++++++ 6 files changed, 1058 insertions(+), 8 deletions(-) create mode 100644 backend/app/services/uc_msc_builtin_template.py create mode 100644 backend/scripts/backfill_uc_msc_production_tasks.py create mode 100644 backend/tests/test_uc_msc_builtin_template.py create mode 100644 docs/sql/migrations/2026-11-13-uc-msc-builtin-task-set.sql diff --git a/backend/app/services/uc_msc_builtin_template.py b/backend/app/services/uc_msc_builtin_template.py new file mode 100644 index 00000000..71c0affd --- /dev/null +++ b/backend/app/services/uc_msc_builtin_template.py @@ -0,0 +1,472 @@ +"""确保 UC 入库编码对应的 5 步内置工艺路线 / 任务组合存在。 + +步骤顺序(业务确认): +1. 脐带MSC原代细胞培养 +2. 脐带MSC原代细胞培养半换液 +3. 脐带MSC原代二次贴壁 +4. 脐带MSC原代重铺操作 +5. 干细胞冻存 + +内容从既有「干细胞培养(内置模版)」按工序代码拷贝,排除传代。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.models.models import ( + BizProcessRoute, + BizProcessRouteStep, + BizProcessRouteStepConsumable, + BizProcessRouteStepDataCollection, + BizProcessRouteStepOutput, + BizProcessRouteStepReagent, + BizProductionTaskSet, + BizProductionTaskSetItem, +) +from app.core.utils import generate_uuid +from app.utils.lab_storage_code_task_set import ( + BC_MSC_BUILTIN_SET_NAME, + UC_MSC_BUILTIN_SET_NAME, +) + +UC_MSC_ROUTE_CODE = "MSC原代-UC-BUILTIN" +UC_MSC_ROUTE_NAME = UC_MSC_BUILTIN_SET_NAME +SOURCE_MSC_ROUTE_CODE = "MSC培养-BUILTIN" +SOURCE_MSC_ROUTE_NAME = BC_MSC_BUILTIN_SET_NAME + +# process_code → 目标 step_order +UC_MSC_STEP_ORDER: List[str] = [ + "SOP-SC-003-E01-01", # 原代细胞培养 + "SOP-SC-004-E01-01", # 半换液 + "SOP-SC-006-E01-01", # 二次贴壁 + "SOP-SC-005-E01-01", # 重铺 + "SOP-SC-008-E01-01", # 冻存 +] + + +def _now() -> datetime: + return datetime.now() + + +def get_uc_msc_builtin_set( + db: Session, *, tenant_no: str +) -> Optional[BizProductionTaskSet]: + return ( + db.query(BizProductionTaskSet) + .filter( + BizProductionTaskSet.tenant_no == tenant_no, + BizProductionTaskSet.set_name == UC_MSC_BUILTIN_SET_NAME, + BizProductionTaskSet.is_builtin == "1", + BizProductionTaskSet.del_flag == 0, + ) + .first() + ) + + +def _find_source_route(db: Session, *, tenant_no: str) -> BizProcessRoute: + route = ( + db.query(BizProcessRoute) + .filter( + BizProcessRoute.tenant_no == tenant_no, + BizProcessRoute.route_code == SOURCE_MSC_ROUTE_CODE, + BizProcessRoute.del_flag == 0, + ) + .first() + ) + if route: + return route + route = ( + db.query(BizProcessRoute) + .filter( + BizProcessRoute.tenant_no == tenant_no, + BizProcessRoute.route_name == SOURCE_MSC_ROUTE_NAME, + BizProcessRoute.is_builtin == "1", + BizProcessRoute.del_flag == 0, + ) + .first() + ) + if not route: + raise ValueError( + f"缺少源工艺路线 {SOURCE_MSC_ROUTE_CODE}/{SOURCE_MSC_ROUTE_NAME},无法创建 UC 模版" + ) + return route + + +def _clone_step_children( + db: Session, + *, + src_step_id: str, + dst_step_id: str, + tenant_no: str, + user_id: Optional[str], + create_dept_id: str, + now: datetime, +) -> None: + reagent_rows = ( + db.query(BizProcessRouteStepReagent) + .filter( + BizProcessRouteStepReagent.step_id == src_step_id, + BizProcessRouteStepReagent.tenant_no == tenant_no, + BizProcessRouteStepReagent.del_flag == 0, + ) + .all() + ) + for r in reagent_rows: + db.add( + BizProcessRouteStepReagent( + id=generate_uuid(), + step_id=dst_step_id, + reagent_code=r.reagent_code, + reagent_name=r.reagent_name, + spec=r.spec, + standard_usage=r.standard_usage, + unit=r.unit, + is_key_material=r.is_key_material, + tenant_no=tenant_no, + order_num=r.order_num, + remark=r.remark, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + consumable_rows = ( + db.query(BizProcessRouteStepConsumable) + .filter( + BizProcessRouteStepConsumable.step_id == src_step_id, + BizProcessRouteStepConsumable.tenant_no == tenant_no, + BizProcessRouteStepConsumable.del_flag == 0, + ) + .all() + ) + for c in consumable_rows: + db.add( + BizProcessRouteStepConsumable( + id=generate_uuid(), + step_id=dst_step_id, + consumable_code=c.consumable_code, + consumable_name=c.consumable_name, + spec_model=c.spec_model, + standard_usage=c.standard_usage, + unit=c.unit, + is_required=c.is_required, + remark=c.remark, + tenant_no=tenant_no, + order_num=c.order_num, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + output_rows = ( + db.query(BizProcessRouteStepOutput) + .filter( + BizProcessRouteStepOutput.step_id == src_step_id, + BizProcessRouteStepOutput.tenant_no == tenant_no, + BizProcessRouteStepOutput.del_flag == 0, + ) + .all() + ) + for o in output_rows: + db.add( + BizProcessRouteStepOutput( + id=generate_uuid(), + step_id=dst_step_id, + output_type_id=o.output_type_id, + output_name=o.output_name, + output_barcode=o.output_barcode, + tenant_no=tenant_no, + order_num=o.order_num, + remark=o.remark, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + dc_rows = ( + db.query(BizProcessRouteStepDataCollection) + .filter( + BizProcessRouteStepDataCollection.step_id == src_step_id, + BizProcessRouteStepDataCollection.tenant_no == tenant_no, + BizProcessRouteStepDataCollection.del_flag == 0, + ) + .all() + ) + for d in dc_rows: + db.add( + BizProcessRouteStepDataCollection( + id=generate_uuid(), + step_id=dst_step_id, + collection_name=d.collection_name, + unit=d.unit, + tenant_no=tenant_no, + order_num=d.order_num, + remark=d.remark, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + +def _source_steps_by_code( + db: Session, *, route_id: str, tenant_no: str +) -> Dict[str, BizProcessRouteStep]: + rows = ( + db.query(BizProcessRouteStep) + .filter( + BizProcessRouteStep.route_id == route_id, + BizProcessRouteStep.tenant_no == tenant_no, + BizProcessRouteStep.del_flag == 0, + ) + .all() + ) + out: Dict[str, BizProcessRouteStep] = {} + for row in rows: + code = (row.process_code or "").strip() + if code and code not in out: + out[code] = row + return out + + +def ensure_uc_msc_builtin_template( + db: Session, + *, + tenant_no: str, + user_id: Optional[str] = None, + create_dept_id: str = "", +) -> Dict[str, Any]: + """幂等创建 UC 5 步内置路线与任务组合;已存在则直接返回。""" + existing = get_uc_msc_builtin_set(db, tenant_no=tenant_no) + if existing and (existing.status or "1") == "1": + item = ( + db.query(BizProductionTaskSetItem) + .filter( + BizProductionTaskSetItem.set_id == existing.id, + BizProductionTaskSetItem.tenant_no == tenant_no, + BizProductionTaskSetItem.del_flag == 0, + ) + .order_by( + BizProductionTaskSetItem.order_num.asc(), + BizProductionTaskSetItem.create_time.asc(), + ) + .first() + ) + if item and item.route_id: + route = ( + db.query(BizProcessRoute) + .filter( + BizProcessRoute.id == item.route_id, + BizProcessRoute.tenant_no == tenant_no, + BizProcessRoute.del_flag == 0, + ) + .first() + ) + step_count = ( + db.query(BizProcessRouteStep.id) + .filter( + BizProcessRouteStep.route_id == item.route_id, + BizProcessRouteStep.tenant_no == tenant_no, + BizProcessRouteStep.del_flag == 0, + ) + .count() + ) + if route and step_count >= len(UC_MSC_STEP_ORDER): + return { + "created": False, + "setId": existing.id, + "setName": existing.set_name, + "routeId": route.id, + "routeCode": route.route_code, + "stepCount": step_count, + } + + source = _find_source_route(db, tenant_no=tenant_no) + by_code = _source_steps_by_code(db, route_id=source.id, tenant_no=tenant_no) + missing = [c for c in UC_MSC_STEP_ORDER if c not in by_code] + if missing: + raise ValueError( + "源工艺路线缺少工序,无法创建 UC 模版:" + "、".join(missing) + ) + + now = _now() + route = ( + db.query(BizProcessRoute) + .filter( + BizProcessRoute.tenant_no == tenant_no, + BizProcessRoute.route_code == UC_MSC_ROUTE_CODE, + BizProcessRoute.del_flag == 0, + ) + .first() + ) + if not route: + route = BizProcessRoute( + id=generate_uuid(), + route_code=UC_MSC_ROUTE_CODE, + route_name=UC_MSC_ROUTE_NAME, + product_type_id=source.product_type_id, + version=source.version, + is_current="0", + equipment_ids=source.equipment_ids, + equipment_name=source.equipment_name, + tenant_no=tenant_no, + order_num=0, + remark="内置模版:UC 入库编码自动派发(5 步,不含传代)", + is_builtin="1", + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(route) + db.flush() + else: + route.route_name = UC_MSC_ROUTE_NAME + route.is_builtin = "1" + route.update_by = user_id + route.update_time = now + db.add(route) + + # 重建步骤(幂等:软删旧步骤后按目标顺序插入) + old_steps = ( + db.query(BizProcessRouteStep) + .filter( + BizProcessRouteStep.route_id == route.id, + BizProcessRouteStep.tenant_no == tenant_no, + BizProcessRouteStep.del_flag == 0, + ) + .all() + ) + for s in old_steps: + s.del_flag = 1 + s.update_by = user_id + s.update_time = now + db.add(s) + db.flush() + + for order, code in enumerate(UC_MSC_STEP_ORDER, start=1): + src = by_code[code] + new_step = BizProcessRouteStep( + id=generate_uuid(), + route_id=route.id, + process_id=src.process_id, + step_order=order, + process_code=src.process_code, + category_id=src.category_id, + process_name=src.process_name, + description=src.description, + equipment_ids=src.equipment_ids, + equipment_name=src.equipment_name, + estimated_duration=src.estimated_duration, + sop_ids=src.sop_ids, + sop_content=src.sop_content, + tenant_no=tenant_no, + order_num=order, + remark=src.remark, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(new_step) + db.flush() + _clone_step_children( + db, + src_step_id=src.id, + dst_step_id=new_step.id, + tenant_no=tenant_no, + user_id=user_id, + create_dept_id=create_dept_id or "", + now=now, + ) + + header = existing or BizProductionTaskSet( + id=generate_uuid(), + set_name=UC_MSC_BUILTIN_SET_NAME, + status="1", + tenant_no=tenant_no, + order_num=0, + remark="内置模版:UC 入库编码自动派发", + is_builtin="1", + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + if existing: + header.status = "1" + header.is_builtin = "1" + header.update_by = user_id + header.update_time = now + db.add(header) + db.flush() + + items = ( + db.query(BizProductionTaskSetItem) + .filter( + BizProductionTaskSetItem.set_id == header.id, + BizProductionTaskSetItem.tenant_no == tenant_no, + BizProductionTaskSetItem.del_flag == 0, + ) + .all() + ) + for it in items: + it.del_flag = 1 + it.update_by = user_id + it.update_time = now + db.add(it) + db.flush() + + item = BizProductionTaskSetItem( + id=generate_uuid(), + set_id=header.id, + task_name="{lab_storage_code}样本处理", + route_id=route.id, + route_name=route.route_name, + description=UC_MSC_BUILTIN_SET_NAME, + remark=None, + tenant_no=tenant_no, + order_num=1, + create_by=user_id, + create_dept_id=create_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(item) + db.flush() + + return { + "created": True, + "setId": header.id, + "setName": header.set_name, + "routeId": route.id, + "routeCode": route.route_code, + "stepCount": len(UC_MSC_STEP_ORDER), + } diff --git a/backend/app/utils/lab_storage_code_task_set.py b/backend/app/utils/lab_storage_code_task_set.py index 8959b65d..b35a9993 100644 --- a/backend/app/utils/lab_storage_code_task_set.py +++ b/backend/app/utils/lab_storage_code_task_set.py @@ -2,12 +2,20 @@ from typing import List, Optional, Tuple +# 科研捐赠脐带间充质:UC… → 5 步原代培养模版(与细胞存储 BC 的 6 步模版分离) +UC_MSC_BUILTIN_SET_NAME = "脐带MSC原代培养(内置模版)" +BC_MSC_BUILTIN_SET_NAME = "干细胞培养(内置模版)" +HSC_BUILTIN_SET_NAME = "造血干样本处理(内置模版)" +UBMC_BUILTIN_SET_NAME = "UBMC冻存(内置模版)" +PBMC_BUILTIN_SET_NAME = "PBMC冻存(内置模版)" + # 最长前缀优先:BC 必须在 B 之前,避免 BC… 误匹配造血干 _LAB_CODE_BUILTIN_SET_RULES: List[Tuple[str, str]] = [ - ("UBMC", "UBMC冻存(内置模版)"), - ("PBMC", "PBMC冻存(内置模版)"), - ("BC", "干细胞培养(内置模版)"), - ("B", "造血干样本处理(内置模版)"), + ("UBMC", UBMC_BUILTIN_SET_NAME), + ("PBMC", PBMC_BUILTIN_SET_NAME), + ("UC", UC_MSC_BUILTIN_SET_NAME), + ("BC", BC_MSC_BUILTIN_SET_NAME), + ("B", HSC_BUILTIN_SET_NAME), ] diff --git a/backend/scripts/backfill_uc_msc_production_tasks.py b/backend/scripts/backfill_uc_msc_production_tasks.py new file mode 100644 index 00000000..4d64b51c --- /dev/null +++ b/backend/scripts/backfill_uc_msc_production_tasks.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""为 UC 开头实验室入库编码的入库生产工单补派 5 步脐带MSC原代任务。 + +默认 dry-run;加 --apply 才写库。会先幂等确保内置模版存在。 + +用法(在 backend 目录、已配置可连目标库的 .env): + python -m scripts.backfill_uc_msc_production_tasks + python -m scripts.backfill_uc_msc_production_tasks --apply + python -m scripts.backfill_uc_msc_production_tasks --tenant zkxw --apply +""" +from __future__ import annotations + +import argparse +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.crud import production_task_set as production_task_set_svc +from app.models.models import ( + BizProductionTask, + BizProductionTaskSet, + BizProductionWorkOrder, + BizSampleReception, +) +from app.services.uc_msc_builtin_template import ensure_uc_msc_builtin_template +from app.utils.lab_storage_code_task_set import UC_MSC_BUILTIN_SET_NAME + + +def _list_candidate_work_orders( + db: Session, *, tenant_no: str +) -> List[Tuple[BizProductionWorkOrder, BizSampleReception]]: + """UC 接收记录关联的入库工单,且当前无有效生产任务。""" + q = ( + db.query(BizProductionWorkOrder, BizSampleReception) + .join( + BizSampleReception, + BizSampleReception.id == BizProductionWorkOrder.reception_id, + ) + .filter( + BizProductionWorkOrder.tenant_no == tenant_no, + BizProductionWorkOrder.del_flag == 0, + BizProductionWorkOrder.work_order_type == "inbound", + BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")), + BizSampleReception.tenant_no == tenant_no, + BizSampleReception.del_flag == 0, + BizSampleReception.accept_conclusion == "js", + BizSampleReception.lab_storage_code.isnot(None), + BizSampleReception.lab_storage_code != "", + or_( + BizSampleReception.lab_storage_code.like("UC%"), + BizSampleReception.lab_storage_code.like("uc%"), + ), + ) + .order_by(BizSampleReception.reception_time.asc()) + ) + rows = q.all() + out: List[Tuple[BizProductionWorkOrder, BizSampleReception]] = [] + for wo, rec in rows: + has_task = ( + db.query(BizProductionTask.id) + .filter( + BizProductionTask.work_order_id == wo.id, + BizProductionTask.tenant_no == tenant_no, + BizProductionTask.del_flag == 0, + ) + .first() + ) + if has_task: + continue + code = (rec.lab_storage_code or "").strip().upper() + if not code.startswith("UC"): + continue + out.append((wo, rec)) + return out + + +def _dispatch_one( + db: Session, + *, + tenant_no: str, + user_id: Optional[str], + create_dept_id: str, + wo: BizProductionWorkOrder, + rec: BizSampleReception, + set_id: str, +) -> Dict[str, Any]: + result = production_task_set_svc.create_tasks_from_set( + db, + tenant_no=tenant_no, + user_id=user_id, + create_dept_id=create_dept_id or "", + work_order_id=wo.id, + set_id=set_id, + ) + return { + "workOrderId": wo.id, + "workOrderNo": wo.work_order_no, + "sampleCode": wo.sample_code or rec.sample_code, + "labStorageCode": rec.lab_storage_code, + "createdCount": result.get("createdCount"), + "tasks": [ + {"id": t.get("id"), "taskName": t.get("taskName"), "routeId": t.get("routeId")} + for t in (result.get("list") or []) + ], + } + + +def run( + *, + tenant_no: str, + apply: bool, + user_id: Optional[str], + create_dept_id: str, +) -> Dict[str, Any]: + db = SessionLocal() + try: + ensure_info = ensure_uc_msc_builtin_template( + db, + tenant_no=tenant_no, + user_id=user_id, + create_dept_id=create_dept_id, + ) + set_row = ( + db.query(BizProductionTaskSet) + .filter( + BizProductionTaskSet.tenant_no == tenant_no, + BizProductionTaskSet.set_name == UC_MSC_BUILTIN_SET_NAME, + BizProductionTaskSet.is_builtin == "1", + BizProductionTaskSet.status == "1", + BizProductionTaskSet.del_flag == 0, + ) + .first() + ) + if not set_row: + raise RuntimeError("UC 内置任务组合创建失败") + + candidates = _list_candidate_work_orders(db, tenant_no=tenant_no) + preview = [ + { + "workOrderId": wo.id, + "workOrderNo": wo.work_order_no, + "workOrderStatus": wo.work_order_status, + "sampleCode": wo.sample_code or rec.sample_code, + "labStorageCode": rec.lab_storage_code, + "receptionId": rec.id, + "receptionTime": str(rec.reception_time) if rec.reception_time else None, + } + for wo, rec in candidates + ] + + if not apply: + db.rollback() + return { + "dryRun": True, + "template": ensure_info, + "candidateCount": len(preview), + "candidates": preview, + "message": "dry-run:未写库。确认后加 --apply 执行补派。", + } + + dispatched: List[Dict[str, Any]] = [] + errors: List[Dict[str, Any]] = [] + for wo, rec in candidates: + try: + dispatched.append( + _dispatch_one( + db, + tenant_no=tenant_no, + user_id=user_id, + create_dept_id=create_dept_id, + wo=wo, + rec=rec, + set_id=set_row.id, + ) + ) + except Exception as exc: # noqa: BLE001 - 汇总后继续 + errors.append( + { + "workOrderId": wo.id, + "workOrderNo": wo.work_order_no, + "labStorageCode": rec.lab_storage_code, + "error": str(exc), + } + ) + db.commit() + return { + "dryRun": False, + "template": ensure_info, + "candidateCount": len(preview), + "dispatchedCount": len(dispatched), + "dispatched": dispatched, + "errorCount": len(errors), + "errors": errors, + } + except Exception: + db.rollback() + raise + finally: + db.close() + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="回补 UC 入库编码生产工单的 5 步脐带MSC原代任务" + ) + parser.add_argument( + "--tenant", + default="zkxw", + help="租户编号,默认 zkxw", + ) + parser.add_argument( + "--apply", + action="store_true", + help="实际写库;默认仅 dry-run", + ) + parser.add_argument( + "--user-id", + default=None, + help="写库 create_by/update_by,可选", + ) + parser.add_argument( + "--create-dept-id", + default="", + help="写库 create_dept_id,可选", + ) + args = parser.parse_args(argv) + + result = run( + tenant_no=(args.tenant or "zkxw").strip() or "zkxw", + apply=bool(args.apply), + user_id=(args.user_id or None), + create_dept_id=(args.create_dept_id or ""), + ) + import json + + print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) + if result.get("errorCount"): + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_lab_storage_code_task_set.py b/backend/tests/test_lab_storage_code_task_set.py index 530cc23a..da17963d 100644 --- a/backend/tests/test_lab_storage_code_task_set.py +++ b/backend/tests/test_lab_storage_code_task_set.py @@ -1,19 +1,36 @@ import unittest -from app.utils.lab_storage_code_task_set import match_builtin_task_set_name_by_lab_storage_code +from app.utils.lab_storage_code_task_set import ( + BC_MSC_BUILTIN_SET_NAME, + HSC_BUILTIN_SET_NAME, + UC_MSC_BUILTIN_SET_NAME, + match_builtin_task_set_name_by_lab_storage_code, +) class TestMatchBuiltinTaskSetName(unittest.TestCase): def test_bc_before_b(self): self.assertEqual( match_builtin_task_set_name_by_lab_storage_code("BC20260101"), - "干细胞培养(内置模版)", + BC_MSC_BUILTIN_SET_NAME, ) def test_b_only(self): self.assertEqual( match_builtin_task_set_name_by_lab_storage_code("B20260101"), - "造血干样本处理(内置模版)", + HSC_BUILTIN_SET_NAME, + ) + + def test_uc_msc(self): + self.assertEqual( + match_builtin_task_set_name_by_lab_storage_code("UC2026010"), + UC_MSC_BUILTIN_SET_NAME, + ) + + def test_uc_case_insensitive(self): + self.assertEqual( + match_builtin_task_set_name_by_lab_storage_code("uc2026016"), + UC_MSC_BUILTIN_SET_NAME, ) def test_ubmc(self): @@ -35,7 +52,7 @@ class TestMatchBuiltinTaskSetName(unittest.TestCase): def test_bc_case_insensitive(self): self.assertEqual( match_builtin_task_set_name_by_lab_storage_code("bc99"), - "干细胞培养(内置模版)", + BC_MSC_BUILTIN_SET_NAME, ) diff --git a/backend/tests/test_uc_msc_builtin_template.py b/backend/tests/test_uc_msc_builtin_template.py new file mode 100644 index 00000000..d2503133 --- /dev/null +++ b/backend/tests/test_uc_msc_builtin_template.py @@ -0,0 +1,41 @@ +import os +import unittest + +# 避免导入 app.models 时 Settings 因缺 SECRET_KEY 失败 +os.environ.setdefault( + "SECRET_KEY", "test-secret-key-for-uc-msc-unit-tests-32c" +) + + +class TestUcMscBuiltinTemplate(unittest.TestCase): + def test_step_order_matches_business_five_nodes(self): + from app.services.uc_msc_builtin_template import UC_MSC_STEP_ORDER + + self.assertEqual( + UC_MSC_STEP_ORDER, + [ + "SOP-SC-003-E01-01", + "SOP-SC-004-E01-01", + "SOP-SC-006-E01-01", + "SOP-SC-005-E01-01", + "SOP-SC-008-E01-01", + ], + ) + self.assertNotIn("SOP-SC-007-E01-01", UC_MSC_STEP_ORDER) + + def test_uc_prefix_maps_to_uc_set_name(self): + from app.services.uc_msc_builtin_template import UC_MSC_ROUTE_CODE + from app.utils.lab_storage_code_task_set import ( + UC_MSC_BUILTIN_SET_NAME, + match_builtin_task_set_name_by_lab_storage_code, + ) + + self.assertEqual(UC_MSC_ROUTE_CODE, "MSC原代-UC-BUILTIN") + self.assertEqual( + match_builtin_task_set_name_by_lab_storage_code("UC2026010"), + UC_MSC_BUILTIN_SET_NAME, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/sql/migrations/2026-11-13-uc-msc-builtin-task-set.sql b/docs/sql/migrations/2026-11-13-uc-msc-builtin-task-set.sql new file mode 100644 index 00000000..83322fe7 --- /dev/null +++ b/docs/sql/migrations/2026-11-13-uc-msc-builtin-task-set.sql @@ -0,0 +1,263 @@ +-- UC 入库编码自动派发:脐带MSC原代培养(内置模版)5 步 +-- 步骤:原代培养 → 半换液 → 二次贴壁 → 重铺 → 冻存(不含传代) +-- 内容从既有「干细胞培养(内置模版)」/ MSC培养-BUILTIN 按 process_code 拷贝 +-- 幂等:已存在同名内置集合则跳过主数据创建-- 生产历史空任务工单请用: +-- cd backend && python -m scripts.backfill_uc_msc_production_tasks --dry-run +-- cd backend && python -m scripts.backfill_uc_msc_production_tasks --apply + +SET NAMES utf8mb4; + +-- 1) 工艺路线头 +INSERT INTO biz_process_route ( + id, route_code, route_name, product_type_id, version, is_current, + equipment_ids, equipment_name, tenant_no, order_num, remark, is_builtin, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCROUTEA5CC11F194118AC54085447A', + 'MSC原代-UC-BUILTIN', + '脐带MSC原代培养(内置模版)', + src.product_type_id, + src.version, + '0', + src.equipment_ids, + src.equipment_name, + src.tenant_no, + 0, + '内置模版:UC 入库编码自动派发(5 步,不含传代)', + '1', + src.create_by, + IFNULL(src.create_dept_id, ''), + NOW(), + src.update_by, + NOW(), + 0 +FROM biz_process_route src +WHERE src.del_flag = 0 + AND ( + src.route_code = 'MSC培养-BUILTIN' + OR (src.route_name = '干细胞培养(内置模版)' AND src.is_builtin = '1') + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route x + WHERE x.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND x.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route x + WHERE x.tenant_no = src.tenant_no + AND x.route_code = 'MSC原代-UC-BUILTIN' + AND x.del_flag = 0 + ) +ORDER BY CASE WHEN src.route_code = 'MSC培养-BUILTIN' THEN 0 ELSE 1 END +LIMIT 1; + +-- 2) 路线步骤(按目标顺序) +-- step 1 SOP-SC-003 +INSERT INTO biz_process_route_step ( + id, route_id, process_id, step_order, process_code, category_id, process_name, + description, equipment_ids, equipment_name, estimated_duration, sop_ids, sop_content, + tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSTEP1A5CC11F194118AC54085447A', + 'UCMSCROUTEA5CC11F194118AC54085447A', + s.process_id, 1, s.process_code, s.category_id, s.process_name, + s.description, s.equipment_ids, s.equipment_name, s.estimated_duration, s.sop_ids, s.sop_content, + s.tenant_no, 1, s.remark, s.create_by, IFNULL(s.create_dept_id, ''), NOW(), s.update_by, NOW(), 0 +FROM biz_process_route_step s +JOIN biz_process_route r ON r.id = s.route_id AND r.del_flag = 0 +WHERE s.del_flag = 0 + AND s.process_code = 'SOP-SC-003-E01-01' + AND ( + r.route_code = 'MSC培养-BUILTIN' + OR (r.route_name = '干细胞培养(内置模版)' AND r.is_builtin = '1') + ) + AND EXISTS ( + SELECT 1 FROM biz_process_route nr + WHERE nr.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND nr.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route_step x + WHERE x.id = 'UCMSCSTEP1A5CC11F194118AC54085447A' AND x.del_flag = 0 + ) +LIMIT 1; + +-- step 2 SOP-SC-004 +INSERT INTO biz_process_route_step ( + id, route_id, process_id, step_order, process_code, category_id, process_name, + description, equipment_ids, equipment_name, estimated_duration, sop_ids, sop_content, + tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSTEP2A5CC11F194118AC54085447A', + 'UCMSCROUTEA5CC11F194118AC54085447A', + s.process_id, 2, s.process_code, s.category_id, s.process_name, + s.description, s.equipment_ids, s.equipment_name, s.estimated_duration, s.sop_ids, s.sop_content, + s.tenant_no, 2, s.remark, s.create_by, IFNULL(s.create_dept_id, ''), NOW(), s.update_by, NOW(), 0 +FROM biz_process_route_step s +JOIN biz_process_route r ON r.id = s.route_id AND r.del_flag = 0 +WHERE s.del_flag = 0 + AND s.process_code = 'SOP-SC-004-E01-01' + AND ( + r.route_code = 'MSC培养-BUILTIN' + OR (r.route_name = '干细胞培养(内置模版)' AND r.is_builtin = '1') + ) + AND EXISTS ( + SELECT 1 FROM biz_process_route nr + WHERE nr.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND nr.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route_step x + WHERE x.id = 'UCMSCSTEP2A5CC11F194118AC54085447A' AND x.del_flag = 0 + ) +LIMIT 1; + +-- step 3 SOP-SC-006 二次贴壁 +INSERT INTO biz_process_route_step ( + id, route_id, process_id, step_order, process_code, category_id, process_name, + description, equipment_ids, equipment_name, estimated_duration, sop_ids, sop_content, + tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSTEP3A5CC11F194118AC54085447A', + 'UCMSCROUTEA5CC11F194118AC54085447A', + s.process_id, 3, s.process_code, s.category_id, s.process_name, + s.description, s.equipment_ids, s.equipment_name, s.estimated_duration, s.sop_ids, s.sop_content, + s.tenant_no, 3, s.remark, s.create_by, IFNULL(s.create_dept_id, ''), NOW(), s.update_by, NOW(), 0 +FROM biz_process_route_step s +JOIN biz_process_route r ON r.id = s.route_id AND r.del_flag = 0 +WHERE s.del_flag = 0 + AND s.process_code = 'SOP-SC-006-E01-01' + AND ( + r.route_code = 'MSC培养-BUILTIN' + OR (r.route_name = '干细胞培养(内置模版)' AND r.is_builtin = '1') + ) + AND EXISTS ( + SELECT 1 FROM biz_process_route nr + WHERE nr.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND nr.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route_step x + WHERE x.id = 'UCMSCSTEP3A5CC11F194118AC54085447A' AND x.del_flag = 0 + ) +LIMIT 1; + +-- step 4 SOP-SC-005 重铺 +INSERT INTO biz_process_route_step ( + id, route_id, process_id, step_order, process_code, category_id, process_name, + description, equipment_ids, equipment_name, estimated_duration, sop_ids, sop_content, + tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSTEP4A5CC11F194118AC54085447A', + 'UCMSCROUTEA5CC11F194118AC54085447A', + s.process_id, 4, s.process_code, s.category_id, s.process_name, + s.description, s.equipment_ids, s.equipment_name, s.estimated_duration, s.sop_ids, s.sop_content, + s.tenant_no, 4, s.remark, s.create_by, IFNULL(s.create_dept_id, ''), NOW(), s.update_by, NOW(), 0 +FROM biz_process_route_step s +JOIN biz_process_route r ON r.id = s.route_id AND r.del_flag = 0 +WHERE s.del_flag = 0 + AND s.process_code = 'SOP-SC-005-E01-01' + AND ( + r.route_code = 'MSC培养-BUILTIN' + OR (r.route_name = '干细胞培养(内置模版)' AND r.is_builtin = '1') + ) + AND EXISTS ( + SELECT 1 FROM biz_process_route nr + WHERE nr.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND nr.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route_step x + WHERE x.id = 'UCMSCSTEP4A5CC11F194118AC54085447A' AND x.del_flag = 0 + ) +LIMIT 1; + +-- step 5 SOP-SC-008 冻存 +INSERT INTO biz_process_route_step ( + id, route_id, process_id, step_order, process_code, category_id, process_name, + description, equipment_ids, equipment_name, estimated_duration, sop_ids, sop_content, + tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSTEP5A5CC11F194118AC54085447A', + 'UCMSCROUTEA5CC11F194118AC54085447A', + s.process_id, 5, s.process_code, s.category_id, s.process_name, + s.description, s.equipment_ids, s.equipment_name, s.estimated_duration, s.sop_ids, s.sop_content, + s.tenant_no, 5, s.remark, s.create_by, IFNULL(s.create_dept_id, ''), NOW(), s.update_by, NOW(), 0 +FROM biz_process_route_step s +JOIN biz_process_route r ON r.id = s.route_id AND r.del_flag = 0 +WHERE s.del_flag = 0 + AND s.process_code = 'SOP-SC-008-E01-01' + AND ( + r.route_code = 'MSC培养-BUILTIN' + OR (r.route_name = '干细胞培养(内置模版)' AND r.is_builtin = '1') + ) + AND EXISTS ( + SELECT 1 FROM biz_process_route nr + WHERE nr.id = 'UCMSCROUTEA5CC11F194118AC54085447A' AND nr.del_flag = 0 + ) + AND NOT EXISTS ( + SELECT 1 FROM biz_process_route_step x + WHERE x.id = 'UCMSCSTEP5A5CC11F194118AC54085447A' AND x.del_flag = 0 + ) +LIMIT 1; + +-- 3) 任务组合头 +INSERT INTO biz_production_task_set ( + id, set_name, status, tenant_no, order_num, remark, is_builtin, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCSET00A5CC11F194118AC54085447A', + '脐带MSC原代培养(内置模版)', + '1', + r.tenant_no, + 0, + '内置模版:UC 入库编码自动派发', + '1', + r.create_by, + IFNULL(r.create_dept_id, ''), + NOW(), + r.update_by, + NOW(), + 0 +FROM biz_process_route r +WHERE r.id = 'UCMSCROUTEA5CC11F194118AC54085447A' + AND r.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM biz_production_task_set x + WHERE x.tenant_no = r.tenant_no + AND x.set_name = '脐带MSC原代培养(内置模版)' + AND x.is_builtin = '1' + AND x.del_flag = 0 + ); + +-- 4) 任务组合条目 +INSERT INTO biz_production_task_set_item ( + id, set_id, task_name, route_id, route_name, description, remark, + tenant_no, order_num, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + 'UCMSCITEM0A5CC11F194118AC54085447A', + s.id, + '{lab_storage_code}样本处理', + 'UCMSCROUTEA5CC11F194118AC54085447A', + '脐带MSC原代培养(内置模版)', + '脐带MSC原代培养(内置模版)', + NULL, + s.tenant_no, + 1, + s.create_by, + IFNULL(s.create_dept_id, ''), + NOW(), + s.update_by, + NOW(), + 0 +FROM biz_production_task_set s +WHERE s.set_name = '脐带MSC原代培养(内置模版)' + AND s.is_builtin = '1' + AND s.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM biz_production_task_set_item x + WHERE x.set_id = s.id AND x.del_flag = 0 + );