production_material_prep.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306
  1. """配置试剂配制单 CRUD"""
  2. from __future__ import annotations
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import and_, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid, parse_datetime
  9. from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
  10. from app.crud.production_material_stock_batch import (
  11. _create_batch_and_usage,
  12. _get_material_for_update,
  13. _parse_date,
  14. _parse_expire_date,
  15. _require_str,
  16. _stock_out_batch,
  17. stock_in_no_exists,
  18. _optional_str,
  19. )
  20. from app.crud.production_material_usage import list_available_requisitions_for_prep
  21. from app.models.models import SysUser
  22. from app.models.production_material import (
  23. BizProductionMaterial,
  24. BizProductionMaterialPrep,
  25. BizProductionMaterialPrepLine,
  26. BizProductionMaterialStockBatch,
  27. BizProductionMaterialUsage,
  28. )
  29. PREP_STATUSES = ("draft", "completed", "cancelled")
  30. def _status_label(status: str) -> str:
  31. if status == "completed":
  32. return "已完成"
  33. if status == "cancelled":
  34. return "已作废"
  35. return "草稿"
  36. def _fmt_date(d: Optional[date]) -> str:
  37. return d.strftime("%Y-%m-%d") if d else ""
  38. def _batch_map_for_prep_lines(
  39. db: Session, batch_ids: List[str]
  40. ) -> Dict[str, BizProductionMaterialStockBatch]:
  41. ids = [x for x in batch_ids if x]
  42. if not ids:
  43. return {}
  44. rows = (
  45. db.query(BizProductionMaterialStockBatch)
  46. .filter(BizProductionMaterialStockBatch.id.in_(ids), BizProductionMaterialStockBatch.del_flag == 0)
  47. .all()
  48. )
  49. return {r.id: r for r in rows}
  50. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  51. ids = list({x for x in user_ids if x})
  52. if not ids:
  53. return {}
  54. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  55. return {u.id: (u.nick_name or "").strip() for u in rows}
  56. def _generate_prep_no(db: Session, tenant_no: str) -> str:
  57. day = datetime.now().strftime("%Y%m%d")
  58. like = f"PZ-{day}-%"
  59. cnt = (
  60. db.query(func.count(BizProductionMaterialPrep.id))
  61. .filter(
  62. BizProductionMaterialPrep.tenant_no == tenant_no,
  63. BizProductionMaterialPrep.prep_no.like(like),
  64. )
  65. .scalar()
  66. )
  67. return f"PZ-{day}-{(cnt or 0) + 1:03d}"
  68. def _generate_stock_in_no(db: Session, tenant_no: str) -> str:
  69. day = datetime.now().strftime("%Y%m%d")
  70. like = f"RK{day}%"
  71. cnt = (
  72. db.query(func.count(BizProductionMaterialPrep.id))
  73. .filter(
  74. BizProductionMaterialPrep.tenant_no == tenant_no,
  75. BizProductionMaterialPrep.stock_in_no.like(like),
  76. BizProductionMaterialPrep.del_flag == 0,
  77. )
  78. .scalar()
  79. )
  80. sno = f"RK{day}{(cnt or 0) + 1:04d}"
  81. if stock_in_no_exists(db, sno, tenant_no):
  82. sno = f"RK{day}{(cnt or 0) + 1:04d}P"
  83. return sno
  84. def _line_to_dict(row: BizProductionMaterialPrepLine) -> Dict[str, Any]:
  85. return {
  86. "id": row.id,
  87. "prepId": row.prep_id,
  88. "componentMaterialId": row.component_material_id,
  89. "componentMaterialType": row.component_material_type or "",
  90. "componentMaterialCode": row.component_material_code or "",
  91. "componentMaterialName": row.component_material_name or "",
  92. "requisitionUsageId": (row.requisition_usage_id or "").strip(),
  93. "actualQty": _dec(row.actual_qty),
  94. "usageUnit": row.usage_unit or "",
  95. "orderNum": row.order_num if row.order_num is not None else 0,
  96. "remark": row.remark or "",
  97. }
  98. def _header_to_dict(
  99. row: BizProductionMaterialPrep,
  100. *,
  101. create_by_name: str = "",
  102. line_count: int = 0,
  103. ) -> Dict[str, Any]:
  104. return {
  105. "id": row.id,
  106. "prepNo": row.prep_no or "",
  107. "configuredMaterialId": row.configured_material_id,
  108. "configuredMaterialCode": row.configured_material_code or "",
  109. "configuredMaterialName": row.configured_material_name or "",
  110. "prepTime": _fmt_dt(row.prep_time),
  111. "outputQty": _dec(row.output_qty),
  112. "outputUnit": row.output_unit or "",
  113. "batchNo": row.batch_no or "",
  114. "storageLocation": row.storage_location or "",
  115. "expireDate": _fmt_date(row.expire_date),
  116. "stockInNo": row.stock_in_no or "",
  117. "outputBatchId": row.output_batch_id or "",
  118. "status": row.status or "draft",
  119. "statusLabel": _status_label(row.status or "draft"),
  120. "lineCount": line_count,
  121. "remark": row.remark or "",
  122. "createBy": row.create_by or "",
  123. "createByName": create_by_name or row.create_by or "",
  124. "createTime": _fmt_dt(row.create_time),
  125. "updateTime": _fmt_dt(row.update_time),
  126. }
  127. def _get_prep_row(db: Session, prep_id: str, tenant_no: str) -> BizProductionMaterialPrep:
  128. row = (
  129. db.query(BizProductionMaterialPrep)
  130. .filter(
  131. BizProductionMaterialPrep.id == prep_id,
  132. BizProductionMaterialPrep.tenant_no == tenant_no,
  133. BizProductionMaterialPrep.del_flag == 0,
  134. )
  135. .first()
  136. )
  137. if not row:
  138. raise ValueError("配制单不存在")
  139. return row
  140. def _require_draft(row: BizProductionMaterialPrep) -> None:
  141. if (row.status or "").strip() != "draft":
  142. raise ValueError("仅草稿状态的配制单可编辑")
  143. def _validate_requisition(
  144. db: Session,
  145. req_id: str,
  146. *,
  147. tenant_no: str,
  148. component_material_id: str,
  149. seen: set,
  150. ) -> BizProductionMaterialUsage:
  151. if not req_id:
  152. raise ValueError("请选择子物料领用记录")
  153. if req_id in seen:
  154. raise ValueError("配制明细中存在重复的领用记录")
  155. seen.add(req_id)
  156. req = (
  157. db.query(BizProductionMaterialUsage)
  158. .filter(
  159. BizProductionMaterialUsage.id == req_id,
  160. BizProductionMaterialUsage.tenant_no == tenant_no,
  161. BizProductionMaterialUsage.del_flag == 0,
  162. BizProductionMaterialUsage.source_type == "manual_out",
  163. BizProductionMaterialUsage.change_type == "use",
  164. )
  165. .first()
  166. )
  167. if not req:
  168. raise ValueError("领用记录不存在或已失效")
  169. if (req.material_id or "").strip() != component_material_id:
  170. raise ValueError("领用记录与子物料不匹配")
  171. return req
  172. def _parse_prep_lines(
  173. db: Session,
  174. lines: List[Dict[str, Any]],
  175. *,
  176. tenant_no: str,
  177. ) -> List[Dict[str, Any]]:
  178. if not lines:
  179. raise ValueError("请至少添加一条配制明细")
  180. parsed: List[Dict[str, Any]] = []
  181. seen_req: set = set()
  182. for idx, it in enumerate(lines):
  183. comp_id = (
  184. it.get("componentMaterialId") or it.get("component_material_id") or ""
  185. ).strip()
  186. if not comp_id:
  187. raise ValueError("请选择子物料")
  188. comp_type = (
  189. it.get("componentMaterialType") or it.get("component_material_type") or ""
  190. ).strip().lower()
  191. if comp_type not in ("reagent", "consumable"):
  192. raise ValueError("子物料类型无效")
  193. comp = (
  194. db.query(BizProductionMaterial)
  195. .filter(
  196. BizProductionMaterial.id == comp_id,
  197. BizProductionMaterial.tenant_no == tenant_no,
  198. BizProductionMaterial.del_flag == 0,
  199. )
  200. .first()
  201. )
  202. if not comp:
  203. raise ValueError("子物料不存在")
  204. req_id = (
  205. it.get("requisitionUsageId") or it.get("requisition_usage_id") or ""
  206. ).strip()
  207. req = _validate_requisition(
  208. db, req_id, tenant_no=tenant_no, component_material_id=comp_id, seen=seen_req
  209. )
  210. qty_raw = it.get("actualQty") if it.get("actualQty") is not None else it.get("actual_qty")
  211. if qty_raw is None or Decimal(str(qty_raw)) <= 0:
  212. raise ValueError("实际用量必须大于0")
  213. actual_qty = Decimal(str(qty_raw))
  214. usage_unit = (
  215. it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
  216. ).strip()[:32]
  217. parsed.append(
  218. {
  219. "idx": idx,
  220. "component": comp,
  221. "component_type": comp_type,
  222. "requisition": req,
  223. "actual_qty": actual_qty,
  224. "usage_unit": usage_unit,
  225. "remark": (it.get("remark") or "").strip()[:512] or None,
  226. }
  227. )
  228. return parsed
  229. def list_preps(
  230. db: Session,
  231. tenant_no: str,
  232. *,
  233. page: int = 1,
  234. page_size: int = 10,
  235. keyword: Optional[str] = None,
  236. status: Optional[str] = None,
  237. configured_material_id: Optional[str] = None,
  238. ) -> Tuple[List[Dict[str, Any]], int]:
  239. q0 = db.query(BizProductionMaterialPrep).filter(
  240. BizProductionMaterialPrep.tenant_no == tenant_no,
  241. BizProductionMaterialPrep.del_flag == 0,
  242. )
  243. st = (status or "").strip().lower()
  244. if st in PREP_STATUSES:
  245. q0 = q0.filter(BizProductionMaterialPrep.status == st)
  246. cmid = (configured_material_id or "").strip()
  247. if cmid:
  248. q0 = q0.filter(BizProductionMaterialPrep.configured_material_id == cmid)
  249. kw = (keyword or "").strip()
  250. if kw:
  251. like = f"%{kw}%"
  252. q0 = q0.filter(
  253. or_(
  254. BizProductionMaterialPrep.prep_no.like(like),
  255. BizProductionMaterialPrep.configured_material_code.like(like),
  256. BizProductionMaterialPrep.configured_material_name.like(like),
  257. BizProductionMaterialPrep.batch_no.like(like),
  258. BizProductionMaterialPrep.stock_in_no.like(like),
  259. )
  260. )
  261. total = q0.count()
  262. rows = (
  263. q0.order_by(BizProductionMaterialPrep.prep_time.desc(), BizProductionMaterialPrep.create_time.desc())
  264. .offset((page - 1) * page_size)
  265. .limit(page_size)
  266. .all()
  267. )
  268. prep_ids = [r.id for r in rows]
  269. line_counts: Dict[str, int] = {}
  270. if prep_ids:
  271. cnt_rows = (
  272. db.query(
  273. BizProductionMaterialPrepLine.prep_id,
  274. func.count(BizProductionMaterialPrepLine.id),
  275. )
  276. .filter(
  277. BizProductionMaterialPrepLine.prep_id.in_(prep_ids),
  278. BizProductionMaterialPrepLine.del_flag == 0,
  279. )
  280. .group_by(BizProductionMaterialPrepLine.prep_id)
  281. .all()
  282. )
  283. line_counts = {pid: int(c) for pid, c in cnt_rows}
  284. name_map = _user_nick_name_map(db, [r.create_by for r in rows if r.create_by])
  285. items = [
  286. _header_to_dict(
  287. r,
  288. create_by_name=name_map.get(r.create_by or "", ""),
  289. line_count=line_counts.get(r.id, 0),
  290. )
  291. for r in rows
  292. ]
  293. return items, total
  294. def get_prep_detail(db: Session, prep_id: str, tenant_no: str) -> Dict[str, Any]:
  295. row = _get_prep_row(db, prep_id, tenant_no)
  296. lines = (
  297. db.query(BizProductionMaterialPrepLine)
  298. .filter(
  299. BizProductionMaterialPrepLine.prep_id == prep_id,
  300. BizProductionMaterialPrepLine.tenant_no == tenant_no,
  301. BizProductionMaterialPrepLine.del_flag == 0,
  302. )
  303. .order_by(
  304. BizProductionMaterialPrepLine.order_num.asc(),
  305. BizProductionMaterialPrepLine.create_time.asc(),
  306. )
  307. .all()
  308. )
  309. name_map = _user_nick_name_map(db, [row.create_by] if row.create_by else [])
  310. result = _header_to_dict(
  311. row,
  312. create_by_name=name_map.get(row.create_by or "", ""),
  313. line_count=len(lines),
  314. )
  315. req_ids = list(
  316. {(ln.requisition_usage_id or "").strip() for ln in lines if (ln.requisition_usage_id or "").strip()}
  317. )
  318. req_map: Dict[str, BizProductionMaterialUsage] = {}
  319. if req_ids:
  320. reqs = (
  321. db.query(BizProductionMaterialUsage)
  322. .filter(
  323. BizProductionMaterialUsage.id.in_(req_ids),
  324. BizProductionMaterialUsage.tenant_no == tenant_no,
  325. BizProductionMaterialUsage.del_flag == 0,
  326. )
  327. .all()
  328. )
  329. req_map = {r.id: r for r in reqs}
  330. req_bmap = _batch_map_for_prep_lines(db, [r.batch_id for r in req_map.values() if r.batch_id])
  331. enriched_lines: List[Dict[str, Any]] = []
  332. for ln in lines:
  333. item = _line_to_dict(ln)
  334. req = req_map.get((ln.requisition_usage_id or "").strip())
  335. if req:
  336. batch = req_bmap.get(req.batch_id or "")
  337. item["batchNo"] = req.batch_no or ""
  338. item["stockInNo"] = req.stock_in_no or ""
  339. item["catalogNo"] = req.catalog_no or ""
  340. item["manufacturer"] = req.manufacturer or ""
  341. item["storageLocation"] = (req.storage_location or "").strip() or (
  342. (batch.storage_location or "") if batch else ""
  343. )
  344. item["requisitionQty"] = abs(float(req.change_qty or 0))
  345. if batch:
  346. item["specModel"] = batch.spec_model or ""
  347. item["stockInDate"] = _fmt_date(batch.stock_in_date)
  348. item["expireDate"] = _fmt_date(batch.expire_date)
  349. enriched_lines.append(item)
  350. result["lines"] = enriched_lines
  351. batch_id = (row.output_batch_id or "").strip()
  352. if batch_id:
  353. batch = (
  354. db.query(BizProductionMaterialStockBatch)
  355. .filter(
  356. BizProductionMaterialStockBatch.id == batch_id,
  357. BizProductionMaterialStockBatch.tenant_no == tenant_no,
  358. BizProductionMaterialStockBatch.del_flag == 0,
  359. )
  360. .first()
  361. )
  362. if batch:
  363. result["specModel"] = batch.spec_model or ""
  364. result["catalogNo"] = batch.catalog_no or ""
  365. result["manufacturer"] = batch.manufacturer or ""
  366. result["stockInDate"] = _fmt_date(batch.stock_in_date)
  367. result["unitPrice"] = None
  368. result["amount"] = None
  369. return result
  370. def create_prep(
  371. db: Session,
  372. *,
  373. data: Dict[str, Any],
  374. tenant_no: str,
  375. user_id: Optional[str] = None,
  376. dept_id: str = "",
  377. ) -> Dict[str, Any]:
  378. """创建配制单(草稿):用于旧版流程。"""
  379. configured_id = (
  380. data.get("configuredMaterialId") or data.get("configured_material_id") or ""
  381. ).strip()
  382. if not configured_id:
  383. raise ValueError("请选择配置试剂")
  384. configured = (
  385. db.query(BizProductionMaterial)
  386. .filter(
  387. BizProductionMaterial.id == configured_id,
  388. BizProductionMaterial.tenant_no == tenant_no,
  389. BizProductionMaterial.del_flag == 0,
  390. )
  391. .first()
  392. )
  393. if not configured:
  394. raise ValueError("配置试剂不存在")
  395. if (configured.material_type or "").strip() != "configured_reagent":
  396. raise ValueError("所选物料不是配置试剂")
  397. prep_time_raw = data.get("prepTime") or data.get("prep_time")
  398. prep_time = parse_datetime(prep_time_raw) if prep_time_raw else datetime.now()
  399. output_qty_raw = data.get("outputQty") if data.get("outputQty") is not None else data.get("output_qty")
  400. if output_qty_raw is None or Decimal(str(output_qty_raw)) <= 0:
  401. raise ValueError("产出数量必须大于0")
  402. output_qty = Decimal(str(output_qty_raw))
  403. batch_no = _require_str(data.get("batchNo") or data.get("batch_no"), "产出批号")
  404. expire_raw = data.get("expireDate") or data.get("expire_date")
  405. expire_date = _parse_expire_date(expire_raw)
  406. lines_raw = data.get("lines") or []
  407. parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
  408. now = datetime.now()
  409. prep_id = generate_uuid()
  410. prep_no = _generate_prep_no(db, tenant_no)
  411. prep = BizProductionMaterialPrep(
  412. id=prep_id,
  413. prep_no=prep_no,
  414. configured_material_id=configured_id,
  415. configured_material_code=configured.material_code,
  416. configured_material_name=configured.material_name,
  417. prep_time=prep_time,
  418. output_qty=output_qty,
  419. output_unit=(data.get("outputUnit") or data.get("output_unit") or configured.unit or "").strip()[:32] or None,
  420. batch_no=batch_no,
  421. storage_location=_optional_str(data.get("storageLocation") or data.get("storage_location")),
  422. expire_date=expire_date,
  423. status="draft",
  424. tenant_no=tenant_no,
  425. remark=(data.get("remark") or "").strip()[:512] or None,
  426. create_by=user_id,
  427. create_dept_id=dept_id or configured.create_dept_id or "",
  428. create_time=now,
  429. update_by=user_id,
  430. update_time=now,
  431. del_flag=0,
  432. )
  433. db.add(prep)
  434. for pl in parsed_lines:
  435. comp = pl["component"]
  436. db.add(
  437. BizProductionMaterialPrepLine(
  438. id=generate_uuid(),
  439. prep_id=prep_id,
  440. component_material_id=comp.id,
  441. component_material_type=pl["component_type"],
  442. component_material_code=comp.material_code,
  443. component_material_name=comp.material_name,
  444. requisition_usage_id=pl["requisition"].id,
  445. actual_qty=pl["actual_qty"],
  446. usage_unit=pl["usage_unit"] or None,
  447. tenant_no=tenant_no,
  448. order_num=pl["idx"],
  449. remark=pl["remark"],
  450. create_by=user_id,
  451. create_dept_id=dept_id or configured.create_dept_id or "",
  452. create_time=now,
  453. update_by=user_id,
  454. update_time=now,
  455. del_flag=0,
  456. )
  457. )
  458. db.commit()
  459. return get_prep_detail(db, prep_id, tenant_no)
  460. def create_quick_prep(
  461. db: Session,
  462. *,
  463. data: Dict[str, Any],
  464. tenant_no: str,
  465. user_id: Optional[str] = None,
  466. dept_id: str = "",
  467. ) -> Dict[str, Any]:
  468. """一次性配制:创建即完成(扣减子物料 → 入库配置试剂 → 自动出库给操作人)。"""
  469. configured_name = (data.get("configuredMaterialName") or data.get("configured_material_name") or "").strip()
  470. if not configured_name:
  471. raise ValueError("请输入配置试剂名称")
  472. output_qty_raw = data.get("outputQty") if data.get("outputQty") is not None else data.get("output_qty")
  473. if output_qty_raw is None or Decimal(str(output_qty_raw)) <= 0:
  474. raise ValueError("产出数量必须大于0")
  475. output_qty = Decimal(str(output_qty_raw))
  476. lines_raw = data.get("lines") or []
  477. if not lines_raw:
  478. raise ValueError("请至少添加一条子物料")
  479. # 校验并解析子物料
  480. parsed_lines = _parse_quick_prep_lines(db, lines_raw, tenant_no=tenant_no)
  481. now = datetime.now()
  482. prep_id = generate_uuid()
  483. prep_no = _generate_prep_no(db, tenant_no)
  484. stock_in_no_raw = (data.get("stockInNo") or data.get("stock_in_no") or "").strip()
  485. if stock_in_no_raw:
  486. if stock_in_no_exists(db, stock_in_no_raw, tenant_no):
  487. raise ValueError("入库编号已存在")
  488. stock_in_no = stock_in_no_raw
  489. else:
  490. stock_in_no = _generate_stock_in_no(db, tenant_no)
  491. # 配置试剂为自配物料,不核算单价与金额(库表 NOT NULL,存 0;接口层按类型屏蔽展示)
  492. unit_price_out = Decimal("0")
  493. amount_out = Decimal("0")
  494. spec_model = _optional_str(data.get("specModel") or data.get("spec_model"))
  495. output_unit = _optional_str(data.get("outputUnit") or data.get("output_unit")) or "瓶"
  496. # 自动创建或获取配置试剂物料
  497. configured_material = _get_or_create_configured_material(
  498. db,
  499. configured_name,
  500. tenant_no,
  501. user_id,
  502. dept_id,
  503. spec_model=spec_model,
  504. unit=output_unit,
  505. )
  506. output_unit = output_unit or configured_material.unit or "瓶"
  507. # 生成批号
  508. batch_no_raw = (data.get("batchNo") or data.get("batch_no") or "").strip()
  509. batch_no = batch_no_raw if batch_no_raw else prep_no
  510. catalog_no_raw = (data.get("catalogNo") or data.get("catalog_no") or "").strip()
  511. catalog_no = catalog_no_raw if catalog_no_raw else (configured_material.material_code or batch_no or "—")
  512. manufacturer = (data.get("manufacturer") or "").strip() or "自配"
  513. # 入库日期(业务日期,仅日期)与变动记录时间(含时分秒)分离,对齐普通入库逻辑
  514. stock_in_date_raw = data.get("stockInDate") or data.get("stock_in_date")
  515. stock_in_date = _parse_date(stock_in_date_raw) if stock_in_date_raw else now.date()
  516. prep_time_raw = (
  517. data.get("prepTime")
  518. or data.get("prep_time")
  519. or data.get("stockInTime")
  520. or data.get("stock_in_time")
  521. )
  522. operate_time = parse_datetime(prep_time_raw) if prep_time_raw else now
  523. try:
  524. # 1. 创建配制单(状态直接为 completed)
  525. expire_date = _parse_expire_date(data.get("expireDate") or data.get("expire_date") or "")
  526. if not expire_date:
  527. expire_date = datetime(now.year + 1, now.month, now.day).date()
  528. prep = BizProductionMaterialPrep(
  529. id=prep_id,
  530. prep_no=prep_no,
  531. configured_material_id=configured_material.id,
  532. configured_material_code=configured_material.material_code,
  533. configured_material_name=configured_name,
  534. prep_time=operate_time,
  535. output_qty=output_qty,
  536. output_unit=output_unit,
  537. batch_no=batch_no,
  538. storage_location=_optional_str(data.get("storageLocation") or data.get("storage_location")),
  539. expire_date=expire_date,
  540. stock_in_no=stock_in_no,
  541. status="completed",
  542. tenant_no=tenant_no,
  543. remark=(data.get("remark") or "").strip()[:512] or None,
  544. create_by=user_id,
  545. create_dept_id=dept_id or configured_material.create_dept_id or "",
  546. create_time=now,
  547. update_by=user_id,
  548. update_time=now,
  549. del_flag=0,
  550. )
  551. db.add(prep)
  552. # 2. 创建配制明细并扣减子物料
  553. for pl in parsed_lines:
  554. comp = pl["component"]
  555. req = pl["requisition"]
  556. actual_qty = pl["actual_qty"]
  557. # 创建配制明细行
  558. db.add(
  559. BizProductionMaterialPrepLine(
  560. id=generate_uuid(),
  561. prep_id=prep_id,
  562. component_material_id=comp.id,
  563. component_material_type=pl["component_type"],
  564. component_material_code=comp.material_code,
  565. component_material_name=comp.material_name,
  566. requisition_usage_id=req.id if req else None,
  567. actual_qty=actual_qty,
  568. usage_unit=pl["usage_unit"] or None,
  569. tenant_no=tenant_no,
  570. order_num=pl["idx"],
  571. remark=pl.get("remark"),
  572. create_by=user_id,
  573. create_dept_id=dept_id or configured_material.create_dept_id or "",
  574. create_time=now,
  575. update_by=user_id,
  576. update_time=now,
  577. del_flag=0,
  578. )
  579. )
  580. # 扣减子物料库存(写入 prep_consume 记录)
  581. _write_quick_prep_consume_usage(
  582. db,
  583. prep=prep,
  584. material=comp,
  585. requisition=req,
  586. actual_qty=actual_qty,
  587. usage_unit=pl["usage_unit"] or None,
  588. tenant_no=tenant_no,
  589. user_id=user_id,
  590. dept_id=dept_id or configured_material.create_dept_id or "",
  591. operate_time=operate_time,
  592. )
  593. # 3. 创建配置试剂入库批次
  594. catalog_no = catalog_no if catalog_no else (configured_material.material_code or batch_no or "—")
  595. batch = _create_batch_and_usage(
  596. db,
  597. material=configured_material,
  598. stock_in_no=stock_in_no,
  599. stock_in_date=stock_in_date,
  600. expire_date=expire_date,
  601. catalog_no=catalog_no,
  602. batch_no=batch_no,
  603. manufacturer=manufacturer,
  604. storage_location=_optional_str(data.get("storageLocation") or data.get("storage_location")),
  605. unit_price=unit_price_out,
  606. amount=amount_out,
  607. qty=output_qty,
  608. remark=f"配制单 {prep_no}",
  609. tenant_no=tenant_no,
  610. user_id=user_id,
  611. operate_time=operate_time,
  612. commit=False,
  613. source_type="prep_in",
  614. prep_id=prep.id,
  615. )
  616. prep.output_batch_id = batch.id
  617. db.add(prep)
  618. # 4. 自动出库给当前操作人
  619. _create_auto_stock_out(
  620. db,
  621. batch=batch,
  622. output_qty=output_qty,
  623. tenant_no=tenant_no,
  624. user_id=user_id,
  625. dept_id=dept_id or configured_material.create_dept_id or "",
  626. operate_time=operate_time,
  627. )
  628. db.commit()
  629. except Exception:
  630. db.rollback()
  631. raise
  632. return get_prep_detail(db, prep_id, tenant_no)
  633. def _parse_quick_prep_lines(
  634. db: Session,
  635. lines: List[Dict[str, Any]],
  636. *,
  637. tenant_no: str,
  638. ) -> List[Dict[str, Any]]:
  639. """解析一次性配制的子物料(按领用记录)。"""
  640. if not lines:
  641. raise ValueError("请至少添加一条子物料")
  642. parsed: List[Dict[str, Any]] = []
  643. seen_req: set = set()
  644. for idx, it in enumerate(lines):
  645. req_id = (
  646. it.get("requisitionUsageId")
  647. or it.get("requisition_usage_id")
  648. or ""
  649. ).strip()
  650. if not req_id:
  651. raise ValueError("请选择领用记录")
  652. comp_id = (it.get("componentMaterialId") or it.get("component_material_id") or "").strip()
  653. if not comp_id:
  654. raise ValueError("子物料不能为空")
  655. comp_type = (it.get("componentMaterialType") or it.get("component_material_type") or "").strip().lower()
  656. if comp_type not in ("reagent", "consumable"):
  657. raise ValueError("子物料类型无效")
  658. qty_raw = it.get("consumptionQty") if it.get("consumptionQty") is not None else it.get("consumption_qty")
  659. if qty_raw is None or Decimal(str(qty_raw)) <= 0:
  660. raise ValueError("消耗数量必须大于0")
  661. consumption_qty = Decimal(str(qty_raw))
  662. if req_id in seen_req:
  663. raise ValueError("同一领用记录不可重复使用")
  664. seen_req.add(req_id)
  665. req = (
  666. db.query(BizProductionMaterialUsage)
  667. .filter(
  668. BizProductionMaterialUsage.id == req_id,
  669. BizProductionMaterialUsage.tenant_no == tenant_no,
  670. BizProductionMaterialUsage.del_flag == 0,
  671. BizProductionMaterialUsage.source_type == "manual_out",
  672. BizProductionMaterialUsage.change_type == "use",
  673. )
  674. .first()
  675. )
  676. if not req:
  677. raise ValueError("领用记录不存在或已失效")
  678. if (req.requisition_status or "open") == "closed":
  679. raise ValueError("领用记录已标记为使用完,不可用于配制")
  680. comp = (
  681. db.query(BizProductionMaterial)
  682. .filter(
  683. BizProductionMaterial.id == comp_id,
  684. BizProductionMaterial.tenant_no == tenant_no,
  685. BizProductionMaterial.del_flag == 0,
  686. )
  687. .first()
  688. )
  689. if not comp:
  690. raise ValueError("子物料不存在")
  691. if (req.material_id or "").strip() != comp.id:
  692. raise ValueError("领用记录与子物料不匹配")
  693. req_qty = abs(req.change_qty or Decimal("0"))
  694. if consumption_qty > req_qty:
  695. raise ValueError(
  696. f"子物料「{comp.material_name}」消耗数量不能超过领用数量 {req_qty}"
  697. )
  698. batch = None
  699. if req.batch_id:
  700. batch = (
  701. db.query(BizProductionMaterialStockBatch)
  702. .filter(
  703. BizProductionMaterialStockBatch.id == req.batch_id,
  704. BizProductionMaterialStockBatch.tenant_no == tenant_no,
  705. BizProductionMaterialStockBatch.del_flag == 0,
  706. )
  707. .first()
  708. )
  709. usage_unit = (it.get("usageUnit") or it.get("usage_unit") or req.usage_unit or comp.unit or "").strip()[:32]
  710. parsed.append({
  711. "idx": idx,
  712. "component": comp,
  713. "component_type": comp_type,
  714. "batch": batch,
  715. "requisition": req,
  716. "actual_qty": consumption_qty,
  717. "usage_unit": usage_unit,
  718. "remark": (it.get("remark") or "").strip()[:512] or None,
  719. })
  720. return parsed
  721. def _get_or_create_configured_material(
  722. db: Session,
  723. name: str,
  724. tenant_no: str,
  725. user_id: Optional[str],
  726. dept_id: str,
  727. *,
  728. spec_model: Optional[str] = None,
  729. unit: Optional[str] = None,
  730. ) -> BizProductionMaterial:
  731. """获取或创建配置试剂物料(按名称匹配)。"""
  732. # 先查找是否存在
  733. existing = (
  734. db.query(BizProductionMaterial)
  735. .filter(
  736. BizProductionMaterial.material_name == name,
  737. BizProductionMaterial.material_type == "configured_reagent",
  738. BizProductionMaterial.tenant_no == tenant_no,
  739. BizProductionMaterial.del_flag == 0,
  740. )
  741. .first()
  742. )
  743. if existing:
  744. if not _is_enabled_status(existing.status):
  745. raise ValueError(f"配置试剂「{name}」已停用,请启用后再配制")
  746. return existing
  747. # 自动生成编码
  748. day = datetime.now().strftime("%Y%m%d")
  749. like = f"PZR-{day}-%"
  750. cnt = (
  751. db.query(func.count(BizProductionMaterial.id))
  752. .filter(
  753. BizProductionMaterial.tenant_no == tenant_no,
  754. BizProductionMaterial.material_code.like(like),
  755. BizProductionMaterial.del_flag == 0,
  756. )
  757. .scalar()
  758. )
  759. code = f"PZR-{day}-{(cnt or 0) + 1:03d}"
  760. now = datetime.now()
  761. mat = BizProductionMaterial(
  762. id=generate_uuid(),
  763. material_type="configured_reagent",
  764. material_code=code,
  765. material_name=name,
  766. spec_model=spec_model,
  767. unit=unit or "瓶",
  768. stock_qty=Decimal("0"),
  769. warning_qty=Decimal("0"),
  770. status="1",
  771. tenant_no=tenant_no,
  772. create_by=user_id,
  773. create_dept_id=dept_id,
  774. create_time=now,
  775. update_by=user_id,
  776. update_time=now,
  777. del_flag=0,
  778. )
  779. db.add(mat)
  780. return mat
  781. def _write_quick_prep_consume_usage(
  782. db: Session,
  783. *,
  784. prep: BizProductionMaterialPrep,
  785. material: BizProductionMaterial,
  786. requisition: Optional[BizProductionMaterialUsage],
  787. actual_qty: Decimal,
  788. usage_unit: Optional[str],
  789. tenant_no: str,
  790. user_id: Optional[str],
  791. dept_id: str,
  792. operate_time: datetime,
  793. ) -> None:
  794. """写入子物料消耗记录(关联领用记录,不重复扣减批次库存)。"""
  795. if not requisition:
  796. raise ValueError("子物料必须关联领用记录")
  797. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  798. unit_price = requisition.unit_price if requisition else None
  799. line_amount = Decimal("0")
  800. if unit_price is not None and actual_qty > 0:
  801. line_amount = (Decimal(str(unit_price)) * actual_qty).quantize(Decimal("0.01"))
  802. usage = BizProductionMaterialUsage(
  803. id=generate_uuid(),
  804. material_id=material.id,
  805. material_type=material.material_type,
  806. material_code=material.material_code,
  807. material_name=material.material_name,
  808. batch_id=requisition.batch_id if requisition else None,
  809. stock_in_no=requisition.stock_in_no if requisition else None,
  810. catalog_no=requisition.catalog_no if requisition else None,
  811. batch_no=requisition.batch_no if requisition else None,
  812. manufacturer=requisition.manufacturer if requisition else None,
  813. storage_location=requisition.storage_location if requisition else None,
  814. unit_price=unit_price,
  815. amount=line_amount if line_amount > 0 else (requisition.amount if requisition else None),
  816. change_type="use",
  817. change_qty=-actual_qty,
  818. usage_unit=usage_unit,
  819. stock_before=stock_qty,
  820. stock_after=stock_qty, # 汇总库存暂不扣减,按批次扣减
  821. parent_usage_id=requisition.id if requisition else None,
  822. prep_id=prep.id,
  823. source_type="prep_consume",
  824. tenant_no=tenant_no,
  825. remark=f"配制单 {prep.prep_no}",
  826. create_by=user_id,
  827. create_dept_id=dept_id,
  828. create_time=operate_time,
  829. update_by=user_id,
  830. update_time=operate_time,
  831. del_flag=0,
  832. )
  833. db.add(usage)
  834. def _create_auto_stock_out(
  835. db: Session,
  836. *,
  837. batch,
  838. output_qty: Decimal,
  839. tenant_no: str,
  840. user_id: Optional[str],
  841. dept_id: str,
  842. operate_time: datetime,
  843. ) -> None:
  844. """自动出库配置试剂给当前操作人(写入领用记录)。"""
  845. if not user_id:
  846. return
  847. _stock_out_batch(
  848. db,
  849. batch.id,
  850. qty=output_qty,
  851. receiver=user_id,
  852. purpose="配制自用",
  853. remark="配制产出自动出库",
  854. usage_unit=batch.unit,
  855. tenant_no=tenant_no,
  856. user_id=user_id,
  857. operate_time=operate_time,
  858. )
  859. def update_prep(
  860. db: Session,
  861. prep_id: str,
  862. *,
  863. data: Dict[str, Any],
  864. tenant_no: str,
  865. user_id: Optional[str] = None,
  866. dept_id: str = "",
  867. ) -> Dict[str, Any]:
  868. prep = _get_prep_row(db, prep_id, tenant_no)
  869. _require_draft(prep)
  870. now = datetime.now()
  871. if data.get("prepTime") or data.get("prep_time"):
  872. prep.prep_time = parse_datetime(data.get("prepTime") or data.get("prep_time"))
  873. if data.get("outputQty") is not None or data.get("output_qty") is not None:
  874. output_qty_raw = data.get("outputQty") if data.get("outputQty") is not None else data.get("output_qty")
  875. if Decimal(str(output_qty_raw)) <= 0:
  876. raise ValueError("产出数量必须大于0")
  877. prep.output_qty = Decimal(str(output_qty_raw))
  878. if data.get("outputUnit") is not None or data.get("output_unit") is not None:
  879. prep.output_unit = (
  880. data.get("outputUnit") or data.get("output_unit") or ""
  881. ).strip()[:32] or None
  882. if data.get("batchNo") or data.get("batch_no"):
  883. prep.batch_no = _require_str(data.get("batchNo") or data.get("batch_no"), "产出批号")
  884. if data.get("storageLocation") is not None or data.get("storage_location") is not None:
  885. prep.storage_location = _optional_str(
  886. data.get("storageLocation") if data.get("storageLocation") is not None else data.get("storage_location")
  887. )
  888. if data.get("expireDate") or data.get("expire_date"):
  889. prep.expire_date = _parse_expire_date(data.get("expireDate") or data.get("expire_date"))
  890. if data.get("remark") is not None:
  891. prep.remark = (data.get("remark") or "").strip()[:512] or None
  892. lines_raw = data.get("lines")
  893. if lines_raw is not None:
  894. parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
  895. existing = (
  896. db.query(BizProductionMaterialPrepLine)
  897. .filter(
  898. BizProductionMaterialPrepLine.prep_id == prep_id,
  899. BizProductionMaterialPrepLine.del_flag == 0,
  900. )
  901. .all()
  902. )
  903. for ln in existing:
  904. ln.del_flag = 1
  905. ln.update_by = user_id
  906. ln.update_time = now
  907. db.add(ln)
  908. for pl in parsed_lines:
  909. comp = pl["component"]
  910. db.add(
  911. BizProductionMaterialPrepLine(
  912. id=generate_uuid(),
  913. prep_id=prep_id,
  914. component_material_id=comp.id,
  915. component_material_type=pl["component_type"],
  916. component_material_code=comp.material_code,
  917. component_material_name=comp.material_name,
  918. requisition_usage_id=pl["requisition"].id,
  919. actual_qty=pl["actual_qty"],
  920. usage_unit=pl["usage_unit"] or None,
  921. tenant_no=tenant_no,
  922. order_num=pl["idx"],
  923. remark=pl["remark"],
  924. create_by=user_id,
  925. create_dept_id=dept_id or prep.create_dept_id or "",
  926. create_time=now,
  927. update_by=user_id,
  928. update_time=now,
  929. del_flag=0,
  930. )
  931. )
  932. prep.update_by = user_id
  933. prep.update_time = now
  934. db.add(prep)
  935. db.commit()
  936. return get_prep_detail(db, prep_id, tenant_no)
  937. def _write_prep_consume_usage(
  938. db: Session,
  939. *,
  940. prep: BizProductionMaterialPrep,
  941. line: BizProductionMaterialPrepLine,
  942. req: BizProductionMaterialUsage,
  943. tenant_no: str,
  944. user_id: Optional[str],
  945. dept_id: str,
  946. operate_time: datetime,
  947. ) -> Decimal:
  948. material = (
  949. db.query(BizProductionMaterial)
  950. .filter(
  951. BizProductionMaterial.id == line.component_material_id,
  952. BizProductionMaterial.tenant_no == tenant_no,
  953. BizProductionMaterial.del_flag == 0,
  954. )
  955. .first()
  956. )
  957. if not material:
  958. raise ValueError("子物料不存在")
  959. actual_qty = line.actual_qty or Decimal("0")
  960. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  961. unit_price = req.unit_price
  962. line_amount = Decimal("0")
  963. if unit_price is not None and actual_qty > 0:
  964. line_amount = (Decimal(str(unit_price)) * actual_qty).quantize(Decimal("0.01"))
  965. usage = BizProductionMaterialUsage(
  966. id=generate_uuid(),
  967. material_id=material.id,
  968. material_type=material.material_type,
  969. material_code=material.material_code,
  970. material_name=material.material_name,
  971. batch_id=req.batch_id,
  972. stock_in_no=req.stock_in_no,
  973. catalog_no=req.catalog_no,
  974. batch_no=req.batch_no,
  975. manufacturer=req.manufacturer,
  976. storage_location=req.storage_location,
  977. unit_price=req.unit_price,
  978. amount=line_amount if line_amount > 0 else req.amount,
  979. change_type="use",
  980. change_qty=-actual_qty,
  981. usage_unit=line.usage_unit,
  982. stock_before=stock_qty,
  983. stock_after=stock_qty,
  984. parent_usage_id=req.id,
  985. prep_id=prep.id,
  986. source_type="prep_consume",
  987. tenant_no=tenant_no,
  988. remark=f"配制单 {prep.prep_no}",
  989. create_by=user_id,
  990. create_dept_id=dept_id,
  991. create_time=operate_time,
  992. update_by=user_id,
  993. update_time=operate_time,
  994. del_flag=0,
  995. )
  996. db.add(usage)
  997. return line_amount
  998. def complete_prep(
  999. db: Session,
  1000. prep_id: str,
  1001. *,
  1002. tenant_no: str,
  1003. user_id: Optional[str] = None,
  1004. dept_id: str = "",
  1005. ) -> Dict[str, Any]:
  1006. prep = _get_prep_row(db, prep_id, tenant_no)
  1007. if (prep.status or "").strip() != "draft":
  1008. raise ValueError("仅草稿状态的配制单可确认配制")
  1009. lines = (
  1010. db.query(BizProductionMaterialPrepLine)
  1011. .filter(
  1012. BizProductionMaterialPrepLine.prep_id == prep_id,
  1013. BizProductionMaterialPrepLine.del_flag == 0,
  1014. )
  1015. .order_by(BizProductionMaterialPrepLine.order_num.asc())
  1016. .all()
  1017. )
  1018. if not lines:
  1019. raise ValueError("配制单无明细,无法确认")
  1020. configured = _get_material_for_update(db, prep.configured_material_id, tenant_no)
  1021. if (configured.material_type or "").strip() != "configured_reagent":
  1022. raise ValueError("配置试剂目录无效")
  1023. if not _is_enabled_status(configured.status):
  1024. raise ValueError(f"配置试剂「{configured.material_name}」已停用")
  1025. operate_time = datetime.now()
  1026. total_amount = Decimal("0")
  1027. try:
  1028. req_map: Dict[str, BizProductionMaterialUsage] = {}
  1029. for line in lines:
  1030. req_id = (line.requisition_usage_id or "").strip()
  1031. if not req_id:
  1032. raise ValueError("配制明细缺少领用记录")
  1033. if req_id not in req_map:
  1034. req = (
  1035. db.query(BizProductionMaterialUsage)
  1036. .filter(
  1037. BizProductionMaterialUsage.id == req_id,
  1038. BizProductionMaterialUsage.tenant_no == tenant_no,
  1039. BizProductionMaterialUsage.del_flag == 0,
  1040. BizProductionMaterialUsage.source_type == "manual_out",
  1041. )
  1042. .first()
  1043. )
  1044. if not req:
  1045. raise ValueError("领用记录不存在或已失效")
  1046. req_map[req_id] = req
  1047. amt = _write_prep_consume_usage(
  1048. db,
  1049. prep=prep,
  1050. line=line,
  1051. req=req_map[req_id],
  1052. tenant_no=tenant_no,
  1053. user_id=user_id,
  1054. dept_id=dept_id or prep.create_dept_id or "",
  1055. operate_time=operate_time,
  1056. )
  1057. total_amount += amt
  1058. stock_in_no = _generate_stock_in_no(db, tenant_no)
  1059. output_qty = prep.output_qty or Decimal("0")
  1060. if output_qty <= 0:
  1061. raise ValueError("产出数量必须大于0")
  1062. unit_price = Decimal("0")
  1063. amount = total_amount
  1064. if output_qty > 0 and total_amount > 0:
  1065. unit_price = (total_amount / output_qty).quantize(Decimal("0.01"))
  1066. amount = (unit_price * output_qty).quantize(Decimal("0.01"))
  1067. stock_in_date = prep.prep_time.date() if prep.prep_time else operate_time.date()
  1068. catalog_no = (configured.material_code or prep.batch_no or "—").strip() or "—"
  1069. batch = _create_batch_and_usage(
  1070. db,
  1071. material=configured,
  1072. stock_in_no=stock_in_no,
  1073. stock_in_date=stock_in_date,
  1074. expire_date=prep.expire_date,
  1075. catalog_no=catalog_no,
  1076. batch_no=prep.batch_no,
  1077. manufacturer="自配",
  1078. storage_location=prep.storage_location,
  1079. unit_price=unit_price,
  1080. amount=amount,
  1081. qty=output_qty,
  1082. remark=f"配制单 {prep.prep_no}",
  1083. tenant_no=tenant_no,
  1084. user_id=user_id,
  1085. operate_time=operate_time,
  1086. commit=False,
  1087. source_type="prep_in",
  1088. prep_id=prep.id,
  1089. )
  1090. prep.status = "completed"
  1091. prep.stock_in_no = stock_in_no
  1092. prep.output_batch_id = batch.id
  1093. prep.update_by = user_id
  1094. prep.update_time = operate_time
  1095. db.add(prep)
  1096. db.commit()
  1097. except Exception:
  1098. db.rollback()
  1099. raise
  1100. return get_prep_detail(db, prep_id, tenant_no)
  1101. def cancel_prep(
  1102. db: Session,
  1103. prep_id: str,
  1104. *,
  1105. tenant_no: str,
  1106. user_id: Optional[str] = None,
  1107. ) -> None:
  1108. prep = _get_prep_row(db, prep_id, tenant_no)
  1109. _require_draft(prep)
  1110. now = datetime.now()
  1111. prep.status = "cancelled"
  1112. prep.update_by = user_id
  1113. prep.update_time = now
  1114. db.add(prep)
  1115. db.commit()
  1116. def delete_prep(
  1117. db: Session,
  1118. prep_id: str,
  1119. *,
  1120. tenant_no: str,
  1121. user_id: Optional[str] = None,
  1122. ) -> None:
  1123. prep = _get_prep_row(db, prep_id, tenant_no)
  1124. _require_draft(prep)
  1125. now = datetime.now()
  1126. prep.del_flag = 1
  1127. prep.update_by = user_id
  1128. prep.update_time = now
  1129. db.add(prep)
  1130. lines = (
  1131. db.query(BizProductionMaterialPrepLine)
  1132. .filter(
  1133. BizProductionMaterialPrepLine.prep_id == prep_id,
  1134. BizProductionMaterialPrepLine.del_flag == 0,
  1135. )
  1136. .all()
  1137. )
  1138. for ln in lines:
  1139. ln.del_flag = 1
  1140. ln.update_by = user_id
  1141. ln.update_time = now
  1142. db.add(ln)
  1143. db.commit()
  1144. def list_available_material_batches(
  1145. db: Session,
  1146. tenant_no: str,
  1147. *,
  1148. page: int = 1,
  1149. page_size: int = 50,
  1150. keyword: Optional[str] = None,
  1151. material_type: Optional[str] = None,
  1152. user_info=None,
  1153. ) -> Tuple[List[Dict[str, Any]], int]:
  1154. """查询可用于配制的领用记录(有剩余、未标记已用完)。"""
  1155. return list_available_requisitions_for_prep(
  1156. db,
  1157. tenant_no,
  1158. page=page,
  1159. page_size=page_size,
  1160. keyword=keyword,
  1161. material_type=material_type,
  1162. user_info=user_info,
  1163. )