Files
zkxw/backend/app/crud/production_task.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

3505 lines
132 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""生产任务 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.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.crud.user_display import user_display_name_map
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 "",
"sopContent": s.sop_content 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,
sop_content=rs.sop_content,
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
name_map = user_display_name_map(db, user_ids)
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 _force_complete_task_steps(
db: Session,
task_id: str,
tenant_no: str,
user_id: Optional[str],
now: datetime,
) -> int:
"""将任务下未完成步骤置为 ywc;不 commit。返回更新条数。"""
steps = (
db.query(BizProductionTaskStep)
.filter(
and_(
BizProductionTaskStep.task_id == task_id,
BizProductionTaskStep.tenant_no == tenant_no,
BizProductionTaskStep.del_flag == 0,
BizProductionTaskStep.step_status != "ywc",
)
)
.all()
)
for step in steps:
step.step_status = "ywc"
if user_id:
step.completed_by = user_id
step.completed_time = now
step.update_by = user_id
step.update_time = now
db.add(step)
return len(steps)
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":
_force_complete_task_steps(db, task_id, tenant_no, user_id, now)
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,
sop_content=proc.sop_content,
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,
sop_content=(data.get("sop_content") if str(data.get("sop_content") or "").strip() else 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", "sop_content", "step_status", "remark",
]:
val = data.get(field)
if val is not None:
if field == "step_order":
setattr(step, field, int(val))
elif field == "sop_content":
setattr(step, field, val if str(val).strip() else None)
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:
def build_row(row_id: str) -> BizProductionTaskStepReagent:
return BizProductionTaskStepReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
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,
)
row.reagent_code = (it.get("reagent_code") or "").strip() or None
row.reagent_name = (it.get("reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_key_material = it.get("is_key_material", "0")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
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:
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
return BizProductionTaskStepConsumable(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
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,
)
row.consumable_code = (it.get("consumable_code") or "").strip() or None
row.consumable_name = (it.get("consumable_name") or "").strip() or None
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_required = it.get("is_required", "1")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
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:
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
return BizProductionTaskStepConfiguredReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(
row: BizProductionTaskStepConfiguredReagent,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
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,
)
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConfiguredReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
duplicate_message="配置试剂编号重复",
foreign_message="配置试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
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 _apply_step_output_fields(
row: BizProductionTaskStepOutput,
item: Dict[str, Any],
*,
order_num: int,
user_id: Optional[str],
now: datetime,
) -> None:
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = order_num
row.update_by = user_id
row.update_time = now
row.del_flag = 0
def replace_step_outputs(
db: Session, step_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
"""
now = datetime.now()
def build_row(row_id: str) -> BizProductionTaskStepOutput:
return BizProductionTaskStepOutput(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
result_rows = sync_child_rows(
db,
BizProductionTaskStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
row, item, order_num=idx, user_id=user_id, now=now_
),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
return [_step_output_to_dict(row) for row in result_rows]
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()
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
return BizProductionTaskStepDataCollection(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(
row: BizProductionTaskStepDataCollection,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
row.collection_name = (it.get("collection_name") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.collection_value = (it.get("collection_value") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
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 _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
kind = (input_type or "").strip()
ident = (input_id or "").strip() or (input_code or "").strip()
if not kind or not ident:
return ""
return f"{kind}:{ident}"
def _task_input_item_identity(it: Dict[str, Any]) -> str:
return _task_input_identity(
it.get("input_type") or it.get("inputType"),
it.get("input_id") or it.get("inputId"),
it.get("input_code") or it.get("inputCode"),
)
def _set_cell_product_status(
db: Session,
product_id: Optional[str],
*,
user_id: Optional[str],
now: datetime,
to_status: str,
from_statuses: tuple,
) -> None:
pid = (product_id or "").strip()
if not pid:
return
product = db.query(BizCellProduct).filter(
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in from_statuses:
product.product_status = to_status
product.update_by = user_id
product.update_time = now
db.add(product)
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()
old_rows = db.query(BizProductionTaskInput).filter(
and_(
BizProductionTaskInput.task_id == task_id,
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
)
).all()
old_identity = {
row.id: (row.input_type or "", row.input_id or "")
for row in old_rows
}
def build_row(row_id: str) -> BizProductionTaskInput:
return BizProductionTaskInput(
id=row_id,
task_id=task_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionTaskInput,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_task_input_item_identity,
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
duplicate_message="原材料编号重复",
foreign_message="原材料不属于当前任务",
)
saved_ids = {row.id for row in saved}
for old_row in old_rows:
if old_row.id in saved_ids:
continue
if old_row.input_type == "cell_product":
_set_cell_product_status(
db,
old_row.input_id,
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
for row in saved:
previous = old_identity.get(row.id)
current = (row.input_type or "", row.input_id or "")
if previous != current and previous and previous[0] == "cell_product":
_set_cell_product_status(
db,
previous[1],
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
if row.input_type == "cell_product":
_set_cell_product_status(
db,
row.input_id,
user_id=user_id,
now=now,
to_status="in_production",
from_statuses=("frozen", "thawed"),
)
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:
# 作废并软删:避免同编码再入库时留下 voided+frozen 双行,污染各类选择器
product.product_status = "voided"
product.del_flag = 1
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,
sop_content=src.sop_content,
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
)