production_work_order.py 53 KB

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