production_work_order.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609
  1. """入库生产工单 CRUD(含临时生产工单)"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from datetime import datetime
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import and_, exists, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.db_collation import eq_unicode, like_unicode
  9. from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
  10. from app.core.utils import generate_uuid
  11. from app.crud.collection_protocol import urls_to_attachment_list
  12. from app.crud import sample_reception as sample_reception_svc
  13. from app.crud import sample_reception_check_result as check_result_svc
  14. from app.crud import production_task as production_task_svc
  15. from app.models.models import (
  16. BizCollectionProtocol,
  17. BizProductionTask,
  18. BizProductionTaskInput,
  19. BizProductionTaskOutput,
  20. BizProductionTaskStep,
  21. BizProductionTaskStepAssignee,
  22. BizProductionTaskStepConsumable,
  23. BizProductionTaskStepDataCollection,
  24. BizProductionTaskStepOutput,
  25. BizProductionTaskStepReagent,
  26. BizProductionWorkOrder,
  27. BizPilotInfusionSchedule,
  28. BizPilotInfusionScheduleProduct,
  29. BizProtocolSample,
  30. BizSampleReception,
  31. )
  32. from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
  33. from app.schemas.production_work_order import ProductionWorkOrderComplete, ProductionWorkOrderListQuery
  34. from app.services.qc_work_order_fee_summary import enrich_work_order_fee_summaries
  35. _WORK_ORDER_STATUS_LABELS = {
  36. "dpz": "待配置",
  37. "jxz": "进行中",
  38. "ywc": "已完成",
  39. "yzf": "已作废",
  40. }
  41. _RECEPTION_SOURCE_LABELS = {
  42. "cell_storage": "细胞存储",
  43. "research_donation": "科研捐赠",
  44. "outbound": "出库生产",
  45. }
  46. _ACCEPT_CONCLUSION_LABELS = {"js": "接收", "bj": "拒收"}
  47. def apply_exclude_inactive_protocol_filter(query, *, exclude: bool = True):
  48. """排除关联采集协议/捐赠协议处于无效态(已作废、属地废弃)的入库生产工单。"""
  49. if not exclude:
  50. return query
  51. inactive_cell_storage = and_(
  52. BizProductionWorkOrder.work_order_type == "inbound",
  53. BizProductionWorkOrder.reception_source == "cell_storage",
  54. exists().where(
  55. and_(
  56. eq_unicode(
  57. BizCollectionProtocol.id,
  58. BizProductionWorkOrder.collection_protocol_id,
  59. ),
  60. BizCollectionProtocol.del_flag == 0,
  61. BizCollectionProtocol.protocol_status.in_(INACTIVE_PROTOCOL_STATUSES),
  62. )
  63. ),
  64. )
  65. inactive_research_donation = and_(
  66. BizProductionWorkOrder.work_order_type == "inbound",
  67. BizProductionWorkOrder.reception_source == "research_donation",
  68. exists().where(
  69. and_(
  70. eq_unicode(
  71. BizResearchDonation.id,
  72. BizProductionWorkOrder.collection_protocol_id,
  73. ),
  74. BizResearchDonation.del_flag == 0,
  75. BizResearchDonation.donation_status.in_(INACTIVE_PROTOCOL_STATUSES),
  76. )
  77. ),
  78. )
  79. return query.filter(~or_(inactive_cell_storage, inactive_research_donation))
  80. def apply_planned_start_time_step_filter(
  81. query,
  82. *,
  83. tenant_no: str,
  84. work_order_id_column,
  85. planned_start_time_start: Optional[str] = None,
  86. planned_start_time_end: Optional[str] = None,
  87. mine_user_id: Optional[str] = None,
  88. ):
  89. """按活跃工单/任务下未完成工艺步骤的计划开始时间过滤。"""
  90. start = (planned_start_time_start or "").strip()
  91. end = (planned_start_time_end or "").strip()
  92. if not start and not end:
  93. return query
  94. step_conditions = [
  95. BizProductionTaskStep.del_flag == 0,
  96. BizProductionTaskStep.tenant_no == tenant_no,
  97. BizProductionTaskStep.planned_start_time.isnot(None),
  98. BizProductionTaskStep.step_status != "ywc",
  99. BizProductionTask.id == BizProductionTaskStep.task_id,
  100. BizProductionTask.del_flag == 0,
  101. BizProductionTask.tenant_no == tenant_no,
  102. BizProductionTask.task_status.in_(("dpz", "jxz")),
  103. BizProductionWorkOrder.id == BizProductionTask.work_order_id,
  104. BizProductionWorkOrder.del_flag == 0,
  105. BizProductionWorkOrder.tenant_no == tenant_no,
  106. BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
  107. work_order_id_column == BizProductionTask.work_order_id,
  108. ]
  109. if start:
  110. step_conditions.append(
  111. BizProductionTaskStep.planned_start_time >= _normalize_date_bound(start)
  112. )
  113. if end:
  114. step_conditions.append(
  115. BizProductionTaskStep.planned_start_time
  116. <= _normalize_date_bound(end, end_of_day=True)
  117. )
  118. uid = (mine_user_id or "").strip()
  119. if uid:
  120. assignee_match = exists().where(
  121. and_(
  122. BizProductionTaskStepAssignee.step_id == BizProductionTaskStep.id,
  123. BizProductionTaskStepAssignee.user_id == uid,
  124. BizProductionTaskStepAssignee.tenant_no == tenant_no,
  125. BizProductionTaskStepAssignee.del_flag == 0,
  126. )
  127. )
  128. step_conditions.append(
  129. or_(assignee_match, BizProductionTask.current_assignee_user_id == uid)
  130. )
  131. return query.filter(exists().where(and_(*step_conditions)))
  132. def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
  133. val = (value or "").strip()
  134. if len(val) == 10:
  135. return f"{val} 23:59:59" if end_of_day else f"{val} 00:00:00"
  136. return val
  137. def _fmt_dt(t: Optional[datetime]) -> str:
  138. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  139. def _soft_delete_sub(db: Session, model_class, parent_field: str, parent_id: str, now: datetime) -> None:
  140. rows = db.query(model_class).filter(
  141. and_(
  142. getattr(model_class, parent_field) == parent_id,
  143. model_class.del_flag == 0,
  144. )
  145. ).all()
  146. for r in rows:
  147. r.del_flag = 1
  148. r.update_time = now
  149. def _generate_work_order_no(db: Session, tenant_no: str) -> str:
  150. today_str = datetime.now().strftime("%Y%m%d")
  151. prefix = f"SC{today_str}"
  152. last = (
  153. db.query(BizProductionWorkOrder)
  154. .filter(
  155. BizProductionWorkOrder.tenant_no == tenant_no,
  156. BizProductionWorkOrder.work_order_no.like(f"{prefix}%"),
  157. BizProductionWorkOrder.del_flag == 0,
  158. )
  159. .order_by(BizProductionWorkOrder.work_order_no.desc())
  160. .first()
  161. )
  162. seq = 1
  163. if last:
  164. seq = int(last.work_order_no[-4:]) + 1
  165. return f"{prefix}{seq:04d}"
  166. def _wo_to_dict(wo: BizProductionWorkOrder) -> Dict[str, Any]:
  167. return {
  168. "id": wo.id,
  169. "workOrderNo": wo.work_order_no,
  170. "workOrderType": getattr(wo, "work_order_type", None) or "inbound",
  171. "receptionId": wo.reception_id,
  172. "protocolSampleId": wo.protocol_sample_id,
  173. "collectionProtocolId": wo.collection_protocol_id,
  174. "protocolNo": wo.protocol_no or "",
  175. "sampleCode": wo.sample_code or "",
  176. "receptionSource": getattr(wo, "reception_source", "cell_storage") or "cell_storage",
  177. "receptionSourceLabel": _RECEPTION_SOURCE_LABELS.get(
  178. getattr(wo, "reception_source", "cell_storage") or "cell_storage",
  179. getattr(wo, "reception_source", "cell_storage") or "cell_storage",
  180. ),
  181. "infusionScheduleId": getattr(wo, "infusion_schedule_id", None) or None,
  182. "bizType": getattr(wo, "biz_type", None) or None,
  183. "bizTypeLabel": {
  184. "pilot_service": "中试服务",
  185. "clinical_application": "临床应用",
  186. "basic_experiment": "基础实验",
  187. }.get(getattr(wo, "biz_type", None) or "", ""),
  188. "workOrderStatus": wo.work_order_status,
  189. "workOrderStatusLabel": _WORK_ORDER_STATUS_LABELS.get(wo.work_order_status, wo.work_order_status),
  190. "qcConclusion": "",
  191. "qcConclusionLabel": "",
  192. "qcWorkOrderNo": "",
  193. "qcWorkOrderStatus": "",
  194. "qcWorkOrderStatusLabel": "",
  195. "receptionTime": "",
  196. "handlerName": "",
  197. "sampleTypeName": "",
  198. "remark": wo.remark or "",
  199. "createTime": _fmt_dt(wo.create_time),
  200. "updateTime": _fmt_dt(wo.update_time),
  201. }
  202. def _enrich_wo_items(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  203. from app.crud import qc_work_order as qc_wo_svc
  204. return qc_wo_svc._enrich_work_order_list_items(db, items)
  205. def _enrich_inbound_qc_conclusions(
  206. db: Session, tenant_no: str, items: List[Dict[str, Any]]
  207. ) -> List[Dict[str, Any]]:
  208. """入库(细胞存储/科研捐赠)生产工单列表补充门禁质检摘要。"""
  209. if not items or not (tenant_no or "").strip():
  210. return items
  211. from collections import defaultdict
  212. from app.services.protocol_sample_list_summary import (
  213. empty_gate_qc_summary_fields,
  214. get_list_gate_qc_summary_map_by_reception_ids,
  215. get_list_qc_conclusion_map_by_sample_codes,
  216. )
  217. def _apply_gate_qc_fields(item: Dict[str, Any], fields: Dict[str, str]) -> None:
  218. merged = empty_gate_qc_summary_fields()
  219. merged.update(fields or {})
  220. item["qcWorkOrderNo"] = merged.get("qcWorkOrderNo") or ""
  221. item["qcWorkOrderStatus"] = merged.get("qcWorkOrderStatus") or ""
  222. item["qcWorkOrderStatusLabel"] = merged.get("qcWorkOrderStatusLabel") or ""
  223. item["qcConclusion"] = merged.get("qcConclusion") or ""
  224. item["qcConclusionLabel"] = merged.get("qcConclusionLabel") or ""
  225. inbound_items: List[Dict[str, Any]] = []
  226. reception_ids: List[str] = []
  227. codes_by_source: Dict[str, List[str]] = defaultdict(list)
  228. for item in items:
  229. wo_type = (item.get("workOrderType") or "inbound").strip() or "inbound"
  230. source = (item.get("receptionSource") or "").strip()
  231. code = (item.get("sampleCode") or "").strip()
  232. if wo_type != "inbound" or source not in ("cell_storage", "research_donation"):
  233. _apply_gate_qc_fields(item, {})
  234. continue
  235. inbound_items.append(item)
  236. rid = (item.get("receptionId") or "").strip()
  237. if rid:
  238. reception_ids.append(rid)
  239. elif code:
  240. codes_by_source[source].append(code)
  241. summary_by_reception = get_list_gate_qc_summary_map_by_reception_ids(
  242. db, tenant_no, reception_ids
  243. )
  244. conclusion_maps: Dict[str, Dict[str, Dict[str, str]]] = {}
  245. for source, codes in codes_by_source.items():
  246. conclusion_maps[source] = get_list_qc_conclusion_map_by_sample_codes(
  247. db, tenant_no, codes, reception_source=source
  248. )
  249. for item in inbound_items:
  250. source = (item.get("receptionSource") or "").strip()
  251. code = (item.get("sampleCode") or "").strip()
  252. rid = (item.get("receptionId") or "").strip()
  253. if rid and rid in summary_by_reception:
  254. _apply_gate_qc_fields(item, summary_by_reception[rid])
  255. continue
  256. fields = conclusion_maps.get(source, {}).get(code) or {}
  257. _apply_gate_qc_fields(item, fields)
  258. return items
  259. _BIZ_TYPE_LABELS = {
  260. "pilot_service": "中试服务",
  261. "clinical_application": "临床应用",
  262. "basic_experiment": "基础实验",
  263. }
  264. def _dec_price(v: Any) -> Optional[float]:
  265. if v is None:
  266. return None
  267. try:
  268. return float(v)
  269. except (TypeError, ValueError):
  270. return None
  271. def _enrich_outbound_items(
  272. db: Session, tenant_no: str, items: List[Dict[str, Any]]
  273. ) -> List[Dict[str, Any]]:
  274. """为 outbound 类型工单附加申请单号、用户姓名、疗程序号、细胞类型、规格、输注时间等关联数据。"""
  275. from app.crud.pilot_infusion_schedule_mgmt import (
  276. _get_plan_schedule_course_meta,
  277. _resolve_course_display,
  278. )
  279. from app.models.models import (
  280. BizPilotApplication,
  281. BizPilotInfusionPlan,
  282. BizPilotInfusionSchedule,
  283. BizPilotPatient,
  284. )
  285. outbound_ids = [
  286. it["infusionScheduleId"]
  287. for it in items
  288. if it.get("workOrderType") == "outbound" and it.get("infusionScheduleId")
  289. ]
  290. if not outbound_ids:
  291. return items
  292. products_map: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
  293. for product in (
  294. db.query(BizPilotInfusionScheduleProduct)
  295. .filter(
  296. BizPilotInfusionScheduleProduct.infusion_schedule_id.in_(outbound_ids),
  297. BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
  298. BizPilotInfusionScheduleProduct.del_flag == 0,
  299. )
  300. .order_by(BizPilotInfusionScheduleProduct.order_num.asc())
  301. .all()
  302. ):
  303. products_map[product.infusion_schedule_id].append(
  304. {
  305. "projectCode": product.project_code or "",
  306. "projectName": product.project_name or "",
  307. "cellTypeName": product.cell_type_name or "",
  308. "specText": product.spec_text or "",
  309. "agreementPrice": _dec_price(product.agreement_price),
  310. "actualPrice": _dec_price(product.actual_price),
  311. "quantity": product.quantity,
  312. "productStatus": product.product_status or "",
  313. }
  314. )
  315. schedules = (
  316. db.query(BizPilotInfusionSchedule)
  317. .filter(
  318. BizPilotInfusionSchedule.id.in_(outbound_ids),
  319. BizPilotInfusionSchedule.tenant_no == tenant_no,
  320. BizPilotInfusionSchedule.del_flag == 0,
  321. )
  322. .all()
  323. )
  324. sched_map = {s.id: s for s in schedules}
  325. plan_ids = list({s.infusion_plan_id for s in schedules if s.infusion_plan_id})
  326. plan_map = {}
  327. if plan_ids:
  328. plans = (
  329. db.query(BizPilotInfusionPlan)
  330. .filter(
  331. BizPilotInfusionPlan.id.in_(plan_ids),
  332. BizPilotInfusionPlan.tenant_no == tenant_no,
  333. BizPilotInfusionPlan.del_flag == 0,
  334. )
  335. .all()
  336. )
  337. plan_map = {p.id: p for p in plans}
  338. app_ids = list({p.pilot_application_id for p in plan_map.values() if p.pilot_application_id})
  339. app_map = {}
  340. patient_map: Dict[str, BizPilotPatient] = {}
  341. if app_ids:
  342. apps = (
  343. db.query(BizPilotApplication)
  344. .filter(
  345. BizPilotApplication.id.in_(app_ids),
  346. BizPilotApplication.tenant_no == tenant_no,
  347. BizPilotApplication.del_flag == 0,
  348. )
  349. .all()
  350. )
  351. app_map = {a.id: a for a in apps}
  352. patients = (
  353. db.query(BizPilotPatient)
  354. .filter(
  355. BizPilotPatient.pilot_application_id.in_(app_ids),
  356. BizPilotPatient.tenant_no == tenant_no,
  357. BizPilotPatient.del_flag == 0,
  358. )
  359. .all()
  360. )
  361. patient_map = {p.pilot_application_id: p for p in patients}
  362. plan_course_meta = _get_plan_schedule_course_meta(db, plan_ids, tenant_no)
  363. for it in items:
  364. if it.get("workOrderType") != "outbound" or not it.get("infusionScheduleId"):
  365. continue
  366. sched = sched_map.get(it["infusionScheduleId"])
  367. if not sched:
  368. continue
  369. plan = plan_map.get(sched.infusion_plan_id) if sched.infusion_plan_id else None
  370. app = app_map.get(plan.pilot_application_id) if plan and plan.pilot_application_id else None
  371. patient = (
  372. patient_map.get(plan.pilot_application_id)
  373. if plan and plan.pilot_application_id
  374. else None
  375. )
  376. plan_meta = (
  377. plan_course_meta.get(sched.infusion_plan_id) if sched.infusion_plan_id else None
  378. )
  379. course = _resolve_course_display(sched, plan, plan_meta)
  380. it["applicationNo"] = app.application_no if app else ""
  381. it["applicationName"] = app.application_name if app else ""
  382. it["channelName"] = app.channel_name if app else ""
  383. if app:
  384. it["projectTitle"] = app.project_title or ""
  385. it["customerName"] = app.customer_name or ""
  386. it["patientName"] = patient.patient_name if patient else ""
  387. it["sortNo"] = course.get("sortNo")
  388. it["totalCourseCount"] = course.get("totalCourseCount")
  389. it["cellType"] = sched.cell_type or ""
  390. it["specText"] = sched.spec_text or ""
  391. it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
  392. it["infusionLocation"] = sched.infusion_location or ""
  393. it["products"] = products_map.get(it["infusionScheduleId"], [])
  394. return items
  395. def _outbound_work_order_ids_by_keyword(db: Session, tenant_no: str, keyword: str) -> List[str]:
  396. """出库工单关键词:按关联申请单号、申请单名称匹配。"""
  397. from app.models.models import (
  398. BizPilotApplication,
  399. BizPilotInfusionPlan,
  400. BizPilotInfusionSchedule,
  401. )
  402. kw = (keyword or "").strip()
  403. if not kw:
  404. return []
  405. like = f"%{kw}%"
  406. rows = (
  407. db.query(BizProductionWorkOrder.id)
  408. .join(
  409. BizPilotInfusionSchedule,
  410. eq_unicode(
  411. BizProductionWorkOrder.infusion_schedule_id,
  412. BizPilotInfusionSchedule.id,
  413. ),
  414. )
  415. .join(
  416. BizPilotInfusionPlan,
  417. eq_unicode(
  418. BizPilotInfusionSchedule.infusion_plan_id,
  419. BizPilotInfusionPlan.id,
  420. ),
  421. )
  422. .join(
  423. BizPilotApplication,
  424. eq_unicode(
  425. BizPilotInfusionPlan.pilot_application_id,
  426. BizPilotApplication.id,
  427. ),
  428. )
  429. .filter(
  430. BizProductionWorkOrder.del_flag == 0,
  431. BizProductionWorkOrder.tenant_no == tenant_no,
  432. BizProductionWorkOrder.work_order_type == "outbound",
  433. BizPilotInfusionSchedule.del_flag == 0,
  434. BizPilotInfusionPlan.del_flag == 0,
  435. BizPilotApplication.del_flag == 0,
  436. or_(
  437. like_unicode(BizPilotApplication.application_no, like),
  438. like_unicode(BizPilotApplication.application_name, like),
  439. like_unicode(BizPilotApplication.project_title, like),
  440. like_unicode(BizPilotApplication.customer_name, like),
  441. ),
  442. )
  443. .all()
  444. )
  445. return [r[0] for r in rows]
  446. def _reception_source_label(source: Optional[str]) -> str:
  447. key = (source or "").strip() or "cell_storage"
  448. return _RECEPTION_SOURCE_LABELS.get(key, key)
  449. def _accept_conclusion_label(v: Optional[str]) -> str:
  450. key = (v or "").strip()
  451. return _ACCEPT_CONCLUSION_LABELS.get(key, key)
  452. def _attach_reception_detail(
  453. db: Session, wo: BizProductionWorkOrder, out: Dict[str, Any], tenant_no: str
  454. ) -> None:
  455. """详情响应附加运输与样本接收信息(与质检工单详情一致)。"""
  456. reception_detail = sample_reception_svc.get_reception_by_id(db, wo.reception_id, tenant_no)
  457. if reception_detail:
  458. out["reception"] = reception_detail
  459. out["productionReception"] = reception_detail.get("productionReception")
  460. return
  461. rec = (
  462. db.query(BizSampleReception)
  463. .filter(BizSampleReception.id == wo.reception_id, BizSampleReception.del_flag == 0)
  464. .first()
  465. )
  466. cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no) if rec else []
  467. out["reception"] = {
  468. "receptionTime": _fmt_dt(rec.reception_time) if rec else "",
  469. "handlerName": (rec.handler_name if rec else "") or "",
  470. "acceptConclusion": (rec.accept_conclusion if rec else "") or "",
  471. "acceptConclusionLabel": _accept_conclusion_label(rec.accept_conclusion if rec else ""),
  472. "sourceType": wo.reception_source or "cell_storage",
  473. "sourceTypeLabel": _reception_source_label(wo.reception_source),
  474. "sampleTypeName": out.get("sampleTypeName", ""),
  475. "checkResults": cr,
  476. "receiveAttachments": (rec.receive_attachments if rec else "") or "",
  477. "receiveAttachmentFiles": urls_to_attachment_list(
  478. rec.receive_attachments if rec else None, db, tenant_no
  479. ),
  480. }
  481. def _attach_outbound_detail(
  482. db: Session, wo: BizProductionWorkOrder, out: Dict[str, Any]
  483. ) -> None:
  484. """outbound 工单详情附加申请单 + 输注计划信息。"""
  485. from app.crud.qc_work_order import build_outbound_pilot_context_by_schedule
  486. out["reception"] = {}
  487. schedule_id = getattr(wo, "infusion_schedule_id", None) or ""
  488. biz_type = getattr(wo, "biz_type", None) or ""
  489. if schedule_id and biz_type in ("pilot_service", "clinical_application", "basic_experiment"):
  490. pilot_ctx = build_outbound_pilot_context_by_schedule(
  491. db, schedule_id, wo.tenant_no or ""
  492. )
  493. out.update(pilot_ctx)
  494. return
  495. from app.models.models import (
  496. BizPilotApplication,
  497. BizPilotInfusionPlan,
  498. BizPilotInfusionSchedule,
  499. )
  500. _APP_STATUS_LABELS = {"dks": "待开始", "jxz": "进行中", "ywc": "已完成", "yzf": "已作废"}
  501. sched = (
  502. db.query(BizPilotInfusionSchedule)
  503. .filter(BizPilotInfusionSchedule.id == schedule_id, BizPilotInfusionSchedule.del_flag == 0)
  504. .first()
  505. )
  506. plan = None
  507. app = None
  508. if sched and sched.infusion_plan_id:
  509. plan = db.query(BizPilotInfusionPlan).filter(BizPilotInfusionPlan.id == sched.infusion_plan_id).first()
  510. if plan and plan.pilot_application_id:
  511. app = db.query(BizPilotApplication).filter(BizPilotApplication.id == plan.pilot_application_id).first()
  512. out["pilotApplication"] = {
  513. "applicationNo": app.application_no if app else "",
  514. "applicationName": app.application_name if app else "",
  515. "applicationStatus": app.application_status if app else "",
  516. "applicationStatusLabel": _APP_STATUS_LABELS.get(app.application_status, "") if app else "",
  517. } if app else {}
  518. out["infusionSchedule"] = {
  519. "cellType": sched.cell_type if sched else "",
  520. "specText": sched.spec_text if sched else "",
  521. "infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
  522. "infusionLocation": sched.infusion_location if sched else "",
  523. "infusionMethod": sched.infusion_method if sched else "",
  524. "scheduleStatus": sched.schedule_status if sched else "",
  525. } if sched else {}
  526. def _wo_to_response_dict(db: Session, wo: BizProductionWorkOrder) -> Dict[str, Any]:
  527. """工单响应(含接收记录关联的样本类型、接收人、接收时间及 reception 详情)。"""
  528. out = _enrich_wo_items(db, [_wo_to_dict(wo)])[0]
  529. tenant_no = (wo.tenant_no or "").strip()
  530. if tenant_no:
  531. out = _enrich_inbound_qc_conclusions(db, tenant_no, [out])[0]
  532. if wo.work_order_type == "outbound" and wo.infusion_schedule_id:
  533. _attach_outbound_detail(db, wo, out)
  534. else:
  535. _attach_reception_detail(db, wo, out, wo.tenant_no or "")
  536. if tenant_no:
  537. out = enrich_work_order_fee_summaries(db, [out], tenant_no)[0]
  538. return out
  539. def create_production_work_order_from_reception(
  540. db: Session,
  541. reception: BizSampleReception,
  542. tenant_no: str,
  543. user_id: Optional[str] = None,
  544. create_dept_id: str = "",
  545. ) -> Optional[BizProductionWorkOrder]:
  546. """接收结论为 js 时创建待配置入库生产工单(flush,不 commit)。"""
  547. if (reception.accept_conclusion or "").strip() != "js":
  548. return None
  549. exists = (
  550. db.query(BizProductionWorkOrder)
  551. .filter(
  552. BizProductionWorkOrder.reception_id == reception.id,
  553. BizProductionWorkOrder.tenant_no == tenant_no,
  554. BizProductionWorkOrder.del_flag == 0,
  555. )
  556. .first()
  557. )
  558. if exists:
  559. return exists
  560. source = getattr(reception, "reception_source", "cell_storage") or "cell_storage"
  561. if source == "research_donation":
  562. protocol_no = ""
  563. d = (
  564. db.query(BizResearchDonation)
  565. .filter(
  566. BizResearchDonation.id == (reception.research_donation_id or ""),
  567. BizResearchDonation.del_flag == 0,
  568. )
  569. .first()
  570. )
  571. if d:
  572. protocol_no = d.donation_no or ""
  573. sample_code = ""
  574. ds = (
  575. db.query(BizResearchDonationSample)
  576. .filter(
  577. BizResearchDonationSample.id == (reception.donation_sample_id or ""),
  578. BizResearchDonationSample.del_flag == 0,
  579. )
  580. .first()
  581. )
  582. if ds:
  583. sample_code = ds.sample_code or ""
  584. wo = BizProductionWorkOrder(
  585. id=generate_uuid(),
  586. work_order_no=_generate_work_order_no(db, tenant_no),
  587. reception_id=reception.id,
  588. protocol_sample_id=reception.donation_sample_id or "",
  589. collection_protocol_id=reception.research_donation_id or "",
  590. protocol_no=protocol_no or None,
  591. sample_code=sample_code or None,
  592. reception_source="research_donation",
  593. work_order_status="dpz",
  594. tenant_no=tenant_no,
  595. create_by=user_id,
  596. create_dept_id=create_dept_id or "",
  597. update_by=user_id,
  598. create_time=datetime.now(),
  599. update_time=datetime.now(),
  600. del_flag=0,
  601. )
  602. else:
  603. protocol_no = ""
  604. p = (
  605. db.query(BizCollectionProtocol)
  606. .filter(
  607. BizCollectionProtocol.id == reception.collection_protocol_id,
  608. BizCollectionProtocol.del_flag == 0,
  609. )
  610. .first()
  611. )
  612. if p:
  613. protocol_no = p.protocol_no or ""
  614. sample_code = ""
  615. s = (
  616. db.query(BizProtocolSample)
  617. .filter(
  618. BizProtocolSample.id == reception.protocol_sample_id,
  619. BizProtocolSample.del_flag == 0,
  620. )
  621. .first()
  622. )
  623. if s:
  624. sample_code = s.sample_code or ""
  625. wo = BizProductionWorkOrder(
  626. id=generate_uuid(),
  627. work_order_no=_generate_work_order_no(db, tenant_no),
  628. reception_id=reception.id,
  629. protocol_sample_id=reception.protocol_sample_id,
  630. collection_protocol_id=reception.collection_protocol_id,
  631. protocol_no=protocol_no or None,
  632. sample_code=sample_code or None,
  633. reception_source="cell_storage",
  634. work_order_status="dpz",
  635. tenant_no=tenant_no,
  636. create_by=user_id,
  637. create_dept_id=create_dept_id or "",
  638. update_by=user_id,
  639. create_time=datetime.now(),
  640. update_time=datetime.now(),
  641. del_flag=0,
  642. )
  643. db.add(wo)
  644. db.flush()
  645. return wo
  646. def list_production_work_orders(
  647. db: Session,
  648. *,
  649. tenant_no: str,
  650. query: Optional[ProductionWorkOrderListQuery] = None,
  651. page: int = 1,
  652. page_size: int = 20,
  653. user_info=None,
  654. ) -> Dict[str, Any]:
  655. def _filtered():
  656. q0 = db.query(BizProductionWorkOrder).filter(BizProductionWorkOrder.del_flag == 0)
  657. q0 = q0.filter(BizProductionWorkOrder.tenant_no == tenant_no)
  658. if not query:
  659. return q0
  660. wo_type = (query.work_order_type or "").strip()
  661. st_raw = (query.work_order_status or "").strip()
  662. if st_raw:
  663. statuses = [s.strip() for s in st_raw.split(",") if s.strip()]
  664. if statuses:
  665. q0 = q0.filter(BizProductionWorkOrder.work_order_status.in_(statuses))
  666. if wo_type:
  667. q0 = q0.filter(BizProductionWorkOrder.work_order_type == wo_type)
  668. rs = (query.reception_source or "").strip()
  669. if rs:
  670. q0 = q0.filter(BizProductionWorkOrder.reception_source == rs)
  671. bt = (query.biz_type or "").strip()
  672. if bt:
  673. q0 = q0.filter(BizProductionWorkOrder.biz_type == bt)
  674. kw = (query.keyword or "").strip()
  675. if kw:
  676. like = f"%{kw}%"
  677. if wo_type == "temporary":
  678. q0 = q0.filter(BizProductionWorkOrder.remark.like(like))
  679. else:
  680. keyword_conditions = [
  681. BizProductionWorkOrder.work_order_no.like(like),
  682. BizProductionWorkOrder.sample_code.like(like),
  683. ]
  684. from app.crud import sample_reception as reception_svc
  685. rec_ids = reception_svc.reception_ids_matching_lab_storage_keyword(
  686. db, tenant_no, kw
  687. )
  688. if rec_ids:
  689. keyword_conditions.append(BizProductionWorkOrder.reception_id.in_(rec_ids))
  690. lab_codes = reception_svc.sample_codes_matching_lab_storage_keyword(
  691. db, tenant_no, kw
  692. )
  693. if lab_codes:
  694. keyword_conditions.append(BizProductionWorkOrder.sample_code.in_(lab_codes))
  695. if wo_type == "outbound":
  696. outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
  697. if outbound_ids:
  698. keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
  699. q0 = q0.filter(or_(*keyword_conditions))
  700. elif query.work_order_no and query.work_order_no.strip():
  701. like = f"%{query.work_order_no.strip()}%"
  702. q0 = q0.filter(BizProductionWorkOrder.work_order_no.like(like))
  703. if query.sample_code and query.sample_code.strip():
  704. like = f"%{query.sample_code.strip()}%"
  705. q0 = q0.filter(BizProductionWorkOrder.sample_code.like(like))
  706. use_planned_start_time = (
  707. (query.planned_start_time_start and query.planned_start_time_start.strip())
  708. or (query.planned_start_time_end and query.planned_start_time_end.strip())
  709. )
  710. if use_planned_start_time:
  711. q0 = apply_planned_start_time_step_filter(
  712. q0,
  713. tenant_no=tenant_no,
  714. work_order_id_column=BizProductionWorkOrder.id,
  715. planned_start_time_start=query.planned_start_time_start,
  716. planned_start_time_end=query.planned_start_time_end,
  717. )
  718. else:
  719. use_infusion_time = wo_type == "outbound" and (
  720. (query.infusion_time_start and query.infusion_time_start.strip())
  721. or (query.infusion_time_end and query.infusion_time_end.strip())
  722. )
  723. if use_infusion_time:
  724. from app.models.models import BizPilotInfusionSchedule
  725. q0 = q0.join(
  726. BizPilotInfusionSchedule,
  727. eq_unicode(
  728. BizProductionWorkOrder.infusion_schedule_id,
  729. BizPilotInfusionSchedule.id,
  730. ),
  731. ).filter(
  732. BizPilotInfusionSchedule.del_flag == 0,
  733. BizPilotInfusionSchedule.tenant_no == tenant_no,
  734. )
  735. if query.infusion_time_start and query.infusion_time_start.strip():
  736. q0 = q0.filter(
  737. BizPilotInfusionSchedule.infusion_time
  738. >= _normalize_date_bound(query.infusion_time_start.strip())
  739. )
  740. if query.infusion_time_end and query.infusion_time_end.strip():
  741. q0 = q0.filter(
  742. BizPilotInfusionSchedule.infusion_time
  743. <= _normalize_date_bound(query.infusion_time_end.strip(), end_of_day=True)
  744. )
  745. else:
  746. if query.create_time_start and query.create_time_start.strip():
  747. q0 = q0.filter(
  748. BizProductionWorkOrder.create_time
  749. >= _normalize_date_bound(query.create_time_start.strip())
  750. )
  751. if query.create_time_end and query.create_time_end.strip():
  752. q0 = q0.filter(
  753. BizProductionWorkOrder.create_time
  754. <= _normalize_date_bound(query.create_time_end.strip(), end_of_day=True)
  755. )
  756. if user_info:
  757. from app.core.data_scope import apply_data_scope
  758. q0 = apply_data_scope(q0, BizProductionWorkOrder, user_info, db, tenant_no)
  759. exclude_inactive = True
  760. if query and query.exclude_void_protocol is not None:
  761. exclude_inactive = bool(query.exclude_void_protocol)
  762. q0 = apply_exclude_inactive_protocol_filter(q0, exclude=exclude_inactive)
  763. return q0
  764. effective_page = query.page if query and query.page else page
  765. effective_size = query.page_size if query and query.page_size else page_size
  766. total = _filtered().order_by(None).with_entities(func.count(BizProductionWorkOrder.id)).scalar() or 0
  767. effective_page = max(1, effective_page)
  768. effective_size = min(max(1, effective_size), 100)
  769. rows = (
  770. _filtered()
  771. .order_by(BizProductionWorkOrder.create_time.desc())
  772. .offset((effective_page - 1) * effective_size)
  773. .limit(effective_size)
  774. .all()
  775. )
  776. items = _enrich_wo_items(db, [_wo_to_dict(x) for x in rows])
  777. items = _enrich_inbound_qc_conclusions(db, tenant_no, items)
  778. items = _enrich_outbound_items(db, tenant_no, items)
  779. items = enrich_work_order_fee_summaries(db, items, tenant_no)
  780. return {"items": items, "total": int(total)}
  781. def sync_production_work_order_status_from_tasks(
  782. db: Session,
  783. work_order_id: str,
  784. tenant_no: str,
  785. user_id: Optional[str] = None,
  786. ) -> None:
  787. """按关联生产任务回写工单:存在未删除任务则为 jxz,全部删除后回 dpz(已完成工单不改动)。"""
  788. if not (work_order_id or "").strip():
  789. return
  790. db.flush()
  791. wo = (
  792. db.query(BizProductionWorkOrder)
  793. .filter(
  794. BizProductionWorkOrder.id == work_order_id.strip(),
  795. BizProductionWorkOrder.tenant_no == tenant_no,
  796. BizProductionWorkOrder.del_flag == 0,
  797. )
  798. .first()
  799. )
  800. if not wo or (wo.work_order_status or "").strip() == "ywc":
  801. return
  802. task_count = (
  803. db.query(BizProductionTask.id)
  804. .filter(
  805. BizProductionTask.work_order_id == work_order_id.strip(),
  806. BizProductionTask.tenant_no == tenant_no,
  807. BizProductionTask.del_flag == 0,
  808. )
  809. .count()
  810. )
  811. new_status = "jxz" if task_count > 0 else "dpz"
  812. if (wo.work_order_status or "").strip() == new_status:
  813. return
  814. now = datetime.now()
  815. wo.work_order_status = new_status
  816. wo.update_by = user_id
  817. wo.update_time = now
  818. db.add(wo)
  819. from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
  820. sync_schedule_from_production_work_order(db, wo, user_id=user_id)
  821. def get_production_work_order(
  822. db: Session, row_id: str, tenant_no: str
  823. ) -> Optional[BizProductionWorkOrder]:
  824. return (
  825. db.query(BizProductionWorkOrder)
  826. .filter(
  827. BizProductionWorkOrder.id == row_id,
  828. BizProductionWorkOrder.tenant_no == tenant_no,
  829. BizProductionWorkOrder.del_flag == 0,
  830. )
  831. .first()
  832. )
  833. def update_production_work_order(
  834. db: Session,
  835. row_id: str,
  836. tenant_no: str,
  837. user_id: Optional[str],
  838. *,
  839. remark: Optional[str] = None,
  840. ) -> BizProductionWorkOrder:
  841. """更新生产工单备注(入库/临时均可,任意状态)"""
  842. wo = get_production_work_order(db, row_id, tenant_no)
  843. if not wo:
  844. raise ValueError("工单不存在")
  845. if remark is not None:
  846. wo.remark = (remark or "").strip() or None
  847. wo.update_by = user_id
  848. wo.update_time = datetime.now()
  849. db.add(wo)
  850. db.flush()
  851. return wo
  852. def complete_production_work_order(
  853. db: Session,
  854. row_id: str,
  855. tenant_no: str,
  856. user_id: str,
  857. data: Optional[ProductionWorkOrderComplete] = None,
  858. ) -> BizProductionWorkOrder:
  859. wo = get_production_work_order(db, row_id, tenant_no)
  860. if not wo:
  861. raise ValueError("工单不存在")
  862. if wo.work_order_status == "ywc":
  863. raise ValueError("工单已完成")
  864. if not data:
  865. raise ValueError("须选择生产结论(成功或失败)")
  866. concl = (getattr(data, "conclusion", None) or "").strip()
  867. if concl not in ("cg", "sb"):
  868. raise ValueError("须选择生产结论(成功或失败)")
  869. remark_val = None
  870. if getattr(data, "remark", None) is not None:
  871. remark_val = (data.remark or "").strip() or None
  872. now = datetime.now()
  873. incomplete_tasks = (
  874. db.query(BizProductionTask)
  875. .filter(
  876. BizProductionTask.work_order_id == wo.id,
  877. BizProductionTask.tenant_no == tenant_no,
  878. BizProductionTask.del_flag == 0,
  879. BizProductionTask.task_status != "ywc",
  880. )
  881. .all()
  882. )
  883. for task in incomplete_tasks:
  884. production_task_svc._force_complete_task_steps(
  885. db, task.id, tenant_no, user_id, now
  886. )
  887. task.task_status = "ywc"
  888. task.conclusion = concl
  889. if remark_val is not None:
  890. task.remark = remark_val
  891. if not task.completed_time:
  892. task.completed_time = now
  893. task.update_by = user_id
  894. task.update_time = now
  895. db.add(task)
  896. production_task_svc._consume_task_input_cell_products(
  897. db, task.id, user_id, now
  898. )
  899. wo.work_order_status = "ywc"
  900. wo.update_by = user_id
  901. wo.update_time = now
  902. if remark_val is not None:
  903. wo.remark = remark_val
  904. db.add(wo)
  905. from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
  906. sync_schedule_from_production_work_order(db, wo, user_id=user_id)
  907. db.commit()
  908. db.refresh(wo)
  909. return wo
  910. def void_production_work_order(
  911. db: Session,
  912. row_id: str,
  913. tenant_no: str,
  914. user_id: str,
  915. ) -> BizProductionWorkOrder:
  916. wo = get_production_work_order(db, row_id, tenant_no)
  917. if not wo:
  918. raise ValueError("工单不存在")
  919. if wo.work_order_status == "ywc":
  920. raise ValueError("已完成的工单不能作废")
  921. if wo.work_order_status == "yzf":
  922. raise ValueError("工单已作废")
  923. if wo.work_order_status not in ("dpz", "jxz"):
  924. raise ValueError("仅待配置或进行中的工单可作废")
  925. now = datetime.now()
  926. incomplete_tasks = (
  927. db.query(BizProductionTask)
  928. .filter(
  929. BizProductionTask.work_order_id == wo.id,
  930. BizProductionTask.tenant_no == tenant_no,
  931. BizProductionTask.del_flag == 0,
  932. BizProductionTask.task_status != "ywc",
  933. )
  934. .all()
  935. )
  936. for task in incomplete_tasks:
  937. task.task_status = "yzf"
  938. task.update_by = user_id
  939. task.update_time = now
  940. db.add(task)
  941. wo.work_order_status = "yzf"
  942. wo.update_by = user_id
  943. wo.update_time = now
  944. db.add(wo)
  945. from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
  946. sync_schedule_from_production_work_order(db, wo, user_id=user_id)
  947. db.commit()
  948. db.refresh(wo)
  949. return wo
  950. _TASK_STATUS_LABELS = {
  951. "dpz": "待配置",
  952. "dzx": "待执行",
  953. "jxz": "执行中",
  954. "ywc": "已完成",
  955. "yzf": "已作废",
  956. }
  957. def _production_task_info(db: Session, task: BizProductionTask) -> Dict[str, Any]:
  958. """将单条生产任务(含投入物、产出物)转为摘要字典。"""
  959. inputs = (
  960. db.query(BizProductionTaskInput)
  961. .filter(
  962. BizProductionTaskInput.task_id == task.id,
  963. BizProductionTaskInput.del_flag == 0,
  964. )
  965. .order_by(BizProductionTaskInput.order_num)
  966. .all()
  967. )
  968. outputs = (
  969. db.query(BizProductionTaskOutput)
  970. .filter(
  971. BizProductionTaskOutput.task_id == task.id,
  972. BizProductionTaskOutput.del_flag == 0,
  973. )
  974. .order_by(BizProductionTaskOutput.order_num)
  975. .all()
  976. )
  977. return {
  978. "id": task.id,
  979. "taskNo": task.task_no,
  980. "taskName": task.task_name or "",
  981. "taskStatus": task.task_status or "",
  982. "taskStatusLabel": _TASK_STATUS_LABELS.get(task.task_status, task.task_status or ""),
  983. "conclusion": (getattr(task, "conclusion", None) or "").strip(),
  984. "conclusionLabel": production_task_svc._production_conclusion_label(
  985. getattr(task, "conclusion", None)
  986. ),
  987. "startedTime": _fmt_dt(task.started_time),
  988. "completedTime": _fmt_dt(task.completed_time),
  989. "inputs": [
  990. {
  991. "inputType": (inp.input_type or ""),
  992. "inputCode": (inp.input_code or ""),
  993. "inputName": (inp.input_name or ""),
  994. }
  995. for inp in inputs
  996. ],
  997. "outputs": [
  998. {
  999. "outputType": "product",
  1000. "outputCode": (out.output_barcode or ""),
  1001. "outputName": (out.output_name or ""),
  1002. }
  1003. for out in outputs
  1004. ],
  1005. }
  1006. def _production_info_item(db: Session, wo: BizProductionWorkOrder) -> Dict[str, Any]:
  1007. """将单条生产工单(含任务列表)转为摘要字典。"""
  1008. tasks = (
  1009. db.query(BizProductionTask)
  1010. .filter(
  1011. BizProductionTask.work_order_id == wo.id,
  1012. BizProductionTask.tenant_no == wo.tenant_no,
  1013. BizProductionTask.del_flag == 0,
  1014. )
  1015. .order_by(BizProductionTask.order_num, BizProductionTask.create_time)
  1016. .all()
  1017. )
  1018. return {
  1019. "workOrderNo": wo.work_order_no or "",
  1020. "workOrderStatus": wo.work_order_status or "",
  1021. "workOrderStatusLabel": _WORK_ORDER_STATUS_LABELS.get(wo.work_order_status, wo.work_order_status or ""),
  1022. "tasks": [_production_task_info(db, t) for t in tasks],
  1023. }
  1024. def get_production_info_map_for_samples(
  1025. db: Session,
  1026. sample_ids: List[str],
  1027. tenant_no: str,
  1028. *,
  1029. sample_code_by_id: Optional[Dict[str, str]] = None,
  1030. ) -> Dict[str, Dict[str, Any]]:
  1031. """按样本批量返回生产工单摘要(含任务、投入物、产出物),供协议查看弹窗使用。
  1032. 细胞存储:传入 sample_code_by_id 时,按租户 + 样本编码关联。
  1033. 科研捐赠等:仅传 sample_ids 时,按 protocol_sample_id 关联。
  1034. """
  1035. if not tenant_no:
  1036. return {}
  1037. if sample_code_by_id:
  1038. codes = list({(c or "").strip() for c in sample_code_by_id.values() if (c or "").strip()})
  1039. if not codes:
  1040. return {}
  1041. wos = (
  1042. db.query(BizProductionWorkOrder)
  1043. .filter(
  1044. BizProductionWorkOrder.tenant_no == tenant_no,
  1045. BizProductionWorkOrder.del_flag == 0,
  1046. BizProductionWorkOrder.sample_code.in_(codes),
  1047. )
  1048. .all()
  1049. )
  1050. code_to_wo: Dict[str, BizProductionWorkOrder] = {}
  1051. for wo in wos:
  1052. code = (wo.sample_code or "").strip()
  1053. if code and code not in code_to_wo:
  1054. code_to_wo[code] = wo
  1055. out: Dict[str, Dict[str, Any]] = {}
  1056. for sid, raw_code in sample_code_by_id.items():
  1057. code = (raw_code or "").strip()
  1058. if not code:
  1059. continue
  1060. wo = code_to_wo.get(code)
  1061. if wo:
  1062. out[sid] = _production_info_item(db, wo)
  1063. return out
  1064. if not sample_ids:
  1065. return {}
  1066. wos = (
  1067. db.query(BizProductionWorkOrder)
  1068. .filter(
  1069. BizProductionWorkOrder.protocol_sample_id.in_(sample_ids),
  1070. BizProductionWorkOrder.tenant_no == tenant_no,
  1071. BizProductionWorkOrder.del_flag == 0,
  1072. )
  1073. .all()
  1074. )
  1075. out = {}
  1076. for wo in wos:
  1077. sid = (wo.protocol_sample_id or "").strip()
  1078. if sid:
  1079. out[sid] = _production_info_item(db, wo)
  1080. return out
  1081. def create_production_work_order_manual(
  1082. db: Session,
  1083. *,
  1084. tenant_no: str,
  1085. user_id: Optional[str] = None,
  1086. create_dept_id: str = "",
  1087. protocol_no: str = "",
  1088. sample_code: str = "",
  1089. reception_source: str = "cell_storage",
  1090. remark: str = "",
  1091. ) -> BizProductionWorkOrder:
  1092. """手动创建生产工单(不关联接收记录)"""
  1093. wo = BizProductionWorkOrder(
  1094. id=generate_uuid(),
  1095. work_order_no=_generate_work_order_no(db, tenant_no),
  1096. work_order_type="inbound",
  1097. reception_id=None,
  1098. protocol_sample_id=None,
  1099. collection_protocol_id=None,
  1100. protocol_no=protocol_no or None,
  1101. sample_code=sample_code or None,
  1102. reception_source=reception_source,
  1103. work_order_status="dpz",
  1104. tenant_no=tenant_no,
  1105. remark=remark,
  1106. create_by=user_id,
  1107. create_dept_id=create_dept_id,
  1108. )
  1109. db.add(wo)
  1110. db.flush()
  1111. return wo
  1112. def _generate_outbound_work_order_no(db: Session, tenant_no: str) -> str:
  1113. today_str = datetime.now().strftime("%Y%m%d")
  1114. prefix = f"CKSC{today_str}"
  1115. last = (
  1116. db.query(BizProductionWorkOrder)
  1117. .filter(
  1118. BizProductionWorkOrder.tenant_no == tenant_no,
  1119. BizProductionWorkOrder.work_order_no.like(f"{prefix}%"),
  1120. BizProductionWorkOrder.del_flag == 0,
  1121. )
  1122. .order_by(BizProductionWorkOrder.work_order_no.desc())
  1123. .first()
  1124. )
  1125. seq = 1
  1126. if last:
  1127. seq = int(last.work_order_no[-4:]) + 1
  1128. return f"{prefix}{seq:04d}"
  1129. def create_outbound_production_work_order(
  1130. db: Session,
  1131. *,
  1132. schedule_id: str,
  1133. tenant_no: str,
  1134. user_id: Optional[str] = None,
  1135. create_dept_id: str = "",
  1136. remark: str = "",
  1137. biz_type: str = "pilot_service",
  1138. ) -> BizProductionWorkOrder:
  1139. existing = (
  1140. db.query(BizProductionWorkOrder)
  1141. .filter(
  1142. BizProductionWorkOrder.infusion_schedule_id == schedule_id,
  1143. BizProductionWorkOrder.tenant_no == tenant_no,
  1144. BizProductionWorkOrder.del_flag == 0,
  1145. )
  1146. .first()
  1147. )
  1148. if existing:
  1149. if (existing.biz_type or "") != biz_type:
  1150. existing.biz_type = biz_type
  1151. db.flush()
  1152. return existing
  1153. wo = BizProductionWorkOrder(
  1154. id=generate_uuid(),
  1155. work_order_no=_generate_outbound_work_order_no(db, tenant_no),
  1156. work_order_type="outbound",
  1157. reception_id=None,
  1158. protocol_sample_id=None,
  1159. collection_protocol_id=None,
  1160. protocol_no=None,
  1161. sample_code=None,
  1162. reception_source="outbound",
  1163. infusion_schedule_id=schedule_id,
  1164. biz_type=biz_type,
  1165. work_order_status="dpz",
  1166. tenant_no=tenant_no,
  1167. remark=remark,
  1168. create_by=user_id,
  1169. create_dept_id=create_dept_id,
  1170. )
  1171. db.add(wo)
  1172. db.flush()
  1173. return wo
  1174. def delete_production_work_order(db: Session, work_order_id: str, tenant_no: str, user_id: Optional[str]) -> None:
  1175. wo = db.query(BizProductionWorkOrder).filter(
  1176. and_(
  1177. BizProductionWorkOrder.id == work_order_id,
  1178. BizProductionWorkOrder.tenant_no == tenant_no,
  1179. BizProductionWorkOrder.del_flag == 0,
  1180. )
  1181. ).first()
  1182. if not wo:
  1183. raise ValueError("工单不存在")
  1184. if wo.work_order_status == "ywc":
  1185. raise ValueError("已完成的工单不能删除")
  1186. now = datetime.now()
  1187. tasks = db.query(BizProductionTask).filter(
  1188. and_(BizProductionTask.work_order_id == work_order_id, BizProductionTask.del_flag == 0)
  1189. ).all()
  1190. for task in tasks:
  1191. steps = db.query(BizProductionTaskStep).filter(
  1192. and_(BizProductionTaskStep.task_id == task.id, BizProductionTaskStep.del_flag == 0)
  1193. ).all()
  1194. for step in steps:
  1195. _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step.id, now)
  1196. _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step.id, now)
  1197. _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step.id, now)
  1198. _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step.id, now)
  1199. step.del_flag = 1
  1200. step.update_by = user_id
  1201. step.update_time = now
  1202. _soft_delete_sub(db, BizProductionTaskInput, "task_id", task.id, now)
  1203. _soft_delete_sub(db, BizProductionTaskOutput, "task_id", task.id, now)
  1204. task.del_flag = 1
  1205. task.update_by = user_id
  1206. task.update_time = now
  1207. wo.del_flag = 1
  1208. wo.update_by = user_id
  1209. wo.update_time = now
  1210. db.commit()
  1211. # ---------------------------------------------------------------------------
  1212. # 临时生产工单(work_order_type='temporary')
  1213. # ---------------------------------------------------------------------------
  1214. def _generate_temp_work_order_no(db: Session, tenant_no: str) -> str:
  1215. today_str = datetime.now().strftime("%Y%m%d")
  1216. prefix = f"LSSC{today_str}"
  1217. last = (
  1218. db.query(BizProductionWorkOrder)
  1219. .filter(
  1220. BizProductionWorkOrder.tenant_no == tenant_no,
  1221. BizProductionWorkOrder.work_order_no.like(f"{prefix}%"),
  1222. BizProductionWorkOrder.del_flag == 0,
  1223. )
  1224. .order_by(BizProductionWorkOrder.work_order_no.desc())
  1225. .first()
  1226. )
  1227. seq = 1
  1228. if last:
  1229. seq = int(last.work_order_no[-4:]) + 1
  1230. return f"{prefix}{seq:04d}"
  1231. def _temp_wo_to_dict(wo: BizProductionWorkOrder) -> Dict[str, Any]:
  1232. return {
  1233. "id": wo.id,
  1234. "workOrderNo": wo.work_order_no,
  1235. "workOrderStatus": wo.work_order_status,
  1236. "workOrderStatusLabel": _WORK_ORDER_STATUS_LABELS.get(wo.work_order_status, wo.work_order_status),
  1237. "configuredTime": _fmt_dt(getattr(wo, "configured_time", None)),
  1238. "configuredBy": getattr(wo, "configured_by", None) or "",
  1239. "remark": wo.remark or "",
  1240. "createTime": _fmt_dt(wo.create_time),
  1241. "updateTime": _fmt_dt(wo.update_time),
  1242. }
  1243. def create_temp_production_work_order(
  1244. db: Session,
  1245. *,
  1246. tenant_no: str,
  1247. user_id: Optional[str] = None,
  1248. create_dept_id: str = "",
  1249. remark: str = "",
  1250. ) -> BizProductionWorkOrder:
  1251. wo = BizProductionWorkOrder(
  1252. id=generate_uuid(),
  1253. work_order_no=_generate_temp_work_order_no(db, tenant_no),
  1254. work_order_type="temporary",
  1255. # 临时工单不走样本接收;显式置空,避免列默认 cell_storage 误导完成工单门禁
  1256. reception_source=None,
  1257. work_order_status="dpz",
  1258. tenant_no=tenant_no,
  1259. remark=remark,
  1260. create_by=user_id,
  1261. create_dept_id=create_dept_id,
  1262. update_by=user_id,
  1263. create_time=datetime.now(),
  1264. update_time=datetime.now(),
  1265. del_flag=0,
  1266. )
  1267. db.add(wo)
  1268. db.flush()
  1269. return wo
  1270. def list_temp_production_work_orders(
  1271. db: Session,
  1272. *,
  1273. page: int = 1,
  1274. page_size: int = 10,
  1275. keyword: Optional[str] = None,
  1276. work_order_status: Optional[str] = None,
  1277. tenant_no: Optional[str] = None,
  1278. user_info=None,
  1279. ) -> Tuple[List[Dict[str, Any]], int]:
  1280. q = db.query(BizProductionWorkOrder).filter(
  1281. BizProductionWorkOrder.del_flag == 0,
  1282. BizProductionWorkOrder.work_order_type == "temporary",
  1283. )
  1284. if tenant_no:
  1285. q = q.filter(BizProductionWorkOrder.tenant_no == tenant_no)
  1286. kw = (keyword or "").strip()
  1287. if kw:
  1288. like = f"%{kw}%"
  1289. q = q.filter(BizProductionWorkOrder.remark.like(like))
  1290. st_raw = (work_order_status or "").strip()
  1291. if st_raw:
  1292. statuses = [s.strip() for s in st_raw.split(",") if s.strip()]
  1293. if statuses:
  1294. q = q.filter(BizProductionWorkOrder.work_order_status.in_(statuses))
  1295. if user_info:
  1296. from app.core.data_scope import apply_data_scope
  1297. q = apply_data_scope(q, BizProductionWorkOrder, user_info, db, tenant_no or "")
  1298. total = q.order_by(None).with_entities(func.count(BizProductionWorkOrder.id)).scalar() or 0
  1299. page = max(1, page)
  1300. page_size = min(max(1, page_size), 100)
  1301. rows = (
  1302. q.order_by(BizProductionWorkOrder.create_time.desc())
  1303. .offset((page - 1) * page_size)
  1304. .limit(page_size)
  1305. .all()
  1306. )
  1307. items = [_temp_wo_to_dict(x) for x in rows]
  1308. return items, int(total)
  1309. def get_temp_production_work_order(
  1310. db: Session, row_id: str, tenant_no: str
  1311. ) -> Optional[BizProductionWorkOrder]:
  1312. return (
  1313. db.query(BizProductionWorkOrder)
  1314. .filter(
  1315. BizProductionWorkOrder.id == row_id,
  1316. BizProductionWorkOrder.tenant_no == tenant_no,
  1317. BizProductionWorkOrder.del_flag == 0,
  1318. BizProductionWorkOrder.work_order_type == "temporary",
  1319. )
  1320. .first()
  1321. )
  1322. def get_temp_production_work_order_detail(
  1323. db: Session, row_id: str, tenant_no: str
  1324. ) -> Optional[Dict[str, Any]]:
  1325. wo = get_temp_production_work_order(db, row_id, tenant_no)
  1326. if not wo:
  1327. return None
  1328. result = _temp_wo_to_dict(wo)
  1329. tasks = (
  1330. db.query(BizProductionTask)
  1331. .filter(
  1332. BizProductionTask.work_order_id == row_id,
  1333. BizProductionTask.order_source == "temporary",
  1334. BizProductionTask.tenant_no == tenant_no,
  1335. BizProductionTask.del_flag == 0,
  1336. )
  1337. .order_by(BizProductionTask.order_num, BizProductionTask.create_time)
  1338. .all()
  1339. )
  1340. result["tasks"] = [production_task_svc._task_to_dict(t) for t in tasks]
  1341. return result
  1342. def configure_temp_production_work_order(
  1343. db: Session,
  1344. work_order_id: str,
  1345. *,
  1346. task_name: str,
  1347. task_type: str,
  1348. route_id: Optional[str],
  1349. description: str,
  1350. remark: str,
  1351. assignee_user_id: str,
  1352. tenant_no: str,
  1353. user_id: Optional[str] = None,
  1354. create_dept_id: str = "",
  1355. ) -> BizProductionWorkOrder:
  1356. wo = get_temp_production_work_order(db, work_order_id, tenant_no)
  1357. if not wo:
  1358. raise ValueError("工单不存在")
  1359. if wo.work_order_status != "dpz":
  1360. raise ValueError("工单已配置,不能重复配置")
  1361. production_task_svc.create_production_task(
  1362. db,
  1363. tenant_no=tenant_no,
  1364. user_id=user_id,
  1365. create_dept_id=create_dept_id,
  1366. data={
  1367. "work_order_id": work_order_id,
  1368. "task_name": task_name,
  1369. "task_type": task_type,
  1370. "route_id": route_id,
  1371. "description": description,
  1372. "remark": remark,
  1373. "assignee_user_id": assignee_user_id,
  1374. },
  1375. order_source="temporary",
  1376. )
  1377. now = datetime.now()
  1378. wo.work_order_status = "jxz"
  1379. wo.configured_time = now
  1380. wo.configured_by = user_id
  1381. wo.update_by = user_id
  1382. wo.update_time = now
  1383. db.add(wo)
  1384. db.flush()
  1385. return wo
  1386. def sync_temp_production_work_order_status(
  1387. db: Session,
  1388. work_order_id: str,
  1389. tenant_no: str,
  1390. user_id: Optional[str] = None,
  1391. ) -> None:
  1392. if not (work_order_id or "").strip():
  1393. return
  1394. wo = get_temp_production_work_order(db, work_order_id.strip(), tenant_no)
  1395. if not wo:
  1396. return
  1397. tasks = (
  1398. db.query(BizProductionTask.task_status)
  1399. .filter(
  1400. BizProductionTask.work_order_id == work_order_id.strip(),
  1401. BizProductionTask.order_source == "temporary",
  1402. BizProductionTask.tenant_no == tenant_no,
  1403. BizProductionTask.del_flag == 0,
  1404. )
  1405. .all()
  1406. )
  1407. if not tasks:
  1408. new_status = "dpz"
  1409. elif all(t.task_status == "ywc" for t in tasks):
  1410. new_status = "ywc"
  1411. else:
  1412. new_status = "jxz"
  1413. if wo.work_order_status == new_status:
  1414. return
  1415. now = datetime.now()
  1416. wo.work_order_status = new_status
  1417. wo.update_by = user_id
  1418. wo.update_time = now
  1419. db.add(wo)
  1420. def delete_temp_production_work_order(
  1421. db: Session, work_order_id: str, tenant_no: str, user_id: Optional[str]
  1422. ) -> None:
  1423. wo = get_temp_production_work_order(db, work_order_id, tenant_no)
  1424. if not wo:
  1425. raise ValueError("工单不存在")
  1426. if wo.work_order_status == "ywc":
  1427. raise ValueError("已完成的工单不能删除")
  1428. now = datetime.now()
  1429. tasks = db.query(BizProductionTask).filter(
  1430. and_(
  1431. BizProductionTask.work_order_id == work_order_id,
  1432. BizProductionTask.order_source == "temporary",
  1433. BizProductionTask.del_flag == 0,
  1434. )
  1435. ).all()
  1436. for task in tasks:
  1437. steps = db.query(BizProductionTaskStep).filter(
  1438. and_(BizProductionTaskStep.task_id == task.id, BizProductionTaskStep.del_flag == 0)
  1439. ).all()
  1440. for step in steps:
  1441. _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step.id, now)
  1442. _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step.id, now)
  1443. _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step.id, now)
  1444. _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step.id, now)
  1445. step.del_flag = 1
  1446. step.update_by = user_id
  1447. step.update_time = now
  1448. _soft_delete_sub(db, BizProductionTaskInput, "task_id", task.id, now)
  1449. _soft_delete_sub(db, BizProductionTaskOutput, "task_id", task.id, now)
  1450. task.del_flag = 1
  1451. task.update_by = user_id
  1452. task.update_time = now
  1453. wo.del_flag = 1
  1454. wo.update_by = user_id
  1455. wo.update_time = now
  1456. db.commit()