"""生产任务 CRUD(biz_production_task + 步骤 + 子表 + 投入/产出)""" from __future__ import annotations import math from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.core.utils import format_date, generate_uuid, parse_datetime from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot from app.models.models import ( BizCellProduct, BizCollectionProtocol, BizProcess, BizProcessConsumable, BizProcessDataCollection, BizProcessOutput, BizProcessReagent, BizProcessRoute, BizProcessRouteStep, BizProcessRouteStepConsumable, BizProcessRouteStepDataCollection, BizProcessRouteStepOutput, BizProcessRouteStepReagent, BizProductionTask, BizProductionTaskHandover, BizProductionTaskInput, BizProductionTaskOutput, BizProductionTaskStep, BizProductionTaskStepAssignee, BizProductionTaskStepConsumable, BizProductionTaskStepConfiguredReagent, BizProductionTaskStepDataCollection, BizProductionTaskStepOutput, BizProductionTaskStepReagent, BizProductionWorkOrder, BizProtocolSample, BizSampleReception, BizStorageSlotOccupancy, SysUser, ) from app.crud import storage_occupancy as occupancy_svc from app.crud.cell_product import ( _row_to_dict as _cp_row_to_dict, _enrich_product_dicts as _cp_enrich, product_code_exists as _cp_product_code_exists, ) from app.models.research_donation import BizResearchDonation, BizResearchDonationSample def _require_requisition_for_catalog_material( db: Session, tenant_no: str, material_code: Optional[str], material_type: str, actual_usage: Optional[str], requisition_usage_id: Optional[str], ) -> None: code = (material_code or "").strip() actual = (actual_usage or "").strip() req_id = (requisition_usage_id or "").strip() if not code or not actual or req_id: return from app.models.production_material import BizProductionMaterial catalog_row = db.query(BizProductionMaterial).filter( and_( BizProductionMaterial.material_code == code, BizProductionMaterial.material_type == material_type, BizProductionMaterial.tenant_no == tenant_no, BizProductionMaterial.del_flag == 0, ) ).first() if catalog_row: raise ValueError(f"物料「{code}」需选择领用记录") _PROTOCOL_STATUS_LABELS = { "dsx": "待生效", "sxz": "生效中", "ygq": "已过期", "yzf": "已作废", "sdfq": "属地废弃", "zky": "转科研" } _DONATION_STATUS_LABELS = { "dsx": "待生效", "sxz": "生效中", "ygq": "已过期", "yzf": "已作废", "sdfq": "属地废弃", "zckc": "转细胞存储" } _RECEPTION_SOURCE_LABELS = { "cell_storage": "细胞存储", "research_donation": "科研捐赠", } _OUTBOUND_BIZ_TYPE_LABELS = { "pilot_service": "中试服务", "clinical_application": "临床应用", } _APPLICATION_STATUS_LABELS = { "dks": "待开始", "jxz": "进行中", "ywc": "已完成", "yzf": "已作废", } _PRODUCTION_CONCLUSION_LABELS = { "cg": "成功", "sb": "失败", } def _production_conclusion_label(v: Optional[str]) -> str: code = (v or "").strip() if not code: return "" return _PRODUCTION_CONCLUSION_LABELS.get(code, code) def _fmt_dt(t: Optional[datetime]) -> str: return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str: val = (value or "").strip() if len(val) == 10: return f"{val} 23:59:59" if end_of_day else f"{val} 00:00:00" return val def _enrich_outbound_task_items( db: Session, tenant_no: str, items: List[Dict[str, Any]], wo_ctx_map: Dict[str, Dict[str, Any]], ) -> None: """出库生产任务:来源类型=业务类型,协议号=申请单号,协议状态=申请单状态。""" from app.crud.pilot_infusion_schedule_mgmt import ( _get_plan_schedule_course_meta, _resolve_course_display, ) from app.models.models import ( BizPilotApplication, BizPilotInfusionPlan, BizPilotInfusionSchedule, BizPilotPatient, ) schedule_ids = [ ctx.get("infusionScheduleId") for ctx in wo_ctx_map.values() if ctx.get("workOrderType") == "outbound" and ctx.get("infusionScheduleId") ] if not schedule_ids: return schedules = { s.id: s for s in db.query(BizPilotInfusionSchedule) .filter( BizPilotInfusionSchedule.id.in_(schedule_ids), BizPilotInfusionSchedule.tenant_no == tenant_no, BizPilotInfusionSchedule.del_flag == 0, ) .all() } plan_ids = list({s.infusion_plan_id for s in schedules.values() if s.infusion_plan_id}) plan_map = {} if plan_ids: plan_map = { p.id: p for p in db.query(BizPilotInfusionPlan) .filter( BizPilotInfusionPlan.id.in_(plan_ids), BizPilotInfusionPlan.tenant_no == tenant_no, BizPilotInfusionPlan.del_flag == 0, ) .all() } app_ids = list({p.pilot_application_id for p in plan_map.values() if p.pilot_application_id}) app_map = {} patient_map: Dict[str, BizPilotPatient] = {} if app_ids: app_map = { a.id: a for a in db.query(BizPilotApplication) .filter( BizPilotApplication.id.in_(app_ids), BizPilotApplication.tenant_no == tenant_no, BizPilotApplication.del_flag == 0, ) .all() } patients = ( db.query(BizPilotPatient) .filter( BizPilotPatient.pilot_application_id.in_(app_ids), BizPilotPatient.tenant_no == tenant_no, BizPilotPatient.del_flag == 0, ) .all() ) patient_map = {p.pilot_application_id: p for p in patients} plan_course_meta = _get_plan_schedule_course_meta(db, plan_ids, tenant_no) schedule_app_map: Dict[str, BizPilotApplication] = {} for sid, sched in schedules.items(): plan = plan_map.get(sched.infusion_plan_id) if sched.infusion_plan_id else None app = app_map.get(plan.pilot_application_id) if plan and plan.pilot_application_id else None if app: schedule_app_map[sid] = app for item in items: ctx = wo_ctx_map.get(item.get("workOrderId") or "") if not ctx or ctx.get("workOrderType") != "outbound": continue biz_type = ctx.get("bizType") or "" biz_label = _OUTBOUND_BIZ_TYPE_LABELS.get(biz_type, biz_type) item["bizType"] = biz_type item["bizTypeLabel"] = biz_label item["receptionSource"] = biz_type item["receptionSourceLabel"] = biz_label sched_id = ctx.get("infusionScheduleId") or "" sched = schedules.get(sched_id) app = schedule_app_map.get(sched_id) if app: app_status = app.application_status or "" item["protocolNo"] = app.application_no or "" item["protocolStatus"] = app_status item["protocolStatusLabel"] = _APPLICATION_STATUS_LABELS.get(app_status, app_status) item["applicationNo"] = app.application_no or "" item["applicationName"] = app.application_name or "" item["channelName"] = app.channel_name or "" item["projectTitle"] = app.project_title or "" item["customerName"] = app.customer_name or "" else: item["protocolNo"] = "" item["protocolStatus"] = "" item["protocolStatusLabel"] = "" item["applicationNo"] = "" item["applicationName"] = "" item["channelName"] = "" item["projectTitle"] = "" item["customerName"] = "" if sched: plan = plan_map.get(sched.infusion_plan_id) if sched.infusion_plan_id else None patient = ( patient_map.get(plan.pilot_application_id) if plan and plan.pilot_application_id else None ) plan_meta = ( plan_course_meta.get(sched.infusion_plan_id) if sched.infusion_plan_id else None ) course = _resolve_course_display(sched, plan, plan_meta) item["patientName"] = patient.patient_name if patient else "" item["sortNo"] = course.get("sortNo") item["totalCourseCount"] = course.get("totalCourseCount") item["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else "" def _generate_task_no(db: Session, tenant_no: str) -> str: day = datetime.now().strftime("%Y%m%d") like = f"RW{day}%" cnt = ( db.query(func.count(BizProductionTask.id)) .filter( BizProductionTask.tenant_no == tenant_no, BizProductionTask.task_no.like(like), ) .scalar() ) return f"RW{day}{(cnt or 0) + 1:04d}" def _task_to_dict(t: BizProductionTask, *, work_order_no: str = "") -> Dict[str, Any]: return { "id": t.id, "taskNo": t.task_no, "workOrderId": t.work_order_id, "orderSource": getattr(t, "order_source", "inbound") or "inbound", "workOrderNo": work_order_no, "taskType": t.task_type or "cell_storage", "taskName": t.task_name or "", "description": t.description or "", "routeId": t.route_id or "", "taskStatus": t.task_status, "currentAssigneeUserId": t.current_assignee_user_id or "", "currentAssigneeUserName": "", "startedTime": _fmt_dt(t.started_time), "completedTime": _fmt_dt(t.completed_time), "conclusion": (getattr(t, "conclusion", None) or "").strip(), "conclusionLabel": _production_conclusion_label(getattr(t, "conclusion", None)), "remark": t.remark or "", "attachments": t.attachments or "", "equipmentIds": parse_equipment_ids(getattr(t, "equipment_ids", None)), "equipmentName": getattr(t, "equipment_name", None) or "", "tenantNo": t.tenant_no, "orderNum": t.order_num if t.order_num is not None else 0, "createTime": _fmt_dt(t.create_time), "updateTime": _fmt_dt(t.update_time), } def _step_to_dict(s: BizProductionTaskStep) -> Dict[str, Any]: return { "id": s.id, "taskId": s.task_id, "stepOrder": s.step_order if s.step_order is not None else 0, "processCode": s.process_code or "", "categoryId": s.category_id or "", "processName": s.process_name or "", "description": s.description or "", "estimatedDuration": s.estimated_duration or "", "actualDuration": s.actual_duration or "", "sopIds": s.sop_ids or "", "stepStatus": s.step_status or "dzx", "plannedStartTime": format_date(getattr(s, "planned_start_time", None)) or "", "startedTime": _fmt_dt(s.started_time), "completedTime": _fmt_dt(s.completed_time), "completedBy": getattr(s, "completed_by", None) or "", "completedByName": "", "assigneeUserIds": [], "assigneeNames": [], "assignees": [], "remark": s.remark or "", } def _step_reagent_to_dict(r: BizProductionTaskStepReagent) -> Dict[str, Any]: return { "id": r.id, "stepId": r.step_id, "reagentCode": r.reagent_code or "", "reagentName": r.reagent_name or "", "spec": r.spec or "", "standardUsage": r.standard_usage or "", "actualUsage": r.actual_usage or "", "unit": r.unit or "", "requisitionUsageId": (r.requisition_usage_id or "").strip(), "isKeyMaterial": r.is_key_material if r.is_key_material is not None else "0", "orderNum": r.order_num if r.order_num is not None else 0, "remark": r.remark or "", } def _step_configured_reagent_to_dict(r: BizProductionTaskStepConfiguredReagent) -> Dict[str, Any]: return { "id": r.id, "stepId": r.step_id, "configuredReagentCode": r.configured_reagent_code or "", "configuredReagentName": r.configured_reagent_name or "", "spec": r.spec or "", "standardUsage": r.standard_usage or "", "actualUsage": r.actual_usage or "", "unit": r.unit or "", "requisitionUsageId": (r.requisition_usage_id or "").strip(), "orderNum": r.order_num if r.order_num is not None else 0, "remark": r.remark or "", } def _step_consumable_to_dict(c: BizProductionTaskStepConsumable) -> Dict[str, Any]: return { "id": c.id, "stepId": c.step_id, "consumableCode": c.consumable_code or "", "consumableName": c.consumable_name or "", "specModel": c.spec_model or "", "standardUsage": c.standard_usage or "", "actualUsage": c.actual_usage or "", "unit": c.unit or "", "requisitionUsageId": (c.requisition_usage_id or "").strip(), "isRequired": c.is_required if c.is_required is not None else "1", "orderNum": c.order_num if c.order_num is not None else 0, "remark": c.remark or "", } def _step_output_to_dict(o: BizProductionTaskStepOutput) -> Dict[str, Any]: return { "id": o.id, "stepId": o.step_id, "outputTypeId": o.output_type_id or "", "outputName": o.output_name or "", "outputBarcode": o.output_barcode or "", "orderNum": o.order_num if o.order_num is not None else 0, "remark": o.remark or "", } def _step_data_collection_to_dict(d: BizProductionTaskStepDataCollection) -> Dict[str, Any]: return { "id": d.id, "stepId": d.step_id, "collectionName": d.collection_name or "", "unit": d.unit or "", "collectionValue": d.collection_value or "", "orderNum": d.order_num if d.order_num is not None else 0, "remark": d.remark or "", } def _task_input_to_dict(i: BizProductionTaskInput, *, product_dict: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: result = { "id": i.id, "taskId": i.task_id, "inputType": i.input_type or "", "inputId": i.input_id or "", "inputCode": i.input_code or "", "inputName": i.input_name or "", "remark": i.remark or "", } if product_dict: for key in [ "productCode", "productName", "productTypeId", "productTypeName", "specId", "specName", "specText", "passage", "batchNo", "storageSlotName", "expectedCellCount", "viability", "freezeDate", "expiryDate", "productStatus", "productImages", "storageSlotId", ]: if key in product_dict: result[key] = product_dict[key] return result def _task_output_to_dict(o: BizProductionTaskOutput, *, product_dict: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: result = { "id": o.id, "taskId": o.task_id, "cellProductId": o.cell_product_id or "", "storageSlotId": o.storage_slot_id or "", "outputName": o.output_name or "", "outputBarcode": o.output_barcode or "", "quantity": float(o.quantity) if o.quantity is not None else None, "unit": o.unit or "", "remark": o.remark or "", "attachments": o.attachments or "", } if product_dict: for key in [ "productCode", "productName", "productTypeId", "productTypeName", "specId", "specName", "specText", "passage", "batchNo", "storageSlotName", "expectedCellCount", "viability", "freezeDate", "expiryDate", "productStatus", "productImages", ]: if key in product_dict: result[key] = product_dict[key] return result def _soft_delete_sub(db: Session, model_class, parent_field: str, parent_id: str, now: datetime) -> None: rows = db.query(model_class).filter( and_( getattr(model_class, parent_field) == parent_id, model_class.del_flag == 0, ) ).all() for r in rows: r.del_flag = 1 r.update_time = now db.add(r) def _resolve_sub_table_order_num(order_num: Optional[int], idx: int, rows: list) -> int: """复制/克隆子表排序号:保留源 order_num;若源记录均为 0 则按遍历顺序写入 idx。""" if rows and len(rows) > 1 and all((getattr(r, "order_num", None) or 0) == 0 for r in rows): return idx if order_num is not None: return order_num return idx def _empty_step_sub_tables() -> Dict[str, Any]: return { "reagents": [], "consumables": [], "configuredReagents": [], "outputs": [], "dataCollections": [], } def _batch_load_step_sub_tables(db: Session, step_ids: List[str]) -> Dict[str, Dict[str, Any]]: """批量加载步骤子表,避免 get_production_task_detail 逐步骤 N+1 查询。""" ids = [sid for sid in step_ids if (sid or "").strip()] if not ids: return {} result: Dict[str, Dict[str, Any]] = {sid: _empty_step_sub_tables() for sid in ids} reagents = ( db.query(BizProductionTaskStepReagent) .filter( and_( BizProductionTaskStepReagent.step_id.in_(ids), BizProductionTaskStepReagent.del_flag == 0, ) ) .order_by( BizProductionTaskStepReagent.step_id.asc(), BizProductionTaskStepReagent.order_num.asc(), BizProductionTaskStepReagent.create_time.asc(), ) .all() ) for row in reagents: result[row.step_id]["reagents"].append(_step_reagent_to_dict(row)) consumables = ( db.query(BizProductionTaskStepConsumable) .filter( and_( BizProductionTaskStepConsumable.step_id.in_(ids), BizProductionTaskStepConsumable.del_flag == 0, ) ) .order_by( BizProductionTaskStepConsumable.step_id.asc(), BizProductionTaskStepConsumable.order_num.asc(), BizProductionTaskStepConsumable.create_time.asc(), ) .all() ) for row in consumables: result[row.step_id]["consumables"].append(_step_consumable_to_dict(row)) outputs = ( db.query(BizProductionTaskStepOutput) .filter( and_( BizProductionTaskStepOutput.step_id.in_(ids), BizProductionTaskStepOutput.del_flag == 0, ) ) .order_by( BizProductionTaskStepOutput.step_id.asc(), BizProductionTaskStepOutput.order_num.asc(), BizProductionTaskStepOutput.create_time.asc(), ) .all() ) for row in outputs: result[row.step_id]["outputs"].append(_step_output_to_dict(row)) data_collections = ( db.query(BizProductionTaskStepDataCollection) .filter( and_( BizProductionTaskStepDataCollection.step_id.in_(ids), BizProductionTaskStepDataCollection.del_flag == 0, ) ) .order_by( BizProductionTaskStepDataCollection.step_id.asc(), BizProductionTaskStepDataCollection.order_num.asc(), BizProductionTaskStepDataCollection.create_time.asc(), ) .all() ) for row in data_collections: result[row.step_id]["dataCollections"].append(_step_data_collection_to_dict(row)) configured_reagents = ( db.query(BizProductionTaskStepConfiguredReagent) .filter( and_( BizProductionTaskStepConfiguredReagent.step_id.in_(ids), BizProductionTaskStepConfiguredReagent.del_flag == 0, ) ) .order_by( BizProductionTaskStepConfiguredReagent.step_id.asc(), BizProductionTaskStepConfiguredReagent.order_num.asc(), BizProductionTaskStepConfiguredReagent.create_time.asc(), ) .all() ) for row in configured_reagents: result[row.step_id]["configuredReagents"].append(_step_configured_reagent_to_dict(row)) return result def _step_sub_tables_to_dict(db: Session, step_id: str) -> Dict[str, Any]: """加载步骤四张子表并序列化(与 get_production_task_detail 一致)。""" return _batch_load_step_sub_tables(db, [step_id]).get(step_id) or _empty_step_sub_tables() def _clone_step_reagents( db: Session, src_step_id: str, new_step_id: str, tenant_no: str, user_id: Optional[str], now: datetime, ) -> None: rows = db.query(BizProcessRouteStepReagent).filter( and_(BizProcessRouteStepReagent.step_id == src_step_id, BizProcessRouteStepReagent.del_flag == 0) ).order_by( BizProcessRouteStepReagent.order_num.asc(), BizProcessRouteStepReagent.create_time.asc(), ).all() for idx, r in enumerate(rows): db.add(BizProductionTaskStepReagent( id=generate_uuid(), step_id=new_step_id, tenant_no=tenant_no, 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, remark=r.remark, order_num=_resolve_sub_table_order_num(r.order_num, idx, rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) def _clone_step_consumables( db: Session, src_step_id: str, new_step_id: str, tenant_no: str, user_id: Optional[str], now: datetime, ) -> None: rows = db.query(BizProcessRouteStepConsumable).filter( and_(BizProcessRouteStepConsumable.step_id == src_step_id, BizProcessRouteStepConsumable.del_flag == 0) ).order_by( BizProcessRouteStepConsumable.order_num.asc(), BizProcessRouteStepConsumable.create_time.asc(), ).all() for idx, c in enumerate(rows): db.add(BizProductionTaskStepConsumable( id=generate_uuid(), step_id=new_step_id, tenant_no=tenant_no, 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, order_num=_resolve_sub_table_order_num(c.order_num, idx, rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) def _clone_step_outputs( db: Session, src_step_id: str, new_step_id: str, tenant_no: str, user_id: Optional[str], now: datetime, ) -> None: rows = db.query(BizProcessRouteStepOutput).filter( and_(BizProcessRouteStepOutput.step_id == src_step_id, BizProcessRouteStepOutput.del_flag == 0) ).order_by( BizProcessRouteStepOutput.order_num.asc(), BizProcessRouteStepOutput.create_time.asc(), ).all() for idx, o in enumerate(rows): db.add(BizProductionTaskStepOutput( id=generate_uuid(), step_id=new_step_id, tenant_no=tenant_no, output_type_id=o.output_type_id, output_name=o.output_name, output_barcode=o.output_barcode, remark=o.remark, order_num=_resolve_sub_table_order_num(o.order_num, idx, rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) def _clone_step_data_collections( db: Session, src_step_id: str, new_step_id: str, tenant_no: str, user_id: Optional[str], now: datetime, ) -> None: rows = db.query(BizProcessRouteStepDataCollection).filter( and_(BizProcessRouteStepDataCollection.step_id == src_step_id, BizProcessRouteStepDataCollection.del_flag == 0) ).order_by( BizProcessRouteStepDataCollection.order_num.asc(), BizProcessRouteStepDataCollection.create_time.asc(), ).all() for idx, d in enumerate(rows): db.add(BizProductionTaskStepDataCollection( id=generate_uuid(), step_id=new_step_id, tenant_no=tenant_no, collection_name=d.collection_name, unit=d.unit, remark=d.remark, order_num=_resolve_sub_table_order_num(d.order_num, idx, rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) def _clone_route_to_task( db: Session, task_id: str, route_id: str, tenant_no: str, user_id: Optional[str], create_dept_id: str, ) -> None: route_steps = ( db.query(BizProcessRouteStep) .filter(and_(BizProcessRouteStep.route_id == route_id, BizProcessRouteStep.del_flag == 0)) .order_by(BizProcessRouteStep.step_order.asc(), BizProcessRouteStep.create_time.asc()) .all() ) now = datetime.now() for rs in route_steps: new_step = BizProductionTaskStep( id=generate_uuid(), task_id=task_id, step_order=rs.step_order, process_code=rs.process_code, category_id=rs.category_id, process_name=rs.process_name, description=rs.description, estimated_duration=rs.estimated_duration, sop_ids=rs.sop_ids, step_status="dzx", tenant_no=tenant_no, order_num=0, remark=None, 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_reagents(db, rs.id, new_step.id, tenant_no, user_id, now) _clone_step_consumables(db, rs.id, new_step.id, tenant_no, user_id, now) _clone_step_outputs(db, rs.id, new_step.id, tenant_no, user_id, now) _clone_step_data_collections(db, rs.id, new_step.id, tenant_no, user_id, now) def _validate_assignee_user_id(db: Session, user_id: str, tenant_no: str) -> str: uid = (user_id or "").strip() if not uid: raise ValueError("请选择负责人") row = ( db.query(SysUser.id) .filter( SysUser.id == uid, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0, SysUser.status == "1", ) .first() ) if not row: raise ValueError("负责人用户无效或已停用") return uid def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None: user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()}) if not user_ids: return rows = ( db.query(SysUser.id, SysUser.nick_name) .filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0) .all() ) name_map = {r.id: (r.nick_name or "").strip() for r in rows} for item in items: uid = (item.get("currentAssigneeUserId") or "").strip() if uid: item["currentAssigneeUserName"] = name_map.get(uid, "") def _enrich_handover_times( db: Session, items: List[Dict[str, Any]], *, from_user_id: str, tenant_no: str, ) -> None: task_ids = [x.get("id") for x in items if x.get("id")] if not task_ids: return rows = ( db.query( BizProductionTaskHandover.task_id, func.max(BizProductionTaskHandover.handover_time), ) .filter( BizProductionTaskHandover.task_id.in_(task_ids), BizProductionTaskHandover.from_user_id == from_user_id, BizProductionTaskHandover.tenant_no == tenant_no, BizProductionTaskHandover.del_flag == 0, ) .group_by(BizProductionTaskHandover.task_id) .all() ) time_map = {r[0]: _fmt_dt(r[1]) for r in rows} for item in items: item["handoverTime"] = time_map.get(item.get("id"), "") def get_production_task_row( db: Session, task_id: str, tenant_no: str ) -> Optional[BizProductionTask]: return ( db.query(BizProductionTask) .filter( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) .first() ) def assert_task_assignee( db: Session, task_id: str, user_id: str, tenant_no: str ) -> BizProductionTask: """Deprecated:优先使用 assert_task_step_assignee_or_legacy。""" return assert_task_step_assignee_or_legacy(db, task_id, user_id, tenant_no) def assert_task_step_assignee_or_legacy( db: Session, task_id: str, user_id: str, tenant_no: str ) -> BizProductionTask: from app.crud import production_task_step_assignee as step_assignee_svc task = get_production_task_row(db, task_id, tenant_no) if not task: raise ValueError("任务不存在") if step_assignee_svc.user_is_any_step_assignee_of_task(db, task_id, user_id, tenant_no): return task if (task.current_assignee_user_id or "") == (user_id or ""): return task raise PermissionError("非任务步骤负责人,无权操作") def assert_step_assignee_for_write( db: Session, step_id: str, user_id: str, tenant_no: str, *, manage: bool = False, ) -> BizProductionTaskStep: if manage: step = db.query(BizProductionTaskStep).filter( BizProductionTaskStep.id == step_id, BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ).first() if not step: raise ValueError("步骤不存在") return step from app.crud import production_task_step_assignee as step_assignee_svc step = db.query(BizProductionTaskStep).filter( BizProductionTaskStep.id == step_id, BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ).first() if not step: raise ValueError("步骤不存在") if not step_assignee_svc.user_is_step_assignee(db, step_id, user_id, tenant_no): raise PermissionError("非当前步骤负责人,无权操作") return step def _record_task_handover( db: Session, *, task_id: str, from_user_id: str, to_user_id: str, tenant_no: str, operator_id: Optional[str], create_dept_id: str, remark: Optional[str] = None, ) -> None: now = datetime.now() db.add( BizProductionTaskHandover( id=generate_uuid(), task_id=task_id, from_user_id=from_user_id, to_user_id=to_user_id, handover_time=now, remark=(remark or "").strip() or None, tenant_no=tenant_no, order_num=0, create_by=operator_id, create_dept_id=create_dept_id or "", create_time=now, update_by=operator_id, update_time=now, del_flag=0, ) ) def forward_production_task( db: Session, task_id: str, *, from_user_id: str, to_user_id: str, tenant_no: str, operator_id: Optional[str] = None, create_dept_id: str = "", remark: Optional[str] = None, require_assignee: bool = True, ) -> BizProductionTask: to_uid = _validate_assignee_user_id(db, to_user_id, tenant_no) from_uid = (from_user_id or "").strip() if not from_uid: raise ValueError("转出用户无效") if from_uid == to_uid: raise ValueError("不能转发给自己") if require_assignee: task = assert_task_assignee(db, task_id, from_uid, tenant_no) else: task = get_production_task_row(db, task_id, tenant_no) if not task: raise ValueError("任务不存在") handover_from = (task.current_assignee_user_id or from_uid).strip() or from_uid _record_task_handover( db, task_id=task_id, from_user_id=handover_from, to_user_id=to_uid, tenant_no=tenant_no, operator_id=operator_id, create_dept_id=create_dept_id, remark=remark, ) task.current_assignee_user_id = to_uid task.update_by = operator_id task.update_time = datetime.now() db.add(task) db.flush() return task def reassign_production_task( db: Session, task_id: str, *, assignee_user_id: str, tenant_no: str, operator_id: Optional[str] = None, create_dept_id: str = "", remark: Optional[str] = None, ) -> BizProductionTask: task = get_production_task_row(db, task_id, tenant_no) if not task: raise ValueError("任务不存在") to_uid = _validate_assignee_user_id(db, assignee_user_id, tenant_no) from_uid = (task.current_assignee_user_id or operator_id or "").strip() if from_uid and from_uid != to_uid: _record_task_handover( db, task_id=task_id, from_user_id=from_uid, to_user_id=to_uid, tenant_no=tenant_no, operator_id=operator_id, create_dept_id=create_dept_id, remark=(remark or "").strip() or "管理员改派", ) elif not from_uid: _record_task_handover( db, task_id=task_id, from_user_id=operator_id or to_uid, to_user_id=to_uid, tenant_no=tenant_no, operator_id=operator_id, create_dept_id=create_dept_id, remark=(remark or "").strip() or "管理员改派", ) task.current_assignee_user_id = to_uid task.update_by = operator_id task.update_time = datetime.now() db.add(task) db.flush() return task def create_production_task( db: Session, *, tenant_no: str, user_id: Optional[str] = None, create_dept_id: str = "", data: Dict[str, Any], order_source: str = "inbound", ) -> BizProductionTask: work_order_id = (data.get("work_order_id") or "").strip() wo = None if work_order_id: wo = ( db.query(BizProductionWorkOrder) .filter( BizProductionWorkOrder.id == work_order_id, BizProductionWorkOrder.tenant_no == tenant_no, BizProductionWorkOrder.del_flag == 0, ) .first() ) if wo: wo_type = getattr(wo, "work_order_type", None) or "inbound" if wo_type == "temporary": order_source = "temporary" elif wo_type == "outbound": order_source = "outbound" task_type = (data.get("task_type") or "").strip() or "cell_storage" if wo and (getattr(wo, "work_order_type", None) or "inbound") == "outbound": if not (data.get("task_type") or "").strip(): task_type = "outbound" assignee_raw = (data.get("assignee_user_id") or data.get("assigneeUserId") or "").strip() assignee_user_id = None if assignee_raw: assignee_user_id = _validate_assignee_user_id(db, assignee_raw, tenant_no) task_no = _generate_task_no(db, tenant_no) now = datetime.now() task = BizProductionTask( id=generate_uuid(), task_no=task_no, work_order_id=data.get("work_order_id", ""), order_source=order_source, task_type=task_type, task_name=(data.get("task_name") or "").strip() or None, description=(data.get("description") or "").strip() or None, route_id=data.get("route_id") or None, task_status="dpz", current_assignee_user_id=assignee_user_id, remark=(data.get("remark") or "").strip() or None, attachments=(data.get("attachments") or "").strip() or None, tenant_no=tenant_no, order_num=0, 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(task) db.flush() route_id = data.get("route_id") if route_id: _clone_route_to_task(db, task.id, route_id, tenant_no, user_id, create_dept_id) route = ( db.query(BizProcessRoute) .filter( BizProcessRoute.id == route_id, BizProcessRoute.tenant_no == tenant_no, BizProcessRoute.del_flag == 0, ) .first() ) if route and (getattr(route, "equipment_ids", None) or getattr(route, "equipment_name", None)): task.equipment_ids = route.equipment_ids task.equipment_name = route.equipment_name db.add(task) from app.crud import production_work_order as production_work_order_svc production_work_order_svc.sync_production_work_order_status_from_tasks( db, task.work_order_id or "", tenant_no, user_id ) if wo and (getattr(wo, "work_order_type", None) or "inbound") == "temporary": if not getattr(wo, "configured_time", None): wo.configured_time = now wo.configured_by = user_id wo.update_by = user_id wo.update_time = now db.add(wo) db.flush() return task def dispatch_task_steps( db: Session, task_id: str, *, tenant_no: str, steps_payload: List[Dict[str, Any]], operator_id: Optional[str] = None, create_dept_id: str = "", ) -> Dict[str, Any]: from app.crud import production_task_step_assignee as step_assignee_svc task = get_production_task_row(db, task_id, tenant_no) if not task: raise ValueError("任务不存在") if not steps_payload: raise ValueError("请至少指定一个步骤的负责人") step_rows = { s.id: s for s in db.query(BizProductionTaskStep).filter( BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ).all() } for item in steps_payload: sid = (item.get("step_id") or item.get("stepId") or "").strip() if sid not in step_rows: raise ValueError(f"步骤不存在或不属于该任务: {sid}") user_ids = item.get("assignee_user_ids") or item.get("assigneeUserIds") or [] step_assignee_svc.sync_assignees_for_step( db, sid, user_ids, tenant_no=tenant_no, operator_id=operator_id, create_dept_id=create_dept_id, ) step = step_rows[sid] if "planned_start_time" in item or "plannedStartTime" in item: pst = item.get("planned_start_time") if pst is None and "plannedStartTime" in item: pst = item.get("plannedStartTime") if pst is None or pst == "": step.planned_start_time = None else: if isinstance(pst, datetime): step.planned_start_time = pst else: parsed = parse_datetime(str(pst).strip()) if parsed is None: raise ValueError("计划开始时间格式无效") step.planned_start_time = parsed step.update_by = operator_id step.update_time = datetime.now() db.add(step) db.flush() task_dict = _task_to_dict(task) _enrich_tasks_with_step_summaries(db, [task_dict], tenant_no=tenant_no) return {"taskId": task_id, "steps": task_dict.get("steps") or []} def _enrich_tasks_with_step_summaries( db: Session, items: List[Dict[str, Any]], *, tenant_no: str ) -> None: """批量附加工艺步骤摘要(无试剂/耗材/产出等子表),供列表卡片展示。""" if not items: return task_ids = [it["id"] for it in items if (it.get("id") or "").strip()] if not task_ids: return rows = ( db.query(BizProductionTaskStep) .filter( BizProductionTaskStep.task_id.in_(task_ids), BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ) .order_by( BizProductionTaskStep.task_id.asc(), BizProductionTaskStep.step_order.asc(), BizProductionTaskStep.create_time.asc(), ) .all() ) step_dicts = [_step_to_dict(s) for s in rows] if step_dicts: from app.crud.production_task_step_assignee import enrich_step_assignees enrich_step_assignees(db, step_dicts, tenant_no=tenant_no) by_task: Dict[str, List[Dict[str, Any]]] = {tid: [] for tid in task_ids} for sd in step_dicts: tid = (sd.get("taskId") or "").strip() if tid in by_task: by_task[tid].append(sd) for item in items: tid = (item.get("id") or "").strip() item["steps"] = by_task.get(tid, []) def list_production_tasks( db: Session, *, tenant_no: str, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, work_order_id: Optional[str] = None, work_order_ids: Optional[List[str]] = None, task_no: Optional[str] = None, task_status: Optional[str] = None, started_time_start: Optional[str] = None, started_time_end: Optional[str] = None, planned_start_time_start: Optional[str] = None, planned_start_time_end: Optional[str] = None, planned_start_time_mine_user_id: Optional[str] = None, current_assignee_user_id: Optional[str] = None, step_assignee_user_id: Optional[str] = None, mine_history_user_id: Optional[str] = None, work_order_type: Optional[str] = None, reception_source: Optional[str] = None, biz_type: Optional[str] = None, user_info=None, include_step_summary: bool = False, exclude_void_protocol: bool = True, ) -> Tuple[List[Dict[str, Any]], int]: def _filtered(): q = db.query(BizProductionTask).filter( BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) joined_wo = False kw = (keyword or "").strip() if kw: like = f"%{kw}%" wo_type = (work_order_type or "").strip() if wo_type == "temporary": keyword_conditions = [BizProductionWorkOrder.remark.like(like)] else: keyword_conditions = [ BizProductionTask.task_no.like(like), BizProductionWorkOrder.work_order_no.like(like), BizProductionWorkOrder.sample_code.like(like), ] if tenant_no: from app.crud.production_work_order import _outbound_work_order_ids_by_keyword outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw) if outbound_ids: keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids)) q = q.outerjoin( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ).filter(or_(*keyword_conditions)) joined_wo = True elif task_no and task_no.strip(): q = q.filter(BizProductionTask.task_no.like(f"%{task_no.strip()}%")) wo_ids = [x.strip() for x in (work_order_ids or []) if x and str(x).strip()] if wo_ids: q = q.filter(BizProductionTask.work_order_id.in_(wo_ids)) elif work_order_id and work_order_id.strip(): q = q.filter(BizProductionTask.work_order_id == work_order_id.strip()) if task_status and task_status.strip(): q = q.filter(BizProductionTask.task_status == task_status.strip()) if started_time_start and started_time_start.strip(): q = q.filter( BizProductionTask.started_time >= _normalize_date_bound(started_time_start.strip()) ) if started_time_end and started_time_end.strip(): q = q.filter( BizProductionTask.started_time <= _normalize_date_bound(started_time_end.strip(), end_of_day=True) ) pst_start = (planned_start_time_start or "").strip() pst_end = (planned_start_time_end or "").strip() if pst_start or pst_end: if not joined_wo: q = q.join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ) joined_wo = True from app.crud.production_work_order import apply_planned_start_time_step_filter q = apply_planned_start_time_step_filter( q, tenant_no=tenant_no, work_order_id_column=BizProductionTask.work_order_id, planned_start_time_start=planned_start_time_start, planned_start_time_end=planned_start_time_end, mine_user_id=planned_start_time_mine_user_id, ) ca_uid = (current_assignee_user_id or "").strip() if ca_uid: q = q.filter(BizProductionTask.current_assignee_user_id == ca_uid) sa_uid = (step_assignee_user_id or "").strip() if sa_uid: step_task_ids = ( db.query(BizProductionTaskStep.task_id) .join( BizProductionTaskStepAssignee, BizProductionTaskStepAssignee.step_id == BizProductionTaskStep.id, ) .filter( BizProductionTaskStepAssignee.user_id == sa_uid, BizProductionTaskStepAssignee.tenant_no == tenant_no, BizProductionTaskStepAssignee.del_flag == 0, BizProductionTaskStep.del_flag == 0, ) .distinct() .subquery() ) q = q.filter( or_( BizProductionTask.id.in_(step_task_ids), BizProductionTask.current_assignee_user_id == sa_uid, ) ) mh_uid = (mine_history_user_id or "").strip() if mh_uid: handover_task_ids = ( db.query(BizProductionTaskHandover.task_id) .filter( BizProductionTaskHandover.from_user_id == mh_uid, BizProductionTaskHandover.tenant_no == tenant_no, BizProductionTaskHandover.del_flag == 0, ) .distinct() .subquery() ) q = q.filter( BizProductionTask.current_assignee_user_id != mh_uid, BizProductionTask.id.in_(handover_task_ids), ) wo_type = (work_order_type or "").strip() if wo_type: if not joined_wo: q = q.join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ) joined_wo = True q = q.filter(BizProductionWorkOrder.work_order_type == wo_type) rs = (reception_source or "").strip() if rs: if not joined_wo: q = q.join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ) joined_wo = True q = q.filter(BizProductionWorkOrder.reception_source == rs) bt = (biz_type or "").strip() if bt: if not joined_wo: q = q.join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ) joined_wo = True q = q.filter(BizProductionWorkOrder.biz_type == bt) if user_info: from app.core.data_scope import apply_data_scope q = apply_data_scope(q, BizProductionTask, user_info, db, tenant_no) if exclude_void_protocol: if not joined_wo: q = q.join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, ), ) from app.crud.production_work_order import apply_exclude_inactive_protocol_filter q = apply_exclude_inactive_protocol_filter(q, exclude=True) return q total = _filtered().order_by(None).with_entities(func.count(BizProductionTask.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( _filtered() .order_by(BizProductionTask.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) items = [_task_to_dict(x) for x in rows] # 批量补充路线名称 route_ids = [x.route_id for x in rows if x.route_id] if route_ids: route_map = { r.id: r.route_name for r in db.query(BizProcessRoute.id, BizProcessRoute.route_name) .filter(BizProcessRoute.id.in_(route_ids)) .all() } for item, row in zip(items, rows): item["routeName"] = route_map.get(row.route_id, "") # 批量补充工单号 + 协议号 + 样本编码 wo_ids = [x.work_order_id for x in rows if x.work_order_id] wo_map: Dict[str, Any] = {} wo_ctx_map: Dict[str, Dict[str, Any]] = {} if wo_ids: wo_rows = ( db.query( BizProductionWorkOrder.id, BizProductionWorkOrder.work_order_no, BizProductionWorkOrder.work_order_type, BizProductionWorkOrder.biz_type, BizProductionWorkOrder.infusion_schedule_id, BizProductionWorkOrder.protocol_no, BizProductionWorkOrder.sample_code, BizProductionWorkOrder.reception_id, BizProductionWorkOrder.protocol_sample_id, BizProductionWorkOrder.collection_protocol_id, BizProductionWorkOrder.reception_source, ) .filter(BizProductionWorkOrder.id.in_(wo_ids)) .all() ) for w in wo_rows: wo_map[w.id] = w wo_type = getattr(w, "work_order_type", None) or "inbound" wo_ctx_map[w.id] = { "workOrderType": wo_type, "bizType": getattr(w, "biz_type", None) or "", "infusionScheduleId": getattr(w, "infusion_schedule_id", None) or "", } cell_wo_ids = [ w.collection_protocol_id for w in wo_rows if (w.reception_source or "cell_storage") != "research_donation" and (getattr(w, "work_order_type", None) or "inbound") != "outbound" and w.collection_protocol_id ] donation_wo_ids = [ w.collection_protocol_id for w in wo_rows if (w.reception_source or "") == "research_donation" and (getattr(w, "work_order_type", None) or "inbound") != "outbound" and w.collection_protocol_id ] proto_status_map: Dict[str, str] = {} proto_void_type_map: Dict[str, str] = {} if cell_wo_ids: for p in ( db.query( BizCollectionProtocol.id, BizCollectionProtocol.protocol_status, BizCollectionProtocol.void_type, ) .filter(BizCollectionProtocol.id.in_(cell_wo_ids), BizCollectionProtocol.del_flag == 0) .all() ): proto_status_map[p.id] = p.protocol_status or "" proto_void_type_map[p.id] = p.void_type or "" donation_status_map: Dict[str, str] = {} donation_void_type_map: Dict[str, str] = {} if donation_wo_ids: for d in ( db.query( BizResearchDonation.id, BizResearchDonation.donation_status, BizResearchDonation.void_type, ) .filter(BizResearchDonation.id.in_(donation_wo_ids), BizResearchDonation.del_flag == 0) .all() ): donation_status_map[d.id] = d.donation_status or "" donation_void_type_map[d.id] = d.void_type or "" from app.core.protocol_status_display import format_protocol_status_label for item, row in zip(items, rows): wo = wo_map.get(row.work_order_id) if wo: item["workOrderNo"] = wo.work_order_no or "" wo_type = getattr(wo, "work_order_type", None) or "inbound" if wo_type != "outbound": item["protocolNo"] = wo.protocol_no or "" item["sampleCode"] = wo.sample_code or "" _source = wo.reception_source or "cell_storage" if _source == "research_donation": _st = donation_status_map.get(wo.collection_protocol_id, "") _vt = donation_void_type_map.get(wo.collection_protocol_id, "") item["protocolStatus"] = _st item["voidType"] = _vt item["protocolStatusLabel"] = format_protocol_status_label( _st, _vt, kind="donation", db=db, tenant_no=tenant_no ) else: _st = proto_status_map.get(wo.collection_protocol_id, "") _vt = proto_void_type_map.get(wo.collection_protocol_id, "") item["protocolStatus"] = _st item["voidType"] = _vt item["protocolStatusLabel"] = format_protocol_status_label( _st, _vt, kind="protocol", db=db, tenant_no=tenant_no ) else: item["sampleCode"] = wo.sample_code or "" _enrich_outbound_task_items(db, tenant_no, items, wo_ctx_map) # 批量补充接收人、接收时间、样本类型(与质检任务/工单列表一致:优先接收记录样本 id) from app.crud.qc_work_order import ( _effective_donation_sample_id, _effective_protocol_sample_id, _effective_reception_source, _load_sample_type_name_map, _sample_type_name_for_item, ) from app.crud.sample_reception import _donation_sample_id_col reception_ids = [wo.reception_id for wo in wo_map.values() if getattr(wo, "reception_id", None)] reception_map: Dict[str, BizSampleReception] = {} if reception_ids: reception_map = { r.id: r for r in db.query(BizSampleReception) .filter(BizSampleReception.id.in_(reception_ids), BizSampleReception.del_flag == 0) .all() } wo_item_ctx: Dict[str, Dict[str, Any]] = {} cell_sample_ids: List[str] = [] donation_sample_ids: List[str] = [] for wo in wo_map.values(): rec = reception_map.get(wo.reception_id) if wo.reception_id else None ctx = { "receptionSource": getattr(wo, "reception_source", "cell_storage") or "cell_storage", "protocolSampleId": wo.protocol_sample_id or "", "collectionProtocolId": wo.collection_protocol_id or "", } wo_item_ctx[wo.id] = ctx source = _effective_reception_source(ctx, rec) if source == "research_donation": dsid = _effective_donation_sample_id(ctx, rec) if dsid: donation_sample_ids.append(dsid) else: psid = _effective_protocol_sample_id(ctx, rec) if psid: cell_sample_ids.append(psid) cell_sample_ids = list(dict.fromkeys(cell_sample_ids)) donation_sample_ids = list(dict.fromkeys(donation_sample_ids)) protocol_samples: Dict[str, BizProtocolSample] = {} if cell_sample_ids: protocol_samples = { s.id: s for s in db.query(BizProtocolSample) .filter(BizProtocolSample.id.in_(cell_sample_ids), BizProtocolSample.del_flag == 0) .all() } donation_samples: Dict[str, BizResearchDonationSample] = {} if donation_sample_ids: for s in ( db.query(BizResearchDonationSample) .filter( _donation_sample_id_col(BizResearchDonationSample.id).in_(donation_sample_ids), BizResearchDonationSample.del_flag == 0, ) .all() ): donation_samples[s.id] = s donation_samples[(s.id or "").strip()] = s type_ids = list( { s.sample_type_id for s in list(protocol_samples.values()) + list(donation_samples.values()) if s.sample_type_id } ) type_map = _load_sample_type_name_map(db, type_ids) # 将补充字段写入 items for item, row in zip(items, rows): wo = wo_map.get(row.work_order_id) if not wo: continue wo_type = getattr(wo, "work_order_type", None) or "inbound" if wo_type == "outbound": continue item["protocolNo"] = wo.protocol_no or "" item["sampleCode"] = wo.sample_code or "" rec = reception_map.get(wo.reception_id) if wo.reception_id else None item["receiverName"] = rec.handler_name or "" if rec else "" item["receptionTime"] = _fmt_dt(rec.reception_time) if rec else "" ctx = wo_item_ctx.get(wo.id, {}) source = _effective_reception_source(ctx, rec) item["sampleTypeName"] = _sample_type_name_for_item( source=source, donation_sample_id=_effective_donation_sample_id(ctx, rec), protocol_sample_id=_effective_protocol_sample_id(ctx, rec), type_map=type_map, protocol_samples=protocol_samples, donation_samples=donation_samples, ) _rs = source item["receptionSource"] = _rs item["receptionSourceLabel"] = _RECEPTION_SOURCE_LABELS.get(_rs, _rs) _enrich_assignee_names(db, items) if include_step_summary and items: _enrich_tasks_with_step_summaries(db, items, tenant_no=tenant_no) return items, int(total) def list_mine_production_tasks( db: Session, *, assignee_user_id: str, tenant_no: str, scope: str = "current", page: int = 1, page_size: int = 10, keyword: Optional[str] = None, task_status: Optional[str] = None, work_order_type: Optional[str] = None, reception_source: Optional[str] = None, biz_type: Optional[str] = None, include_step_summary: bool = False, exclude_void_protocol: bool = True, planned_start_time_start: Optional[str] = None, planned_start_time_end: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: uid = (assignee_user_id or "").strip() if not uid: return [], 0 pst_start = (planned_start_time_start or "").strip() pst_end = (planned_start_time_end or "").strip() mine_planned_user_id = uid if (pst_start or pst_end) else None sc = (scope or "current").strip().lower() if sc == "history": items, total = list_production_tasks( db, tenant_no=tenant_no, page=page, page_size=page_size, keyword=keyword, task_status=task_status, mine_history_user_id=uid, work_order_type=work_order_type, reception_source=reception_source, biz_type=biz_type, include_step_summary=include_step_summary, exclude_void_protocol=exclude_void_protocol, planned_start_time_start=planned_start_time_start, planned_start_time_end=planned_start_time_end, planned_start_time_mine_user_id=mine_planned_user_id, ) _enrich_handover_times(db, items, from_user_id=uid, tenant_no=tenant_no) return items, total return list_production_tasks( db, tenant_no=tenant_no, page=page, page_size=page_size, keyword=keyword, task_status=task_status, step_assignee_user_id=uid, work_order_type=work_order_type, reception_source=reception_source, biz_type=biz_type, include_step_summary=include_step_summary, exclude_void_protocol=exclude_void_protocol, planned_start_time_start=planned_start_time_start, planned_start_time_end=planned_start_time_end, planned_start_time_mine_user_id=mine_planned_user_id, ) def get_production_task_detail( db: Session, task_id: str, tenant_no: str, *, include_step_sub_tables: bool = True, ) -> Optional[Dict[str, Any]]: task = db.query(BizProductionTask).filter( and_( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ).first() if not task: return None wo = db.query(BizProductionWorkOrder).filter(BizProductionWorkOrder.id == task.work_order_id).first() result = _task_to_dict(task, work_order_no=wo.work_order_no if wo else "") if wo: wo_type = getattr(wo, "work_order_type", None) or "inbound" result["workOrderType"] = wo_type if wo_type == "outbound": result["bizType"] = getattr(wo, "biz_type", None) or "" elif wo_type == "inbound": result["receptionSource"] = ( getattr(wo, "reception_source", "cell_storage") or "cell_storage" ) result["sampleCode"] = wo.sample_code or "" if wo.reception_id: rec = ( db.query(BizSampleReception) .filter( BizSampleReception.id == wo.reception_id, BizSampleReception.tenant_no == tenant_no, BizSampleReception.del_flag == 0, ) .first() ) if rec and (rec.lab_storage_code or "").strip(): result["labStorageCode"] = (rec.lab_storage_code or "").strip() if wo_type == "outbound": wo_ctx = { wo.id: { "workOrderType": wo_type, "bizType": getattr(wo, "biz_type", None) or "", "infusionScheduleId": getattr(wo, "infusion_schedule_id", None) or "", } } _enrich_outbound_task_items(db, tenant_no, [result], wo_ctx) else: result["protocolNo"] = wo.protocol_no or "" _src = getattr(wo, "reception_source", "cell_storage") or "cell_storage" _cpid = wo.collection_protocol_id or "" if _src == "research_donation" and _cpid: d = ( db.query(BizResearchDonation.donation_status, BizResearchDonation.void_type) .filter(BizResearchDonation.id == _cpid, BizResearchDonation.del_flag == 0) .first() ) _st = d.donation_status or "" if d else "" _vt = d.void_type or "" if d else "" result["protocolStatus"] = _st result["voidType"] = _vt from app.core.protocol_status_display import format_protocol_status_label result["protocolStatusLabel"] = format_protocol_status_label( _st, _vt, kind="donation", db=db, tenant_no=tenant_no ) elif _cpid: p = ( db.query(BizCollectionProtocol.protocol_status, BizCollectionProtocol.void_type) .filter(BizCollectionProtocol.id == _cpid, BizCollectionProtocol.del_flag == 0) .first() ) _st = p.protocol_status or "" if p else "" _vt = p.void_type or "" if p else "" result["protocolStatus"] = _st result["voidType"] = _vt from app.core.protocol_status_display import format_protocol_status_label result["protocolStatusLabel"] = format_protocol_status_label( _st, _vt, kind="protocol", db=db, tenant_no=tenant_no ) else: result["protocolStatus"] = "" result["protocolStatusLabel"] = "" steps = ( db.query(BizProductionTaskStep) .filter(and_(BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.del_flag == 0)) .order_by(BizProductionTaskStep.step_order.asc(), BizProductionTaskStep.create_time.asc()) .all() ) step_ids = [s.id for s in steps] sub_tables_by_step: Dict[str, Dict[str, Any]] = {} if include_step_sub_tables and step_ids: sub_tables_by_step = _batch_load_step_sub_tables(db, step_ids) step_list = [] for s in steps: sd = _step_to_dict(s) sd.update(sub_tables_by_step.get(s.id) or _empty_step_sub_tables()) step_list.append(sd) from app.crud.production_task_step_assignee import enrich_step_assignees, enrich_step_completed_by_names enrich_step_assignees(db, step_list, tenant_no=tenant_no) enrich_step_completed_by_names(db, step_list) inputs = db.query(BizProductionTaskInput).filter( and_(BizProductionTaskInput.task_id == task_id, BizProductionTaskInput.del_flag == 0) ).order_by(BizProductionTaskInput.order_num.asc(), BizProductionTaskInput.create_time.asc()).all() outputs = db.query(BizProductionTaskOutput).filter( and_(BizProductionTaskOutput.task_id == task_id, BizProductionTaskOutput.del_flag == 0) ).order_by(BizProductionTaskOutput.order_num.asc(), BizProductionTaskOutput.create_time.asc()).all() result["steps"] = step_list # --- enrich inputs with cell product info --- input_dicts = [_task_input_to_dict(i) for i in inputs] cp_input_ids = [i.input_id for i in inputs if i.input_type == "cell_product" and i.input_id] input_product_map: Dict[str, Dict[str, Any]] = {} if cp_input_ids: cp_rows = db.query(BizCellProduct).filter( BizCellProduct.id.in_(set(cp_input_ids)), BizCellProduct.del_flag == 0 ).all() cp_dicts = [_cp_row_to_dict(c) for c in cp_rows] cp_dicts = _cp_enrich(db, tenant_no, cp_dicts) input_product_map = {d["id"]: d for d in cp_dicts} for idx, inp in enumerate(inputs): if inp.input_type == "cell_product" and inp.input_id and inp.input_id in input_product_map: input_dicts[idx] = _task_input_to_dict(inp, product_dict=input_product_map[inp.input_id]) sample_codes: List[str] = [] task_sample_code = (result.get("sampleCode") or "").strip() if task_sample_code: sample_codes.append(task_sample_code) for item in input_dicts: if item.get("inputType") == "sample": sc = (item.get("inputCode") or "").strip() if sc: sample_codes.append(sc) if sample_codes: from app.crud import sample_reception as reception_svc code_map = reception_svc.get_lab_storage_code_map_by_sample_codes( db, tenant_no, sample_codes ) if task_sample_code and code_map.get(task_sample_code) and not result.get("labStorageCode"): result["labStorageCode"] = code_map[task_sample_code] for item in input_dicts: if item.get("inputType") == "sample": sc = (item.get("inputCode") or "").strip() item["labStorageCode"] = code_map.get(sc, "") result["inputs"] = input_dicts # --- enrich outputs with cell product info --- output_dicts = [_task_output_to_dict(o) for o in outputs] cp_output_ids = [o.cell_product_id for o in outputs if o.cell_product_id] output_product_map: Dict[str, Dict[str, Any]] = {} if cp_output_ids: cp_rows = db.query(BizCellProduct).filter( BizCellProduct.id.in_(set(cp_output_ids)), BizCellProduct.del_flag == 0 ).all() cp_dicts = [_cp_row_to_dict(c) for c in cp_rows] cp_dicts = _cp_enrich(db, tenant_no, cp_dicts) output_product_map = {d["id"]: d for d in cp_dicts} for idx, out in enumerate(outputs): if out.cell_product_id and out.cell_product_id in output_product_map: output_dicts[idx] = _task_output_to_dict(out, product_dict=output_product_map[out.cell_product_id]) result["outputs"] = output_dicts return result def _build_process_library_for_editor( processes: List[Dict[str, Any]], categories: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """按分类分组工艺库,结构与前端 route-config 左栏一致。""" cat_map: Dict[str, Dict[str, Any]] = {} u_key = "__uncategorized__" cat_map[u_key] = {"categoryId": u_key, "categoryName": "未分类", "processes": []} for cat in categories or []: cat_id = (cat.get("id") or "").strip() if not cat_id: continue cat_map[cat_id] = { "categoryId": cat_id, "categoryName": cat.get("categoryName") or cat.get("name") or "未命名分类", "processes": [], } for proc in processes or []: cat_id = (proc.get("categoryId") or "").strip() or u_key if cat_id not in cat_map: cat_map[cat_id] = {"categoryId": cat_id, "categoryName": "未知分类", "processes": []} cat_map[cat_id]["processes"].append(proc) return list(cat_map.values()) def get_production_task_editor_init( db: Session, task_id: str, tenant_no: str, *, user_info=None, include_reference: bool = True, ) -> Optional[Dict[str, Any]]: """编辑生产任务弹窗初始化:任务详情 + 工艺库 + 步骤配置参考数据(单次聚合)。""" task = get_production_task_detail( db, task_id, tenant_no, include_step_sub_tables=True ) if not task: return None result: Dict[str, Any] = {"task": task} if not include_reference: return result from app.crud import consumable as consumable_svc from app.crud import equipment as equipment_svc from app.crud import item_unit as item_unit_svc from app.crud import output_type as output_type_svc from app.crud import process as process_svc from app.crud import process_category as process_category_svc from app.crud import reagent as reagent_svc from app.crud import sop_document as sop_document_svc processes = process_svc.list_all_processes(db, tenant_no, user_info=user_info) categories, _ = process_category_svc.list_process_categories( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) sops, _ = sop_document_svc.list_sop_documents( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) output_types, _ = output_type_svc.list_output_types( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) equipments, _ = equipment_svc.list_equipments( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) reagents, _ = reagent_svc.list_reagents( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) consumables, _ = consumable_svc.list_consumables( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) item_units, _ = item_unit_svc.list_item_units( db, page=1, page_size=9999, tenant_no=tenant_no, user_info=user_info ) result["processLibrary"] = _build_process_library_for_editor(processes, categories) result["options"] = { "processCategories": categories, "sops": sops, "outputTypes": output_types, "equipments": equipments, "reagents": reagents, "consumables": consumables, "itemUnits": item_units, } return result def update_production_task( db: Session, task_id: str, tenant_no: str, user_id: Optional[str], data: Dict[str, Any], ) -> BizProductionTask: task = db.query(BizProductionTask).filter( and_( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ).first() if not task: raise ValueError("任务不存在") for field in ["task_name", "description", "remark", "attachments"]: val = data.get(field) if val is not None: setattr(task, field, (val or "").strip() or None) if "equipment_ids" in data: eids, enames = resolve_equipment_list_snapshot(db, data.get("equipment_ids"), tenant_no) task.equipment_ids = eids task.equipment_name = enames task.update_by = user_id task.update_time = datetime.now() db.add(task) db.commit() db.refresh(task) return task def _consume_task_input_cell_products( db: Session, task_id: str, user_id: Optional[str], now: datetime, ) -> None: """任务首次完成时,将原材料中的细胞产品标记为已使用并释放库位占用。""" input_rows = db.query(BizProductionTaskInput).filter( and_( BizProductionTaskInput.task_id == task_id, BizProductionTaskInput.del_flag == 0, BizProductionTaskInput.input_type == "cell_product", BizProductionTaskInput.input_id.isnot(None), BizProductionTaskInput.input_id != "", ) ).all() seen_ids: set = set() consumable_from = frozenset({"frozen", "thawed", "in_production"}) for row in input_rows: cp_id = (row.input_id or "").strip() if not cp_id or cp_id in seen_ids: continue seen_ids.add(cp_id) product = db.query(BizCellProduct).filter( BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0, ).first() if not product: continue current = (product.product_status or "").strip() if current in ("used", "voided", "sold"): continue if current not in consumable_from: continue product.product_status = "used" product.update_by = user_id product.update_time = now db.add(product) if product.storage_slot_id: try: occupancy_svc.remove_by_item( db, "cell_product", product.id, user_id=user_id, ) except Exception: pass def update_task_status( db: Session, task_id: str, tenant_no: str, user_id: Optional[str], status: str, *, conclusion: Optional[str] = None, remark: Optional[str] = None, ) -> BizProductionTask: task = db.query(BizProductionTask).filter( and_( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ).first() if not task: raise ValueError("任务不存在") if task.task_status == "ywc" and status != "ywc": raise ValueError("任务已完成,不可变更状态") now = datetime.now() previous_status = task.task_status task.task_status = status if status == "jxz" and not task.started_time: task.started_time = now if status == "ywc": concl = (conclusion or "").strip() if concl not in ("cg", "sb"): raise ValueError("须选择生产结论(成功或失败)") if previous_status != "ywc": incomplete_step_count = db.query(BizProductionTaskStep).filter( and_( BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.del_flag == 0, BizProductionTaskStep.step_status != "ywc", ) ).count() if incomplete_step_count > 0: raise ValueError("尚有未完成的工艺步骤,请完成全部步骤后再完成任务") task.conclusion = concl if remark is not None: task.remark = (remark or "").strip() or None if previous_status != "ywc" and not task.completed_time: task.completed_time = now if previous_status != "ywc": _consume_task_input_cell_products(db, task.id, user_id, now) task.update_by = user_id task.update_time = now db.add(task) from app.crud import production_work_order as production_work_order_svc production_work_order_svc.sync_production_work_order_status_from_tasks( db, task.work_order_id or "", tenant_no, user_id ) db.commit() db.refresh(task) return task def add_task_step( db: Session, task_id: str, tenant_no: str, user_id: Optional[str], data: Dict[str, Any], ) -> Dict[str, Any]: task = db.query(BizProductionTask).filter( and_( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ).first() if not task: raise ValueError("任务不存在") now = datetime.now() step_order = data.get("step_order") if step_order is None: max_order = db.query(func.max(BizProductionTaskStep.step_order)).filter( and_(BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.del_flag == 0) ).scalar() step_order = (max_order or 0) + 1 # 如果传入了 process_id,从工艺主表深拷贝基本信息和子表数据 process_id = data.get("process_id") if process_id: proc = db.query(BizProcess).filter( and_(BizProcess.id == process_id, BizProcess.del_flag == 0) ).first() if not proc: raise ValueError("工艺不存在") step = BizProductionTaskStep( id=generate_uuid(), task_id=task_id, step_order=step_order, process_code=proc.process_code, category_id=proc.category_id, process_name=proc.process_name, description=proc.description, estimated_duration=proc.estimated_duration, sop_ids=proc.sop_ids, step_status="dzx", tenant_no=tenant_no, order_num=0, remark=None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(step) db.flush() # 深拷贝工艺子表 → 任务步骤子表 reagent_rows = db.query(BizProcessReagent).filter( and_(BizProcessReagent.process_id == process_id, BizProcessReagent.del_flag == 0) ).order_by(BizProcessReagent.order_num.asc(), BizProcessReagent.create_time.asc()).all() for idx, r in enumerate(reagent_rows): db.add(BizProductionTaskStepReagent( id=generate_uuid(), step_id=step.id, tenant_no=tenant_no, 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, remark=r.remark, order_num=_resolve_sub_table_order_num(r.order_num, idx, reagent_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) consumable_rows = db.query(BizProcessConsumable).filter( and_(BizProcessConsumable.process_id == process_id, BizProcessConsumable.del_flag == 0) ).order_by(BizProcessConsumable.order_num.asc(), BizProcessConsumable.create_time.asc()).all() for idx, c in enumerate(consumable_rows): db.add(BizProductionTaskStepConsumable( id=generate_uuid(), step_id=step.id, tenant_no=tenant_no, 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, order_num=_resolve_sub_table_order_num(c.order_num, idx, consumable_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) output_rows = db.query(BizProcessOutput).filter( and_(BizProcessOutput.process_id == process_id, BizProcessOutput.del_flag == 0) ).order_by(BizProcessOutput.order_num.asc(), BizProcessOutput.create_time.asc()).all() for idx, o in enumerate(output_rows): db.add(BizProductionTaskStepOutput( id=generate_uuid(), step_id=step.id, tenant_no=tenant_no, output_type_id=o.output_type_id, output_name=o.output_name, output_barcode=o.output_barcode, remark=o.remark, order_num=_resolve_sub_table_order_num(o.order_num, idx, output_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) collection_rows = db.query(BizProcessDataCollection).filter( and_(BizProcessDataCollection.process_id == process_id, BizProcessDataCollection.del_flag == 0) ).order_by(BizProcessDataCollection.order_num.asc(), BizProcessDataCollection.create_time.asc()).all() for idx, d in enumerate(collection_rows): db.add(BizProductionTaskStepDataCollection( id=generate_uuid(), step_id=step.id, tenant_no=tenant_no, collection_name=d.collection_name, unit=d.unit, remark=d.remark, order_num=_resolve_sub_table_order_num(d.order_num, idx, collection_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) db.commit() else: # 无 process_id 时,使用前端传来的字段直接创建(兼容旧逻辑) step = BizProductionTaskStep( id=generate_uuid(), task_id=task_id, step_order=step_order, process_code=(data.get("process_code") or "").strip() or None, category_id=(data.get("category_id") or "").strip() or None, process_name=(data.get("process_name") or "").strip() or None, description=(data.get("description") or "").strip() or None, estimated_duration=(data.get("estimated_duration") or "").strip() or None, actual_duration=(data.get("actual_duration") or "").strip() or None, sop_ids=(data.get("sop_ids") or "").strip() or None, step_status=(data.get("step_status") or "dzx"), tenant_no=tenant_no, order_num=0, remark=(data.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(step) db.commit() # 返回带子表的完整步骤信息(与 get_production_task_detail 格式一致) result = _step_to_dict(step) reagents = db.query(BizProductionTaskStepReagent).filter( and_(BizProductionTaskStepReagent.step_id == step.id, BizProductionTaskStepReagent.del_flag == 0) ).order_by(BizProductionTaskStepReagent.order_num.asc(), BizProductionTaskStepReagent.create_time.asc()).all() consumables = db.query(BizProductionTaskStepConsumable).filter( and_(BizProductionTaskStepConsumable.step_id == step.id, BizProductionTaskStepConsumable.del_flag == 0) ).order_by(BizProductionTaskStepConsumable.order_num.asc(), BizProductionTaskStepConsumable.create_time.asc()).all() outputs = db.query(BizProductionTaskStepOutput).filter( and_(BizProductionTaskStepOutput.step_id == step.id, BizProductionTaskStepOutput.del_flag == 0) ).order_by(BizProductionTaskStepOutput.order_num.asc(), BizProductionTaskStepOutput.create_time.asc()).all() data_collections = db.query(BizProductionTaskStepDataCollection).filter( and_(BizProductionTaskStepDataCollection.step_id == step.id, BizProductionTaskStepDataCollection.del_flag == 0) ).order_by(BizProductionTaskStepDataCollection.order_num.asc(), BizProductionTaskStepDataCollection.create_time.asc()).all() configured_reagents = db.query(BizProductionTaskStepConfiguredReagent).filter( and_(BizProductionTaskStepConfiguredReagent.step_id == step.id, BizProductionTaskStepConfiguredReagent.del_flag == 0) ).order_by(BizProductionTaskStepConfiguredReagent.order_num.asc(), BizProductionTaskStepConfiguredReagent.create_time.asc()).all() result["reagents"] = [_step_reagent_to_dict(r) for r in reagents] result["consumables"] = [_step_consumable_to_dict(c) for c in consumables] result["configuredReagents"] = [_step_configured_reagent_to_dict(r) for r in configured_reagents] result["outputs"] = [_step_output_to_dict(o) for o in outputs] result["dataCollections"] = [_step_data_collection_to_dict(d) for d in data_collections] return result def update_task_step( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], data: Dict[str, Any], ) -> BizProductionTaskStep: step = db.query(BizProductionTaskStep).filter( and_( BizProductionTaskStep.id == step_id, BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ) ).first() if not step: raise ValueError("步骤不存在") for field in [ "step_order", "process_code", "category_id", "process_name", "description", "estimated_duration", "actual_duration", "sop_ids", "step_status", "remark", ]: val = data.get(field) if val is not None: if field == "step_order": setattr(step, field, int(val)) else: setattr(step, field, (val or "").strip() or None) if data.get("started_time"): parsed = parse_datetime(str(data["started_time"]).strip()) if parsed is None: raise ValueError("开始时间格式无效") step.started_time = parsed if data.get("completed_time"): parsed = parse_datetime(str(data["completed_time"]).strip()) if parsed is None: raise ValueError("完成时间格式无效") step.completed_time = parsed new_status = (data.get("step_status") or "").strip() if new_status == "ywc" and user_id: step.completed_by = user_id step.update_by = user_id step.update_time = datetime.now() db.add(step) db.commit() db.refresh(step) return step def delete_task_step(db: Session, step_id: str, tenant_no: str, user_id: Optional[str]) -> None: step = db.query(BizProductionTaskStep).filter( and_( BizProductionTaskStep.id == step_id, BizProductionTaskStep.tenant_no == tenant_no, BizProductionTaskStep.del_flag == 0, ) ).first() if not step: raise ValueError("步骤不存在") now = datetime.now() step.del_flag = 1 step.update_by = user_id step.update_time = now db.add(step) _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now) _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now) _soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now) _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now) _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now) db.commit() def replace_step_reagents( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() # Look up task info from step_id for stock log step_row = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0) ).first() task_id = "" task_no = "" step_name = "" if step_row: step_name = step_row.process_name or "" task_row = db.query(BizProductionTask).filter( and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0) ).first() if task_row: task_id = task_row.id task_no = task_row.task_no or "" try: _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now) usage_items: List[Dict[str, Any]] = [] for idx, it in enumerate(items): req_id = ( (it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip() ) _require_requisition_for_catalog_material( db, tenant_no, it.get("reagent_code"), "reagent", it.get("actual_usage"), req_id, ) db.add(BizProductionTaskStepReagent( id=generate_uuid(), step_id=step_id, tenant_no=tenant_no, reagent_code=(it.get("reagent_code") or "").strip() or None, reagent_name=(it.get("reagent_name") or "").strip() or None, spec=(it.get("spec") or "").strip() or None, standard_usage=(it.get("standard_usage") or "").strip() or None, actual_usage=(it.get("actual_usage") or "").strip() or None, unit=(it.get("unit") or "").strip() or None, requisition_usage_id=req_id or None, is_key_material=it.get("is_key_material", "0"), remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) if req_id: usage_items.append({ "requisitionUsageId": req_id, "qty": it.get("actual_usage"), "usageUnit": it.get("unit"), "remark": it.get("remark"), }) from app.crud import production_material_usage as pm_usage_crud pm_usage_crud.register_step_usage_from_requisition( db, step_id, step_name, task_id, task_no, usage_items, tenant_no, user_id, "", ) db.commit() except Exception: db.rollback() raise def replace_step_consumables( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() # Look up task info from step_id for stock log step_row = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0) ).first() task_id = "" task_no = "" step_name = "" if step_row: step_name = step_row.process_name or "" task_row = db.query(BizProductionTask).filter( and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0) ).first() if task_row: task_id = task_row.id task_no = task_row.task_no or "" try: _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now) usage_items: List[Dict[str, Any]] = [] for idx, it in enumerate(items): req_id = ( (it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip() ) _require_requisition_for_catalog_material( db, tenant_no, it.get("consumable_code"), "consumable", it.get("actual_usage"), req_id, ) db.add(BizProductionTaskStepConsumable( id=generate_uuid(), step_id=step_id, tenant_no=tenant_no, consumable_code=(it.get("consumable_code") or "").strip() or None, consumable_name=(it.get("consumable_name") or "").strip() or None, spec_model=(it.get("spec_model") or "").strip() or None, standard_usage=(it.get("standard_usage") or "").strip() or None, actual_usage=(it.get("actual_usage") or "").strip() or None, unit=(it.get("unit") or "").strip() or None, requisition_usage_id=req_id or None, is_required=it.get("is_required", "1"), remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) if req_id: usage_items.append({ "requisitionUsageId": req_id, "qty": it.get("actual_usage"), "usageUnit": it.get("unit"), "remark": it.get("remark"), }) from app.crud import production_material_usage as pm_usage_crud pm_usage_crud.register_step_usage_from_requisition( db, step_id, step_name, task_id, task_no, usage_items, tenant_no, user_id, "", ) db.commit() except Exception: db.rollback() raise def replace_step_configured_reagents( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() step_row = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0) ).first() task_id = "" task_no = "" step_name = "" if step_row: step_name = step_row.process_name or "" task_row = db.query(BizProductionTask).filter( and_(BizProductionTask.id == step_row.task_id, BizProductionTask.del_flag == 0) ).first() if task_row: task_id = task_row.id task_no = task_row.task_no or "" try: _soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now) usage_items: List[Dict[str, Any]] = [] for idx, it in enumerate(items): req_id = ( (it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip() ) _require_requisition_for_catalog_material( db, tenant_no, it.get("configured_reagent_code"), "configured_reagent", it.get("actual_usage"), req_id, ) db.add(BizProductionTaskStepConfiguredReagent( id=generate_uuid(), step_id=step_id, tenant_no=tenant_no, configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None, configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None, spec=(it.get("spec") or "").strip() or None, standard_usage=(it.get("standard_usage") or "").strip() or None, actual_usage=(it.get("actual_usage") or "").strip() or None, unit=(it.get("unit") or "").strip() or None, requisition_usage_id=req_id or None, remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) if req_id: usage_items.append({ "requisitionUsageId": req_id, "qty": it.get("actual_usage"), "usageUnit": it.get("unit"), "remark": it.get("remark"), }) from app.crud import production_material_usage as pm_usage_crud pm_usage_crud.register_step_usage_from_requisition( db, step_id, step_name, task_id, task_no, usage_items, tenant_no, user_id, "", ) db.commit() except Exception: db.rollback() raise def replace_step_outputs( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now) for idx, it in enumerate(items): db.add(BizProductionTaskStepOutput( id=generate_uuid(), step_id=step_id, tenant_no=tenant_no, output_type_id=(it.get("output_type_id") or "").strip() or None, output_name=(it.get("output_name") or "").strip() or None, output_barcode=(it.get("output_barcode") or "").strip() or None, remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) db.commit() def replace_step_data_collections( db: Session, step_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now) for idx, it in enumerate(items): db.add(BizProductionTaskStepDataCollection( id=generate_uuid(), step_id=step_id, tenant_no=tenant_no, collection_name=(it.get("collection_name") or "").strip() or None, unit=(it.get("unit") or "").strip() or None, collection_value=(it.get("collection_value") or "").strip() or None, remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) db.commit() def spawn_temporary_work_order_from_task( db: Session, source_task_id: str, *, tenant_no: str, user_id: Optional[str] = None, create_dept_id: str = "", remark: str = "", ) -> Dict[str, Any]: """从生产任务创建临时生产工单,并返回待复制到新任务的原材料载荷(不含产出)。""" task = ( db.query(BizProductionTask) .filter( and_( BizProductionTask.id == source_task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ) .first() ) if not task: raise ValueError("任务不存在") inputs = ( db.query(BizProductionTaskInput) .filter( and_( BizProductionTaskInput.task_id == source_task_id, BizProductionTaskInput.del_flag == 0, ) ) .order_by(BizProductionTaskInput.order_num.asc(), BizProductionTaskInput.create_time.asc()) .all() ) if not inputs: raise ValueError("请先在原材料中填写并保存后再派发临时工单") copy_items: List[Dict[str, Any]] = [] for inp in inputs: inp_type = (inp.input_type or "").strip() inp_id = (inp.input_id or "").strip() inp_code = (inp.input_code or "").strip() inp_name = (inp.input_name or "").strip() if not inp_type or (not inp_id and not inp_code and not inp_name): raise ValueError("原材料数据不完整,请保存后再派发") copy_items.append({ "inputType": inp_type, "inputId": inp_id or None, "inputCode": inp_code or None, "inputName": inp_name or None, "remark": (inp.remark or "").strip() or None, }) user_remark = (remark or "").strip() full_remark = user_remark[:512] if user_remark else "" from app.crud import production_work_order as production_work_order_svc new_wo = production_work_order_svc.create_temp_production_work_order( db, tenant_no=tenant_no, user_id=user_id, create_dept_id=create_dept_id, remark=full_remark, ) return { "workOrder": production_work_order_svc._temp_wo_to_dict(new_wo), "sourceTaskId": task.id, "sourceTaskNo": task.task_no or "", "inputItems": copy_items, } def set_task_inputs( db: Session, task_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() # --- 1. 加载旧 inputs 并按 cell_product 恢复状态 --- old_rows = db.query(BizProductionTaskInput).filter( and_( BizProductionTaskInput.task_id == task_id, BizProductionTaskInput.del_flag == 0, ) ).all() for old_row in old_rows: # 如果是细胞产品,恢复状态 if old_row.input_type == "cell_product" and old_row.input_id: product = db.query(BizCellProduct).filter( BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0 ).first() if product and product.product_status == "in_production": product.product_status = "frozen" product.update_by = user_id product.update_time = now db.add(product) # --- 2. 全量软删除旧行 --- _soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now) # --- 3. 按传入顺序批量新建 --- for idx, it in enumerate(items): inp_type = (it.get("input_type") or it.get("inputType") or "").strip() inp_id = (it.get("input_id") or it.get("inputId") or "").strip() # 如果是细胞产品,设置状态为 in_production if inp_type == "cell_product" and inp_id: product = db.query(BizCellProduct).filter( BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0 ).first() if product and product.product_status in ("frozen", "thawed"): product.product_status = "in_production" product.update_by = user_id product.update_time = now db.add(product) db.add(BizProductionTaskInput( id=generate_uuid(), task_id=task_id, tenant_no=tenant_no, input_type=inp_type or None, input_id=inp_id or None, input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None, input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None, remark=(it.get("remark") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) db.flush() db.commit() def _parse_optional_date(val): """解析可选日期字符串(兼容 YYYY-MM-DD 与 ISO 8601)""" if not val: return None if isinstance(val, datetime): return val return parse_datetime(str(val).strip()) def _create_occupancy_for_product(db: Session, product: BizCellProduct, tenant_no: str, user_id: Optional[str]): """为细胞产品创建库位占用记录""" slot_id = (product.storage_slot_id or "").strip() if not slot_id: return occupancy_svc.assert_slot_vacant( db, slot_id, tenant_no=tenant_no, exclude_product_id=product.id, ) occupancy_svc._add_stored_occupancy_no_commit( db, slot_id=slot_id, item_type="cell_product", item_id=product.id, item_code=product.product_code or "", item_name=product.product_name or "", item_source="production", tenant_no=tenant_no, user_id=user_id, create_dept_id=product.create_dept_id or "", ) def _output_item_has_spec(it: Dict[str, Any]) -> bool: return any(k in it for k in ("spec_text", "specText", "spec_name", "specName")) def _output_item_spec_text(it: Dict[str, Any]) -> Optional[str]: for key in ("spec_text", "specText", "spec_name", "specName"): if key not in it: continue val = it[key] if val is None: return None s = str(val).strip() return s or None val = it.get("spec_text") or it.get("specText") or it.get("spec_name") or it.get("specName") if val is None: return None s = str(val).strip() return s or None def _update_product_from_output( db: Session, product: BizCellProduct, it: Dict[str, Any], tenant_no: str, user_id, now, ): """从 output item 更新细胞产品字段""" new_code = it.get("product_code") if it.get("product_code") is not None else it.get("productCode") if new_code is not None: code = (str(new_code) or "").strip() if code and code != (product.product_code or "").strip(): if _cp_product_code_exists(db, tenant_no, code, exclude_id=product.id): raise ValueError(f"产品编码 {code} 已存在") product.product_code = code field_map = [ ("product_name", "product_name", "productName"), ("product_type_id", "product_type_id", "productTypeId"), ("spec_id", "spec_id", "specId"), ("spec_text", "spec_text", "specText"), ("batch_no", "batch_no", "batchNo"), ("storage_slot_id", "storage_slot_id", "storageSlotId"), ("unit", "unit", "unit"), ("product_images", "product_images", "productImages"), ("remark", "remark", "remark"), ] for db_field, snake_key, camel_key in field_map: val = it.get(snake_key) if it.get(snake_key) is not None else it.get(camel_key) if val is not None: setattr(product, db_field, (str(val) or "").strip() or None) spec_text = _output_item_spec_text(it) if _output_item_has_spec(it): product.spec_text = spec_text for num_field, snake_key, camel_key in [ ("expected_cell_count", "expected_cell_count", "expectedCellCount"), ("viability", "viability", "viability"), ]: val = it.get(snake_key) if val is None: val = it.get(camel_key) if val is not None: setattr(product, num_field, val) for date_field, snake_key, camel_key in [ ("freeze_date", "freeze_date", "freezeDate"), ("expiry_date", "expiry_date", "expiryDate"), ]: val = it.get(snake_key) or it.get(camel_key) if val is not None: setattr(product, date_field, _parse_optional_date(val)) passage = it.get("passage") if passage is not None: product.passage = (str(passage) or "").strip() or None quantity = it.get("quantity") if quantity is not None: product.quantity = quantity product.update_by = user_id product.update_time = now def set_task_outputs( db: Session, task_id: str, tenant_no: str, user_id: Optional[str], items: List[Dict[str, Any]], ) -> None: now = datetime.now() batch_slot_ids: List[str] = [] for it in items: sid = (it.get("storage_slot_id") or it.get("storageSlotId") or "").strip() if sid: batch_slot_ids.append(sid) occupancy_svc.assert_batch_slots_unique(batch_slot_ids) # --- 1. 加载旧 outputs --- old_rows = db.query(BizProductionTaskOutput).filter( and_( BizProductionTaskOutput.task_id == task_id, BizProductionTaskOutput.del_flag == 0, ) ).all() old_cp_map: Dict[str, BizProductionTaskOutput] = {} for o in old_rows: if o.cell_product_id: old_cp_map[o.cell_product_id] = o # --- 2. 构建新的 cp_id 集合 --- new_cp_ids: set = set() for it in items: cp_id = (it.get("cell_product_id") or it.get("cellProductId") or "").strip() if cp_id: new_cp_ids.add(cp_id) # --- 3. 处理删除(旧有新无) --- for cp_id, old_row in old_cp_map.items(): if cp_id not in new_cp_ids: product = db.query(BizCellProduct).filter( BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0 ).first() if product: product.product_status = "voided" product.update_by = user_id product.update_time = now db.add(product) if product.storage_slot_id: occupancy_svc.remove_by_item(db, "cell_product", cp_id, user_id=user_id) old_row.del_flag = 1 old_row.update_by = user_id old_row.update_time = now db.add(old_row) # --- 4. 处理新增和修改 --- for idx, it in enumerate(items): cp_id = (it.get("cell_product_id") or it.get("cellProductId") or "").strip() if cp_id and cp_id in old_cp_map: # === 修改 === product = db.query(BizCellProduct).filter( BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0 ).first() if product: old_slot_id = product.storage_slot_id _update_product_from_output(db, product, it, tenant_no, user_id, now) new_slot_id = product.storage_slot_id db.add(product) if old_slot_id != new_slot_id: if old_slot_id: occupancy_svc.remove_by_item(db, "cell_product", cp_id, user_id=user_id) if new_slot_id: _create_occupancy_for_product(db, product, tenant_no, user_id) old_row = old_cp_map[cp_id] old_row.output_name = product.product_name if product else "" old_row.output_barcode = product.product_code if product else "" old_row.storage_slot_id = product.storage_slot_id if product else None old_row.quantity = it.get("quantity") old_row.unit = (it.get("unit") or "").strip() or None old_row.remark = (it.get("remark") or "").strip() or None old_row.order_num = idx old_row.update_by = user_id old_row.update_time = now db.add(old_row) else: # === 新增 === product = BizCellProduct( id=generate_uuid(), product_code=(it.get("product_code") or it.get("productCode") or "").strip(), product_name=(it.get("product_name") or it.get("productName") or "").strip(), product_type_id=(it.get("product_type_id") or it.get("productTypeId") or "").strip() or None, spec_id=(it.get("spec_id") or it.get("specId") or "").strip() or None, spec_text=_output_item_spec_text(it), passage=(it.get("passage") or "").strip() or None, batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None, storage_slot_id=(it.get("storage_slot_id") or it.get("storageSlotId") or "").strip() or None, expected_cell_count=it.get("expected_cell_count") if it.get("expected_cell_count") is not None else it.get("expectedCellCount"), viability=it.get("viability"), freeze_date=_parse_optional_date(it.get("freeze_date") or it.get("freezeDate")), product_status=it.get("product_status") or it.get("productStatus") or "frozen", source_type="production", production_task_id=task_id, quantity=it.get("quantity"), unit=(it.get("unit") or "").strip() or None, expiry_date=_parse_optional_date(it.get("expiry_date") or it.get("expiryDate")), product_images=(it.get("product_images") or it.get("productImages") or "").strip() or None, tenant_no=tenant_no, order_num=idx, remark=(it.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(product) db.flush() if product.storage_slot_id: _create_occupancy_for_product(db, product, tenant_no, user_id) output_row = BizProductionTaskOutput( id=generate_uuid(), task_id=task_id, tenant_no=tenant_no, cell_product_id=product.id, storage_slot_id=product.storage_slot_id, output_name=product.product_name or "", output_barcode=product.product_code or "", quantity=product.quantity, unit=product.unit, remark=product.remark, attachments=(it.get("attachments") or "").strip() or None, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(output_row) db.flush() db.commit() def reorder_task_steps(db: Session, task_id: str, step_ids: List[str]) -> None: now = datetime.now() for idx, sid in enumerate(step_ids): step = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.id == sid, BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.del_flag == 0) ).first() if step: step.step_order = idx + 1 step.update_time = now db.add(step) db.commit() def copy_task_step(db: Session, step_id: str, *, tenant_no: str = "zkxw", user_id: Optional[str] = None) -> Dict[str, Any]: """复制生产任务步骤,插入到原步骤之后,名称追加(复制)""" src = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.id == step_id, BizProductionTaskStep.del_flag == 0) ).first() if not src: raise ValueError("步骤不存在") now = datetime.now() new_order = src.step_order + 1 # 后续步骤 step_order +1 later_steps = db.query(BizProductionTaskStep).filter( and_( BizProductionTaskStep.task_id == src.task_id, BizProductionTaskStep.del_flag == 0, BizProductionTaskStep.step_order > src.step_order, ) ).order_by(BizProductionTaskStep.step_order.asc()).all() for ls in later_steps: ls.step_order += 1 ls.update_time = now db.add(ls) # 创建新步骤 new_name = (src.process_name or "").strip() if not new_name.endswith("(复制)"): new_name += "(复制)" new_step = BizProductionTaskStep( id=generate_uuid(), task_id=src.task_id, step_order=new_order, process_code=src.process_code, category_id=src.category_id, process_name=new_name, description=src.description, estimated_duration=src.estimated_duration, actual_duration=src.actual_duration, sop_ids=src.sop_ids, step_status="dzx", tenant_no=tenant_no, order_num=0, remark=src.remark, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(new_step) db.flush() # 复制子表:试剂(从生产任务步骤试剂表复制) reagent_rows = db.query(BizProductionTaskStepReagent).filter( and_(BizProductionTaskStepReagent.step_id == step_id, BizProductionTaskStepReagent.del_flag == 0) ).order_by( BizProductionTaskStepReagent.order_num.asc(), BizProductionTaskStepReagent.create_time.asc(), ).all() for idx, r in enumerate(reagent_rows): db.add(BizProductionTaskStepReagent( id=generate_uuid(), step_id=new_step.id, tenant_no=tenant_no, reagent_code=r.reagent_code, reagent_name=r.reagent_name, spec=r.spec, standard_usage=r.standard_usage, actual_usage=r.actual_usage, unit=r.unit, is_key_material=r.is_key_material, remark=r.remark, order_num=_resolve_sub_table_order_num(r.order_num, idx, reagent_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) # 复制子表:耗材 consumable_rows = db.query(BizProductionTaskStepConsumable).filter( and_(BizProductionTaskStepConsumable.step_id == step_id, BizProductionTaskStepConsumable.del_flag == 0) ).order_by( BizProductionTaskStepConsumable.order_num.asc(), BizProductionTaskStepConsumable.create_time.asc(), ).all() for idx, c in enumerate(consumable_rows): db.add(BizProductionTaskStepConsumable( id=generate_uuid(), step_id=new_step.id, tenant_no=tenant_no, consumable_code=c.consumable_code, consumable_name=c.consumable_name, spec_model=c.spec_model, standard_usage=c.standard_usage, actual_usage=c.actual_usage, unit=c.unit, is_required=c.is_required, remark=c.remark, order_num=_resolve_sub_table_order_num(c.order_num, idx, consumable_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) # 复制子表:产出 output_rows = db.query(BizProductionTaskStepOutput).filter( and_(BizProductionTaskStepOutput.step_id == step_id, BizProductionTaskStepOutput.del_flag == 0) ).order_by( BizProductionTaskStepOutput.order_num.asc(), BizProductionTaskStepOutput.create_time.asc(), ).all() for idx, o in enumerate(output_rows): db.add(BizProductionTaskStepOutput( id=generate_uuid(), step_id=new_step.id, tenant_no=tenant_no, output_type_id=o.output_type_id, output_name=o.output_name, output_barcode=o.output_barcode, remark=o.remark, order_num=_resolve_sub_table_order_num(o.order_num, idx, output_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) # 复制子表:数据采集 collection_rows = db.query(BizProductionTaskStepDataCollection).filter( and_(BizProductionTaskStepDataCollection.step_id == step_id, BizProductionTaskStepDataCollection.del_flag == 0) ).order_by( BizProductionTaskStepDataCollection.order_num.asc(), BizProductionTaskStepDataCollection.create_time.asc(), ).all() for idx, d in enumerate(collection_rows): db.add(BizProductionTaskStepDataCollection( id=generate_uuid(), step_id=new_step.id, tenant_no=tenant_no, collection_name=d.collection_name, unit=d.unit, collection_value=d.collection_value, remark=d.remark, order_num=_resolve_sub_table_order_num(d.order_num, idx, collection_rows), create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, )) db.commit() result = _step_to_dict(new_step) result.update(_step_sub_tables_to_dict(db, new_step.id)) return result def delete_production_task(db: Session, task_id: str, tenant_no: str, user_id: Optional[str]) -> None: """软删除生产任务及其所有子表数据(步骤 + 投入 + 产出)""" task = db.query(BizProductionTask).filter( and_( BizProductionTask.id == task_id, BizProductionTask.tenant_no == tenant_no, BizProductionTask.del_flag == 0, ) ).first() if not task: raise ValueError("任务不存在") now = datetime.now() # 1. 软删除所有步骤及其子表 steps = db.query(BizProductionTaskStep).filter( and_(BizProductionTaskStep.task_id == task_id, BizProductionTaskStep.del_flag == 0) ).all() for step in steps: step.del_flag = 1 step.update_by = user_id step.update_time = now db.add(step) _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step.id, now) _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step.id, now) _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step.id, now) _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step.id, now) # 2. 软删除投入 _soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now) # 3. 软删除产出 _soft_delete_sub(db, BizProductionTaskOutput, "task_id", task_id, now) # 4. 软删除任务本体 task.del_flag = 1 task.update_by = user_id task.update_time = now db.add(task) work_order_id = task.work_order_id or "" from app.crud import production_work_order as production_work_order_svc production_work_order_svc.sync_production_work_order_status_from_tasks( db, work_order_id, tenant_no, user_id ) db.commit() def total_pages(total: int, page_size: int) -> int: if total <= 0: return 0 return int(math.ceil(total / float(page_size))) _PRODUCTION_TAB_STEP_COUNT_KEYS = ( "inbound_cell_storage", "inbound_research_donation", "outbound_pilot_service", "outbound_basic_experiment", "temporary", ) def _empty_production_tab_step_counts() -> Dict[str, Dict[str, int]]: return { k: {"overdueIncomplete": 0, "futureIncomplete": 0} for k in _PRODUCTION_TAB_STEP_COUNT_KEYS } def _resolve_production_tab_key( wo_type: Optional[str], reception_source: Optional[str], biz_type: Optional[str], ) -> Optional[str]: wt = (wo_type or "inbound").strip() or "inbound" if wt == "temporary": return "temporary" if wt == "outbound": bt = (biz_type or "pilot_service").strip() or "pilot_service" if bt == "basic_experiment": return "outbound_basic_experiment" if bt == "clinical_application": return None return "outbound_pilot_service" rs = (reception_source or "cell_storage").strip() or "cell_storage" return "inbound_research_donation" if rs == "research_donation" else "inbound_cell_storage" def _aggregate_production_tab_step_field( counts: Dict[str, Dict[str, int]], rows, field: str, ) -> None: for wo_type, rs, bt, cnt in rows: tab_key = _resolve_production_tab_key(wo_type, rs, bt) if not tab_key or tab_key not in counts: continue counts[tab_key][field] += int(cnt or 0) def _base_active_step_count_query( db: Session, *, tenant_no: str, user_info=None, mine_user_id: Optional[str] = None, ): q = ( db.query( BizProductionWorkOrder.work_order_type, BizProductionWorkOrder.reception_source, BizProductionWorkOrder.biz_type, func.count(BizProductionTaskStep.id), ) .select_from(BizProductionTaskStep) .join( BizProductionTask, and_( BizProductionTask.id == BizProductionTaskStep.task_id, BizProductionTask.del_flag == 0, BizProductionTask.tenant_no == tenant_no, ), ) .join( BizProductionWorkOrder, and_( BizProductionWorkOrder.id == BizProductionTask.work_order_id, BizProductionWorkOrder.del_flag == 0, BizProductionWorkOrder.tenant_no == tenant_no, ), ) .filter( BizProductionTaskStep.del_flag == 0, BizProductionTaskStep.tenant_no == tenant_no, BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")), BizProductionTask.task_status.in_(("dpz", "jxz")), BizProductionTaskStep.step_status != "ywc", ) ) if user_info: from app.core.data_scope import apply_data_scope q = apply_data_scope(q, BizProductionWorkOrder, user_info, db, tenant_no) uid = (mine_user_id or "").strip() if uid: step_task_ids = ( db.query(BizProductionTaskStep.task_id) .join( BizProductionTaskStepAssignee, BizProductionTaskStepAssignee.step_id == BizProductionTaskStep.id, ) .filter( BizProductionTaskStepAssignee.user_id == uid, BizProductionTaskStepAssignee.tenant_no == tenant_no, BizProductionTaskStepAssignee.del_flag == 0, BizProductionTaskStep.del_flag == 0, ) .distinct() .subquery() ) q = q.filter( or_( BizProductionTask.id.in_(step_task_ids), BizProductionTask.current_assignee_user_id == uid, ) ) from app.crud.production_work_order import apply_exclude_inactive_protocol_filter q = apply_exclude_inactive_protocol_filter(q, exclude=True) return q def count_step_tab_badges_by_tab( db: Session, *, tenant_no: str, user_info=None, mine_user_id: Optional[str] = None, ) -> Dict[str, Dict[str, int]]: """按 Tab 统计工艺步骤角标:计划开始已到期未完成数 + 计划开始在今日之后未完成数。""" counts = _empty_production_tab_step_counts() today_end = datetime.now().replace(hour=23, minute=59, second=59, microsecond=0) overdue_rows = ( _base_active_step_count_query( db, tenant_no=tenant_no, user_info=user_info, mine_user_id=mine_user_id ) .filter( BizProductionTaskStep.planned_start_time.isnot(None), BizProductionTaskStep.planned_start_time <= today_end, ) .group_by( BizProductionWorkOrder.work_order_type, BizProductionWorkOrder.reception_source, BizProductionWorkOrder.biz_type, ) .all() ) _aggregate_production_tab_step_field(counts, overdue_rows, "overdueIncomplete") future_rows = ( _base_active_step_count_query( db, tenant_no=tenant_no, user_info=user_info, mine_user_id=mine_user_id ) .filter( BizProductionTaskStep.planned_start_time.isnot(None), BizProductionTaskStep.planned_start_time > today_end, ) .group_by( BizProductionWorkOrder.work_order_type, BizProductionWorkOrder.reception_source, BizProductionWorkOrder.biz_type, ) .all() ) _aggregate_production_tab_step_field(counts, future_rows, "futureIncomplete") return counts def count_production_work_order_tab_step_badges( db: Session, *, tenant_no: str, user_info=None ) -> Dict[str, Dict[str, int]]: return count_step_tab_badges_by_tab(db, tenant_no=tenant_no, user_info=user_info) def count_mine_production_work_order_tab_step_badges( db: Session, *, tenant_no: str, assignee_user_id: str ) -> Dict[str, Dict[str, int]]: return count_step_tab_badges_by_tab( db, tenant_no=tenant_no, mine_user_id=assignee_user_id )