outbound_release_material.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. """出库放行审核 · 出库物料准备(台账 CRUD)"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional
  6. from fastapi import HTTPException
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.crud import cell_product as cell_product_svc
  10. from app.models.models import (
  11. BizCellProduct,
  12. BizOutboundReleaseCell,
  13. BizOutboundReleaseMaterial,
  14. BizOutboundReleasePaper,
  15. BizOutboundReleaseQcMaterial,
  16. BizPilotInfusionSchedule,
  17. )
  18. from app.models.material import BizMaterial
  19. def _fmt_dt(t: Optional[datetime]) -> str:
  20. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  21. def _cell_row_to_dict(row: BizOutboundReleaseCell) -> Dict[str, Any]:
  22. return {
  23. "id": row.id,
  24. "materialId": row.material_id or "",
  25. "infusionScheduleId": row.infusion_schedule_id or "",
  26. "cellProductId": row.cell_product_id or "",
  27. "productCode": row.product_code or "",
  28. "productName": row.product_name or "",
  29. "productTypeName": row.product_type_name or "",
  30. "specName": row.spec_name or "",
  31. "passage": row.passage or "",
  32. "batchNo": row.batch_no or "",
  33. "storageSlotName": row.storage_slot_name or "",
  34. "productStatus": row.product_status or "",
  35. "remark": row.remark or "",
  36. "orderNum": row.order_num if row.order_num is not None else 0,
  37. }
  38. def _enrich_cell_dicts_with_live_status(
  39. db: Session,
  40. tenant_no: str,
  41. cells: List[Dict[str, Any]],
  42. ) -> List[Dict[str, Any]]:
  43. """出库细胞状态以细胞产品库为准(台账 product_status 仅为保存时快照)。"""
  44. cp_ids = {
  45. (c.get("cellProductId") or c.get("cell_product_id") or "").strip()
  46. for c in cells
  47. }
  48. cp_ids.discard("")
  49. if not cp_ids:
  50. return cells
  51. live_status: Dict[str, str] = {}
  52. for product in (
  53. db.query(BizCellProduct)
  54. .filter(
  55. BizCellProduct.id.in_(cp_ids),
  56. BizCellProduct.tenant_no == tenant_no,
  57. BizCellProduct.del_flag == 0,
  58. )
  59. .all()
  60. ):
  61. live_status[product.id] = (product.product_status or "").strip()
  62. enriched: List[Dict[str, Any]] = []
  63. for cell in cells:
  64. cp_id = (cell.get("cellProductId") or cell.get("cell_product_id") or "").strip()
  65. status = live_status.get(cp_id)
  66. if status:
  67. enriched.append({**cell, "productStatus": status})
  68. else:
  69. enriched.append(cell)
  70. return enriched
  71. def _qc_material_row_to_dict(row: BizOutboundReleaseQcMaterial) -> Dict[str, Any]:
  72. return {
  73. "id": row.id,
  74. "materialId": row.material_id or "",
  75. "infusionScheduleId": row.infusion_schedule_id or "",
  76. "qcMaterialId": row.qc_material_id or "",
  77. "qty": float(row.qty) if row.qty is not None else 0,
  78. "materialCode": row.material_code or "",
  79. "materialName": row.material_name or "",
  80. "specModel": row.spec_model or "",
  81. "unit": row.unit or "",
  82. "remark": row.remark or "",
  83. "orderNum": row.order_num if row.order_num is not None else 0,
  84. }
  85. def _paper_row_to_dict(row: BizOutboundReleasePaper) -> Dict[str, Any]:
  86. return {
  87. "id": row.id,
  88. "materialId": row.material_id or "",
  89. "infusionScheduleId": row.infusion_schedule_id or "",
  90. "paperName": row.paper_name or "",
  91. "copies": row.copies if row.copies is not None else 1,
  92. "attachmentUrl": row.attachment_url or "",
  93. "remark": row.remark or "",
  94. "orderNum": row.order_num if row.order_num is not None else 0,
  95. }
  96. def _legacy_paper_rows_from_attachments(paper_attachments: Optional[str]) -> List[Dict[str, Any]]:
  97. urls = (paper_attachments or "").strip()
  98. if not urls:
  99. return []
  100. rows: List[Dict[str, Any]] = []
  101. for idx, file_url in enumerate([u.strip() for u in urls.split(",") if u.strip()]):
  102. file_name = file_url.split("/").pop() or "附件"
  103. rows.append(
  104. {
  105. "id": "",
  106. "materialId": "",
  107. "infusionScheduleId": "",
  108. "paperName": file_name,
  109. "copies": 1,
  110. "attachmentUrl": file_url,
  111. "remark": "",
  112. "orderNum": idx + 1,
  113. }
  114. )
  115. return rows
  116. def _material_header_to_dict(
  117. header: Optional[BizOutboundReleaseMaterial],
  118. cells: List[Dict[str, Any]],
  119. qc_materials: List[Dict[str, Any]],
  120. paper_materials: List[Dict[str, Any]],
  121. ) -> Dict[str, Any]:
  122. if not header:
  123. return {
  124. "id": "",
  125. "infusionScheduleId": "",
  126. "paperAttachments": "",
  127. "paperMaterials": paper_materials,
  128. "remark": "",
  129. "cells": cells,
  130. "qcMaterials": qc_materials,
  131. "createTime": "",
  132. "updateTime": "",
  133. }
  134. return {
  135. "id": header.id,
  136. "infusionScheduleId": header.infusion_schedule_id or "",
  137. "paperAttachments": header.paper_attachments or "",
  138. "paperMaterials": paper_materials,
  139. "remark": header.remark or "",
  140. "cells": cells,
  141. "qcMaterials": qc_materials,
  142. "createTime": _fmt_dt(header.create_time),
  143. "updateTime": _fmt_dt(header.update_time),
  144. }
  145. def _soft_delete_outbound_cells_not_in(
  146. db: Session,
  147. schedule_id: str,
  148. tenant_no: str,
  149. keep_cell_product_ids: set,
  150. user_id: str,
  151. now: datetime,
  152. ) -> None:
  153. q = db.query(BizOutboundReleaseCell).filter(
  154. BizOutboundReleaseCell.infusion_schedule_id == schedule_id,
  155. BizOutboundReleaseCell.tenant_no == tenant_no,
  156. BizOutboundReleaseCell.del_flag == 0,
  157. )
  158. if keep_cell_product_ids:
  159. q = q.filter(BizOutboundReleaseCell.cell_product_id.notin_(keep_cell_product_ids))
  160. q.update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  161. def _soft_delete_outbound_qc_materials_not_in(
  162. db: Session,
  163. schedule_id: str,
  164. tenant_no: str,
  165. keep_qc_material_ids: set,
  166. user_id: str,
  167. now: datetime,
  168. ) -> None:
  169. q = db.query(BizOutboundReleaseQcMaterial).filter(
  170. BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id,
  171. BizOutboundReleaseQcMaterial.tenant_no == tenant_no,
  172. BizOutboundReleaseQcMaterial.del_flag == 0,
  173. )
  174. if keep_qc_material_ids:
  175. q = q.filter(BizOutboundReleaseQcMaterial.qc_material_id.notin_(keep_qc_material_ids))
  176. q.update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  177. def _find_outbound_cell_row(
  178. db: Session, schedule_id: str, tenant_no: str, cell_product_id: str
  179. ) -> Optional[BizOutboundReleaseCell]:
  180. """按 schedule+cell 查找台账行(优先未删除)。"""
  181. return (
  182. db.query(BizOutboundReleaseCell)
  183. .filter(
  184. BizOutboundReleaseCell.infusion_schedule_id == schedule_id,
  185. BizOutboundReleaseCell.cell_product_id == cell_product_id,
  186. BizOutboundReleaseCell.tenant_no == tenant_no,
  187. )
  188. .order_by(BizOutboundReleaseCell.del_flag.asc())
  189. .first()
  190. )
  191. def _find_outbound_qc_material_row(
  192. db: Session, schedule_id: str, tenant_no: str, qc_material_id: str
  193. ) -> Optional[BizOutboundReleaseQcMaterial]:
  194. return (
  195. db.query(BizOutboundReleaseQcMaterial)
  196. .filter(
  197. BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id,
  198. BizOutboundReleaseQcMaterial.qc_material_id == qc_material_id,
  199. BizOutboundReleaseQcMaterial.tenant_no == tenant_no,
  200. )
  201. .order_by(BizOutboundReleaseQcMaterial.del_flag.asc())
  202. .first()
  203. )
  204. def _get_schedule(db: Session, schedule_id: str, tenant_no: str) -> Optional[BizPilotInfusionSchedule]:
  205. sid = (schedule_id or "").strip()
  206. if not sid:
  207. return None
  208. return (
  209. db.query(BizPilotInfusionSchedule)
  210. .filter(
  211. BizPilotInfusionSchedule.id == sid,
  212. BizPilotInfusionSchedule.tenant_no == tenant_no,
  213. BizPilotInfusionSchedule.del_flag == 0,
  214. )
  215. .first()
  216. )
  217. def get_outbound_material(
  218. db: Session,
  219. schedule_id: str,
  220. tenant_no: str,
  221. ) -> Dict[str, Any]:
  222. """获取输注计划的出库物料准备(台账)。"""
  223. schedule = _get_schedule(db, schedule_id, tenant_no)
  224. if not schedule:
  225. raise HTTPException(status_code=404, detail="输注计划不存在")
  226. header = (
  227. db.query(BizOutboundReleaseMaterial)
  228. .filter(
  229. BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id,
  230. BizOutboundReleaseMaterial.tenant_no == tenant_no,
  231. BizOutboundReleaseMaterial.del_flag == 0,
  232. )
  233. .first()
  234. )
  235. cell_rows = (
  236. db.query(BizOutboundReleaseCell)
  237. .filter(
  238. BizOutboundReleaseCell.infusion_schedule_id == schedule_id,
  239. BizOutboundReleaseCell.tenant_no == tenant_no,
  240. BizOutboundReleaseCell.del_flag == 0,
  241. )
  242. .order_by(
  243. BizOutboundReleaseCell.order_num.asc(),
  244. BizOutboundReleaseCell.create_time.asc(),
  245. )
  246. .all()
  247. )
  248. qc_rows = (
  249. db.query(BizOutboundReleaseQcMaterial)
  250. .filter(
  251. BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id,
  252. BizOutboundReleaseQcMaterial.tenant_no == tenant_no,
  253. BizOutboundReleaseQcMaterial.del_flag == 0,
  254. )
  255. .order_by(
  256. BizOutboundReleaseQcMaterial.order_num.asc(),
  257. BizOutboundReleaseQcMaterial.create_time.asc(),
  258. )
  259. .all()
  260. )
  261. paper_rows = (
  262. db.query(BizOutboundReleasePaper)
  263. .filter(
  264. BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
  265. BizOutboundReleasePaper.tenant_no == tenant_no,
  266. BizOutboundReleasePaper.del_flag == 0,
  267. )
  268. .order_by(
  269. BizOutboundReleasePaper.order_num.asc(),
  270. BizOutboundReleasePaper.create_time.asc(),
  271. )
  272. .all()
  273. )
  274. paper_materials = [_paper_row_to_dict(r) for r in paper_rows]
  275. if not paper_materials and header and (header.paper_attachments or "").strip():
  276. paper_materials = _legacy_paper_rows_from_attachments(header.paper_attachments)
  277. cell_dicts = _enrich_cell_dicts_with_live_status(
  278. db,
  279. tenant_no,
  280. [_cell_row_to_dict(r) for r in cell_rows],
  281. )
  282. return _material_header_to_dict(
  283. header,
  284. cell_dicts,
  285. [_qc_material_row_to_dict(r) for r in qc_rows],
  286. paper_materials,
  287. )
  288. def save_outbound_material(
  289. db: Session,
  290. schedule_id: str,
  291. *,
  292. paper_attachments: Optional[str],
  293. paper_materials: List[Dict[str, Any]],
  294. remark: Optional[str],
  295. cells: List[Dict[str, Any]],
  296. qc_materials: List[Dict[str, Any]],
  297. tenant_no: str,
  298. user_id: str,
  299. create_dept_id: str = "",
  300. ) -> Dict[str, Any]:
  301. """全量保存出库物料准备(台账,不联动库存)。"""
  302. schedule = _get_schedule(db, schedule_id, tenant_no)
  303. if not schedule:
  304. raise HTTPException(status_code=404, detail="输注计划不存在")
  305. cell_ids = [
  306. (it.get("cellProductId") or it.get("cell_product_id") or "").strip()
  307. for it in (cells or [])
  308. ]
  309. qc_ids = [
  310. (it.get("materialId") or it.get("material_id") or "").strip()
  311. for it in (qc_materials or [])
  312. ]
  313. if len([x for x in cell_ids if x]) != len(set(x for x in cell_ids if x)):
  314. raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同细胞产品")
  315. if len([x for x in qc_ids if x]) != len(set(x for x in qc_ids if x)):
  316. raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同物料")
  317. now = datetime.now()
  318. header = (
  319. db.query(BizOutboundReleaseMaterial)
  320. .filter(
  321. BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id,
  322. BizOutboundReleaseMaterial.tenant_no == tenant_no,
  323. BizOutboundReleaseMaterial.del_flag == 0,
  324. )
  325. .first()
  326. )
  327. if not header:
  328. header = BizOutboundReleaseMaterial(
  329. id=generate_uuid(),
  330. infusion_schedule_id=schedule_id,
  331. tenant_no=tenant_no,
  332. order_num=0,
  333. create_by=user_id,
  334. create_dept_id=create_dept_id or "",
  335. create_time=now,
  336. del_flag=0,
  337. )
  338. db.add(header)
  339. db.flush()
  340. else:
  341. header.update_by = user_id
  342. header.update_time = now
  343. header.paper_attachments = None
  344. header.remark = (remark or "").strip() or None
  345. db.add(header)
  346. incoming_cell_ids = {x for x in cell_ids if x}
  347. incoming_qc_ids = {x for x in qc_ids if x}
  348. _soft_delete_outbound_cells_not_in(
  349. db, schedule_id, tenant_no, incoming_cell_ids, user_id, now
  350. )
  351. _soft_delete_outbound_qc_materials_not_in(
  352. db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
  353. )
  354. db.query(BizOutboundReleasePaper).filter(
  355. BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
  356. BizOutboundReleasePaper.tenant_no == tenant_no,
  357. BizOutboundReleasePaper.del_flag == 0,
  358. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  359. db.flush()
  360. for idx, item in enumerate(cells or []):
  361. cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
  362. if not cp_id:
  363. raise HTTPException(status_code=400, detail="细胞产品ID不能为空")
  364. product = (
  365. db.query(BizCellProduct)
  366. .filter(
  367. BizCellProduct.id == cp_id,
  368. BizCellProduct.tenant_no == tenant_no,
  369. BizCellProduct.del_flag == 0,
  370. )
  371. .first()
  372. )
  373. if not product:
  374. raise HTTPException(status_code=400, detail=f"细胞产品不存在或已删除(ID: {cp_id})")
  375. enriched = cell_product_svc._enrich_product_dicts(
  376. db,
  377. tenant_no,
  378. [cell_product_svc._row_to_dict(product)],
  379. )[0]
  380. cell_row = _find_outbound_cell_row(db, schedule_id, tenant_no, cp_id)
  381. if cell_row:
  382. cell_row.material_id = header.id
  383. cell_row.product_code = enriched.get("productCode") or ""
  384. cell_row.product_name = enriched.get("productName") or ""
  385. cell_row.product_type_name = enriched.get("productTypeName") or ""
  386. cell_row.spec_name = enriched.get("specName") or ""
  387. cell_row.passage = enriched.get("passage") or ""
  388. cell_row.batch_no = enriched.get("batchNo") or ""
  389. cell_row.storage_slot_name = enriched.get("storageSlotName") or ""
  390. cell_row.product_status = enriched.get("productStatus") or ""
  391. cell_row.order_num = idx + 1
  392. cell_row.remark = (item.get("remark") or "").strip() or None
  393. cell_row.del_flag = 0
  394. cell_row.update_by = user_id
  395. cell_row.update_time = now
  396. db.add(cell_row)
  397. else:
  398. db.add(
  399. BizOutboundReleaseCell(
  400. id=generate_uuid(),
  401. material_id=header.id,
  402. infusion_schedule_id=schedule_id,
  403. cell_product_id=cp_id,
  404. product_code=enriched.get("productCode") or "",
  405. product_name=enriched.get("productName") or "",
  406. product_type_name=enriched.get("productTypeName") or "",
  407. spec_name=enriched.get("specName") or "",
  408. passage=enriched.get("passage") or "",
  409. batch_no=enriched.get("batchNo") or "",
  410. storage_slot_name=enriched.get("storageSlotName") or "",
  411. product_status=enriched.get("productStatus") or "",
  412. tenant_no=tenant_no,
  413. order_num=idx + 1,
  414. remark=(item.get("remark") or "").strip() or None,
  415. create_by=user_id,
  416. create_dept_id=create_dept_id or "",
  417. create_time=now,
  418. update_by=user_id,
  419. update_time=now,
  420. del_flag=0,
  421. )
  422. )
  423. for idx, item in enumerate(qc_materials or []):
  424. mid = (item.get("materialId") or item.get("material_id") or "").strip()
  425. qty_raw = item.get("qty")
  426. if not mid:
  427. raise HTTPException(status_code=400, detail="市场物料ID不能为空")
  428. try:
  429. qty_dec = Decimal(str(qty_raw))
  430. except Exception:
  431. raise HTTPException(status_code=400, detail="物料用量格式无效")
  432. if qty_dec <= 0:
  433. raise HTTPException(status_code=400, detail="物料用量必须大于0")
  434. material = (
  435. db.query(BizMaterial)
  436. .filter(
  437. BizMaterial.id == mid,
  438. BizMaterial.tenant_no == tenant_no,
  439. BizMaterial.del_flag == 0,
  440. )
  441. .first()
  442. )
  443. if not material:
  444. raise HTTPException(status_code=400, detail=f"市场物料不存在或已删除(ID: {mid})")
  445. qc_row = _find_outbound_qc_material_row(db, schedule_id, tenant_no, mid)
  446. if qc_row:
  447. qc_row.material_id = header.id
  448. qc_row.qty = qty_dec
  449. qc_row.material_code = material.material_code or ""
  450. qc_row.material_name = material.material_name or ""
  451. qc_row.spec_model = material.spec_model or ""
  452. qc_row.unit = material.unit or ""
  453. qc_row.order_num = idx + 1
  454. qc_row.remark = (item.get("remark") or "").strip() or None
  455. qc_row.del_flag = 0
  456. qc_row.update_by = user_id
  457. qc_row.update_time = now
  458. db.add(qc_row)
  459. else:
  460. db.add(
  461. BizOutboundReleaseQcMaterial(
  462. id=generate_uuid(),
  463. material_id=header.id,
  464. infusion_schedule_id=schedule_id,
  465. qc_material_id=mid,
  466. qty=qty_dec,
  467. material_code=material.material_code or "",
  468. material_name=material.material_name or "",
  469. spec_model=material.spec_model or "",
  470. unit=material.unit or "",
  471. tenant_no=tenant_no,
  472. order_num=idx + 1,
  473. remark=(item.get("remark") or "").strip() or None,
  474. create_by=user_id,
  475. create_dept_id=create_dept_id or "",
  476. create_time=now,
  477. update_by=user_id,
  478. update_time=now,
  479. del_flag=0,
  480. )
  481. )
  482. for idx, item in enumerate(paper_materials or []):
  483. paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
  484. attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
  485. copies_raw = item.get("copies")
  486. if not paper_name:
  487. raise HTTPException(status_code=400, detail="纸质材料名称不能为空")
  488. if not attachment_url:
  489. raise HTTPException(status_code=400, detail="纸质材料附件不能为空")
  490. try:
  491. copies = int(copies_raw)
  492. except Exception:
  493. raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
  494. if copies <= 0:
  495. raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
  496. db.add(
  497. BizOutboundReleasePaper(
  498. id=generate_uuid(),
  499. material_id=header.id,
  500. infusion_schedule_id=schedule_id,
  501. paper_name=paper_name,
  502. copies=copies,
  503. attachment_url=attachment_url,
  504. tenant_no=tenant_no,
  505. order_num=idx + 1,
  506. remark=(item.get("remark") or "").strip() or None,
  507. create_by=user_id,
  508. create_dept_id=create_dept_id or "",
  509. create_time=now,
  510. update_by=user_id,
  511. update_time=now,
  512. del_flag=0,
  513. )
  514. )
  515. db.flush()
  516. return get_outbound_material(db, schedule_id, tenant_no)