storage_occupancy.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. """库位占用记录 CRUD(biz_storage_slot_occupancy)"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from typing import Any, Dict, List, Optional
  5. from sqlalchemy import func
  6. from sqlalchemy.orm import Session
  7. from app.core.utils import generate_uuid
  8. from app.models.models import (
  9. BizCellProduct,
  10. BizStorageFacilitySlot,
  11. BizStorageSlotOccupancy,
  12. )
  13. SLOT_OCCUPIED_MSG = "该储物格已被占用,请选择其他储物格"
  14. _BATCH_DUPLICATE_SLOT_MSG = "同一批次不能选择重复的储物格"
  15. _CELL_PRODUCT_IN_STORE_STATUSES = frozenset({"frozen", "thawed", "in_production"})
  16. def assert_batch_slots_unique(slot_ids: List[Optional[str]]) -> None:
  17. """同一批次内储物格不可重复。"""
  18. seen: set[str] = set()
  19. for sid in slot_ids:
  20. slot_id = (sid or "").strip()
  21. if not slot_id:
  22. continue
  23. if slot_id in seen:
  24. raise ValueError(_BATCH_DUPLICATE_SLOT_MSG)
  25. seen.add(slot_id)
  26. def assert_slot_vacant(
  27. db: Session,
  28. slot_id: str,
  29. *,
  30. tenant_no: str = "",
  31. exclude_occupancy_id: Optional[str] = None,
  32. exclude_product_id: Optional[str] = None,
  33. ) -> None:
  34. """断言储物格当前无在库物品(任意 item_type)。"""
  35. slot_id = (slot_id or "").strip()
  36. if not slot_id:
  37. return
  38. slot = (
  39. db.query(BizStorageFacilitySlot)
  40. .filter(
  41. BizStorageFacilitySlot.id == slot_id,
  42. BizStorageFacilitySlot.del_flag == 0,
  43. )
  44. .with_for_update()
  45. .first()
  46. )
  47. if not slot:
  48. raise ValueError("储物格不存在")
  49. if tenant_no and slot.tenant_no and slot.tenant_no != tenant_no:
  50. raise ValueError("储物格不存在")
  51. occ_q = db.query(BizStorageSlotOccupancy).filter(
  52. BizStorageSlotOccupancy.slot_id == slot_id,
  53. BizStorageSlotOccupancy.occupancy_status == "stored",
  54. BizStorageSlotOccupancy.del_flag == 0,
  55. )
  56. if exclude_occupancy_id:
  57. occ_q = occ_q.filter(BizStorageSlotOccupancy.id != exclude_occupancy_id)
  58. if occ_q.first():
  59. raise ValueError(SLOT_OCCUPIED_MSG)
  60. prod_q = db.query(BizCellProduct).filter(
  61. BizCellProduct.storage_slot_id == slot_id,
  62. BizCellProduct.del_flag == 0,
  63. BizCellProduct.product_status.in_(list(_CELL_PRODUCT_IN_STORE_STATUSES)),
  64. )
  65. if tenant_no:
  66. prod_q = prod_q.filter(BizCellProduct.tenant_no == tenant_no)
  67. if exclude_product_id:
  68. prod_q = prod_q.filter(BizCellProduct.id != exclude_product_id)
  69. if prod_q.first():
  70. raise ValueError(SLOT_OCCUPIED_MSG)
  71. def _add_stored_occupancy_no_commit(
  72. db: Session,
  73. *,
  74. slot_id: str,
  75. item_type: str,
  76. item_id: str,
  77. item_code: str,
  78. item_name: str,
  79. item_source: str,
  80. tenant_no: str,
  81. user_id: Optional[str] = None,
  82. create_dept_id: str = "",
  83. remark: Optional[str] = None,
  84. stored_at: Optional[datetime] = None,
  85. ) -> BizStorageSlotOccupancy:
  86. now = stored_at or datetime.now()
  87. obj = BizStorageSlotOccupancy(
  88. id=generate_uuid(),
  89. slot_id=slot_id,
  90. item_type=item_type,
  91. item_id=item_id,
  92. item_code=(item_code or "").strip() or None,
  93. item_name=(item_name or "").strip() or None,
  94. item_source=(item_source or "manual").strip(),
  95. stored_at=now,
  96. removed_at=None,
  97. occupancy_status="stored",
  98. tenant_no=tenant_no,
  99. order_num=0,
  100. remark=(remark or "").strip() or None,
  101. create_by=user_id,
  102. create_dept_id=create_dept_id or "",
  103. create_time=now,
  104. update_by=user_id,
  105. update_time=now,
  106. del_flag=0,
  107. )
  108. db.add(obj)
  109. db.flush()
  110. _refresh_slot_cache(db, slot_id)
  111. return obj
  112. def remove_by_item_no_commit(
  113. db: Session,
  114. item_type: str,
  115. item_id: str,
  116. *,
  117. user_id: Optional[str] = None,
  118. ) -> None:
  119. """按物品出库(不 commit)。"""
  120. obj = db.query(BizStorageSlotOccupancy).filter(
  121. BizStorageSlotOccupancy.item_type == item_type,
  122. BizStorageSlotOccupancy.item_id == item_id,
  123. BizStorageSlotOccupancy.occupancy_status == "stored",
  124. BizStorageSlotOccupancy.del_flag == 0,
  125. ).first()
  126. if not obj:
  127. return
  128. now = datetime.now()
  129. obj.occupancy_status = "removed"
  130. obj.removed_at = now
  131. obj.update_by = user_id
  132. obj.update_time = now
  133. db.add(obj)
  134. db.flush()
  135. _refresh_slot_cache(db, obj.slot_id)
  136. def _row_to_dict(c: BizStorageSlotOccupancy) -> Dict[str, Any]:
  137. return {
  138. "id": c.id,
  139. "slotId": c.slot_id,
  140. "itemType": c.item_type or "",
  141. "itemId": c.item_id or "",
  142. "itemCode": c.item_code or "",
  143. "itemName": c.item_name or "",
  144. "itemSource": c.item_source or "",
  145. "storedAt": c.stored_at.strftime("%Y-%m-%d %H:%M:%S") if c.stored_at else "",
  146. "removedAt": c.removed_at.strftime("%Y-%m-%d %H:%M:%S") if c.removed_at else "",
  147. "occupancyStatus": c.occupancy_status or "",
  148. "tenantNo": c.tenant_no,
  149. "remark": c.remark or "",
  150. "createBy": c.create_by or "",
  151. "createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
  152. }
  153. def _load_cell_product_map(
  154. db: Session,
  155. tenant_no: str,
  156. product_ids: List[str],
  157. ) -> Dict[str, Dict[str, Any]]:
  158. """批量加载细胞产品完整信息(一次 enrich,避免 per-id 的 N+1)。"""
  159. ids = [i for i in {(x or "").strip() for x in product_ids} if i]
  160. if not ids:
  161. return {}
  162. from app.crud.cell_product import _enrich_product_dicts, _row_to_dict
  163. q = db.query(BizCellProduct).filter(
  164. BizCellProduct.id.in_(ids),
  165. BizCellProduct.del_flag == 0,
  166. )
  167. if tenant_no:
  168. q = q.filter(BizCellProduct.tenant_no == tenant_no)
  169. rows = q.all()
  170. enriched = _enrich_product_dicts(db, tenant_no, [_row_to_dict(p) for p in rows])
  171. return {item["id"]: item for item in enriched if item.get("id")}
  172. def _load_cell_product_shelf_summaries(
  173. db: Session,
  174. tenant_no: str,
  175. product_ids: List[str],
  176. ) -> Dict[str, Dict[str, Any]]:
  177. """库位货架展示专用:仅批量取 id/编码/名称/类型名/批次号(2 次查询)。"""
  178. from app.models.models import BizProductionProductType
  179. ids = [i for i in {(x or "").strip() for x in product_ids} if i]
  180. if not ids:
  181. return {}
  182. q = db.query(
  183. BizCellProduct.id,
  184. BizCellProduct.product_code,
  185. BizCellProduct.product_name,
  186. BizCellProduct.product_type_id,
  187. BizCellProduct.batch_no,
  188. ).filter(
  189. BizCellProduct.id.in_(ids),
  190. BizCellProduct.del_flag == 0,
  191. )
  192. if tenant_no:
  193. q = q.filter(BizCellProduct.tenant_no == tenant_no)
  194. products = q.all()
  195. type_ids = {r.product_type_id for r in products if r.product_type_id}
  196. type_map: Dict[str, str] = {}
  197. if type_ids:
  198. type_rows = (
  199. db.query(BizProductionProductType.id, BizProductionProductType.product_type_name)
  200. .filter(
  201. BizProductionProductType.id.in_(type_ids),
  202. BizProductionProductType.del_flag == 0,
  203. )
  204. .all()
  205. )
  206. type_map = {r.id: r.product_type_name or "" for r in type_rows}
  207. return {
  208. r.id: {
  209. "id": r.id,
  210. "productCode": r.product_code or "",
  211. "productName": r.product_name or "",
  212. "productTypeId": r.product_type_id or "",
  213. "productTypeName": type_map.get(r.product_type_id or "", ""),
  214. "batchNo": r.batch_no or "",
  215. }
  216. for r in products
  217. }
  218. def _attach_cell_product_fields(item: Dict[str, Any], cp: Dict[str, Any]) -> None:
  219. item["cellProduct"] = cp
  220. item["productTypeName"] = cp.get("productTypeName") or ""
  221. item["specName"] = cp.get("specName") or ""
  222. item["passage"] = cp.get("passage") or ""
  223. item["batchNo"] = cp.get("batchNo") or ""
  224. item["productStatus"] = cp.get("productStatus") or ""
  225. item["expectedCellCount"] = cp.get("expectedCellCount")
  226. item["viability"] = cp.get("viability")
  227. freeze = cp.get("freezeDate") or ""
  228. expiry = cp.get("expiryDate") or ""
  229. item["freezeDate"] = freeze[:10] if freeze else ""
  230. item["expiryDate"] = expiry[:10] if expiry else ""
  231. item["cellProductSourceTypeLabel"] = cp.get("sourceTypeLabel") or ""
  232. def enrich_occupancy_items(
  233. db: Session,
  234. tenant_no: str,
  235. items: List[Dict[str, Any]],
  236. ) -> List[Dict[str, Any]]:
  237. cp_ids = [
  238. (i.get("itemId") or "").strip()
  239. for i in items
  240. if (i.get("itemType") or "") == "cell_product" and (i.get("itemId") or "").strip()
  241. ]
  242. cp_map = _load_cell_product_map(db, tenant_no, cp_ids)
  243. for item in items:
  244. if (item.get("itemType") or "") != "cell_product":
  245. continue
  246. cp = cp_map.get((item.get("itemId") or "").strip())
  247. if cp:
  248. _attach_cell_product_fields(item, cp)
  249. return items
  250. def enrich_slots_with_stored_cell_products(
  251. db: Session,
  252. tenant_no: str,
  253. slots: List[Dict[str, Any]],
  254. ) -> List[Dict[str, Any]]:
  255. """为占用格附带在库占用 id 与物品摘要(名称/类型),供库位货架展示与批量出库。"""
  256. slot_ids = [s.get("id") for s in slots if int(s.get("occupiedCount") or 0) > 0 and s.get("id")]
  257. if not slot_ids:
  258. return slots
  259. occs = (
  260. db.query(BizStorageSlotOccupancy)
  261. .filter(
  262. BizStorageSlotOccupancy.slot_id.in_(slot_ids),
  263. BizStorageSlotOccupancy.occupancy_status == "stored",
  264. BizStorageSlotOccupancy.del_flag == 0,
  265. )
  266. .all()
  267. )
  268. occ_by_slot = {o.slot_id: o for o in occs}
  269. cp_ids = [
  270. o.item_id
  271. for o in occs
  272. if (o.item_type or "") == "cell_product" and (o.item_id or "").strip()
  273. ]
  274. # 货架只需名称+类型,勿走完整 product_to_response(会 N+1 拉设施树/任务等)
  275. cp_map = _load_cell_product_shelf_summaries(db, tenant_no, cp_ids)
  276. for slot in slots:
  277. occ = occ_by_slot.get(slot.get("id"))
  278. if not occ:
  279. continue
  280. slot["storedOccupancyId"] = occ.id
  281. slot["storedItemType"] = occ.item_type or ""
  282. slot["storedItemCode"] = occ.item_code or ""
  283. slot["storedItemName"] = occ.item_name or ""
  284. if (occ.item_type or "") == "cell_product" and (occ.item_id or "").strip():
  285. cp = cp_map.get(occ.item_id)
  286. if cp:
  287. slot["storedCellProduct"] = cp
  288. elif occ.item_name or occ.item_code:
  289. # 产品已删时仍用占用快照展示名称
  290. slot["storedCellProduct"] = {
  291. "id": occ.item_id,
  292. "productCode": occ.item_code or "",
  293. "productName": occ.item_name or "",
  294. "productTypeName": "",
  295. }
  296. return slots
  297. def _refresh_slot_cache(db: Session, slot_id: str) -> None:
  298. """刷新 slot 的 occupied_count / last_item_type / last_stored_at / slot_status"""
  299. slot = db.query(BizStorageFacilitySlot).filter(
  300. BizStorageFacilitySlot.id == slot_id,
  301. BizStorageFacilitySlot.del_flag == 0,
  302. ).first()
  303. if not slot:
  304. return
  305. count = db.query(func.count(BizStorageSlotOccupancy.id)).filter(
  306. BizStorageSlotOccupancy.slot_id == slot_id,
  307. BizStorageSlotOccupancy.occupancy_status == "stored",
  308. BizStorageSlotOccupancy.del_flag == 0,
  309. ).scalar() or 0
  310. slot.occupied_count = count
  311. if count > 0:
  312. slot.slot_status = "占用"
  313. latest = db.query(BizStorageSlotOccupancy).filter(
  314. BizStorageSlotOccupancy.slot_id == slot_id,
  315. BizStorageSlotOccupancy.occupancy_status == "stored",
  316. BizStorageSlotOccupancy.del_flag == 0,
  317. ).order_by(BizStorageSlotOccupancy.stored_at.desc()).first()
  318. if latest:
  319. slot.last_item_type = latest.item_type
  320. slot.last_stored_at = latest.stored_at
  321. else:
  322. slot.slot_status = "空"
  323. slot.last_item_type = None
  324. slot.last_stored_at = None
  325. slot.update_time = datetime.now()
  326. db.add(slot)
  327. db.flush()
  328. def create_occupancy(
  329. db: Session,
  330. *,
  331. slot_id: str,
  332. item_type: str,
  333. item_id: str,
  334. item_code: str,
  335. item_name: str,
  336. item_source: str = "manual",
  337. tenant_no: str = "",
  338. user_id: Optional[str] = None,
  339. remark: Optional[str] = None,
  340. ) -> BizStorageSlotOccupancy:
  341. """创建入库记录 + 刷新 slot 缓存"""
  342. slot_id = (slot_id or "").strip()
  343. if not slot_id:
  344. raise ValueError("储物格不能为空")
  345. assert_slot_vacant(db, slot_id, tenant_no=tenant_no)
  346. obj = _add_stored_occupancy_no_commit(
  347. db,
  348. slot_id=slot_id,
  349. item_type=item_type,
  350. item_id=item_id,
  351. item_code=item_code,
  352. item_name=item_name,
  353. item_source=item_source,
  354. tenant_no=tenant_no,
  355. user_id=user_id,
  356. remark=remark,
  357. )
  358. db.commit()
  359. db.refresh(obj)
  360. return obj
  361. def batch_create_occupancies(
  362. db: Session,
  363. *,
  364. items: List[Dict[str, Any]],
  365. tenant_no: str = "",
  366. user_id: Optional[str] = None,
  367. ) -> List[BizStorageSlotOccupancy]:
  368. """批量入库"""
  369. slot_ids = [(it.get("slot_id") or "").strip() for it in items]
  370. assert_batch_slots_unique(slot_ids)
  371. for sid in slot_ids:
  372. if sid:
  373. assert_slot_vacant(db, sid, tenant_no=tenant_no)
  374. created: List[BizStorageSlotOccupancy] = []
  375. slot_ids_seen: set = set()
  376. for it in items:
  377. sid = (it.get("slot_id") or "").strip()
  378. obj = _add_stored_occupancy_no_commit(
  379. db,
  380. slot_id=sid,
  381. item_type=it.get("item_type", "cell_product"),
  382. item_id=it["item_id"],
  383. item_code=it.get("item_code") or "",
  384. item_name=it.get("item_name") or "",
  385. item_source=it.get("item_source") or "production",
  386. tenant_no=tenant_no,
  387. user_id=user_id,
  388. remark=it.get("remark"),
  389. )
  390. created.append(obj)
  391. slot_ids_seen.add(sid)
  392. db.commit()
  393. for obj in created:
  394. db.refresh(obj)
  395. return created
  396. def remove_occupancy(
  397. db: Session,
  398. occupancy_id: str,
  399. *,
  400. user_id: Optional[str] = None,
  401. ) -> BizStorageSlotOccupancy:
  402. """手动出库"""
  403. obj = db.query(BizStorageSlotOccupancy).filter(
  404. BizStorageSlotOccupancy.id == occupancy_id,
  405. BizStorageSlotOccupancy.del_flag == 0,
  406. ).first()
  407. if not obj:
  408. raise ValueError("占用记录不存在")
  409. if obj.occupancy_status == "removed":
  410. raise ValueError("该物品已出库")
  411. now = datetime.now()
  412. obj.occupancy_status = "removed"
  413. obj.removed_at = now
  414. obj.update_by = user_id
  415. obj.update_time = now
  416. db.add(obj)
  417. db.flush()
  418. _refresh_slot_cache(db, obj.slot_id)
  419. db.commit()
  420. db.refresh(obj)
  421. return obj
  422. def remove_by_item(
  423. db: Session,
  424. item_type: str,
  425. item_id: str,
  426. *,
  427. user_id: Optional[str] = None,
  428. ) -> None:
  429. """按物品类型+ID出库(细胞产品状态变更时调用)"""
  430. remove_by_item_no_commit(db, item_type, item_id, user_id=user_id)
  431. db.commit()
  432. def list_occupancies(
  433. db: Session,
  434. *,
  435. slot_id: str,
  436. occupancy_status: Optional[str] = "stored",
  437. item_type: Optional[str] = None,
  438. tenant_no: str = "",
  439. page: int = 1,
  440. page_size: int = 20,
  441. ) -> Dict[str, Any]:
  442. """分页查询占用记录"""
  443. q = db.query(BizStorageSlotOccupancy).filter(
  444. BizStorageSlotOccupancy.slot_id == slot_id,
  445. BizStorageSlotOccupancy.del_flag == 0,
  446. )
  447. if tenant_no:
  448. q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
  449. if occupancy_status:
  450. q = q.filter(BizStorageSlotOccupancy.occupancy_status == occupancy_status)
  451. if item_type:
  452. q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
  453. total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
  454. page = max(1, page)
  455. page_size = min(max(1, page_size), 100)
  456. rows = (
  457. q.order_by(BizStorageSlotOccupancy.stored_at.desc())
  458. .offset((page - 1) * page_size)
  459. .limit(page_size)
  460. .all()
  461. )
  462. items = enrich_occupancy_items(db, tenant_no, [_row_to_dict(r) for r in rows])
  463. return {"items": items, "total": int(total)}
  464. def list_occupancy_history(
  465. db: Session,
  466. *,
  467. slot_id: Optional[str] = None,
  468. facility_node_id: Optional[str] = None,
  469. item_type: Optional[str] = None,
  470. start_date: Optional[str] = None,
  471. end_date: Optional[str] = None,
  472. tenant_no: str = "",
  473. page: int = 1,
  474. page_size: int = 20,
  475. ) -> Dict[str, Any]:
  476. """出入库历史"""
  477. q = db.query(BizStorageSlotOccupancy).filter(
  478. BizStorageSlotOccupancy.del_flag == 0,
  479. )
  480. if tenant_no:
  481. q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
  482. if slot_id:
  483. q = q.filter(BizStorageSlotOccupancy.slot_id == slot_id)
  484. if item_type:
  485. q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
  486. if start_date:
  487. try:
  488. sd = datetime.strptime(start_date, "%Y-%m-%d")
  489. q = q.filter(BizStorageSlotOccupancy.stored_at >= sd)
  490. except ValueError:
  491. pass
  492. if end_date:
  493. try:
  494. ed = datetime.strptime(end_date, "%Y-%m-%d")
  495. ed = ed.replace(hour=23, minute=59, second=59)
  496. q = q.filter(BizStorageSlotOccupancy.stored_at <= ed)
  497. except ValueError:
  498. pass
  499. if facility_node_id:
  500. slot_ids = [
  501. r[0] for r in db.query(BizStorageFacilitySlot.id).filter(
  502. BizStorageFacilitySlot.facility_node_id == facility_node_id,
  503. BizStorageFacilitySlot.del_flag == 0,
  504. ).all()
  505. ]
  506. if slot_ids:
  507. q = q.filter(BizStorageSlotOccupancy.slot_id.in_(slot_ids))
  508. else:
  509. return {"items": [], "total": 0}
  510. total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
  511. page = max(1, page)
  512. page_size = min(max(1, page_size), 100)
  513. rows = (
  514. q.order_by(BizStorageSlotOccupancy.create_time.desc())
  515. .offset((page - 1) * page_size)
  516. .limit(page_size)
  517. .all()
  518. )
  519. items = enrich_occupancy_items(db, tenant_no, [_row_to_dict(r) for r in rows])
  520. return {"items": items, "total": int(total)}