cell_product.py 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685
  1. """细胞产品 CRUD(biz_cell_product)"""
  2. from __future__ import annotations
  3. import math
  4. import re
  5. from datetime import datetime
  6. from typing import Any, Dict, List, Optional, Tuple
  7. from sqlalchemy import and_, func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import generate_uuid
  10. from app.utils.sample_special import sample_special_to_dict
  11. from app.crud import storage_occupancy as occupancy_svc
  12. from app.models.models import BizStorageSlotOccupancy
  13. from app.models.models import (
  14. BizCellProduct,
  15. BizCellProductSpec,
  16. BizProductionProductType,
  17. BizStorageFacility,
  18. BizStorageFacilitySlot,
  19. )
  20. from app.services.storage_facility_code import build_full_slot_code
  21. _CELL_PRODUCT_STATUSES = frozenset(
  22. {"frozen", "in_production", "sold", "used", "voided", "thawed"}
  23. )
  24. _CELL_PRODUCT_RELEASE_STATUSES = frozenset({"sold", "used", "voided"})
  25. _CELL_PRODUCT_STORE_STATUSES = frozenset({"frozen", "thawed", "in_production"})
  26. def _apply_cell_product_status_change(
  27. db: Session,
  28. obj: BizCellProduct,
  29. to_status: str,
  30. tenant_no: str,
  31. user_id: Optional[str],
  32. now: datetime,
  33. ) -> None:
  34. """管理端手动改状态:允许在合法枚举间任意变更,并同步库位占用。"""
  35. from_status = (obj.product_status or "").strip()
  36. to_status = (to_status or "").strip()
  37. if to_status == from_status:
  38. return
  39. if to_status not in _CELL_PRODUCT_STATUSES:
  40. raise ValueError(
  41. f"状态必须为 {'/'.join(sorted(_CELL_PRODUCT_STATUSES))}"
  42. )
  43. obj.product_status = to_status
  44. slot_id = (obj.storage_slot_id or "").strip()
  45. if to_status in _CELL_PRODUCT_RELEASE_STATUSES:
  46. if slot_id:
  47. try:
  48. occupancy_svc.remove_by_item(
  49. db, "cell_product", obj.id, user_id=user_id,
  50. )
  51. except Exception:
  52. pass
  53. return
  54. if to_status in _CELL_PRODUCT_STORE_STATUSES and slot_id:
  55. existing = db.query(BizStorageSlotOccupancy).filter(
  56. BizStorageSlotOccupancy.item_type == "cell_product",
  57. BizStorageSlotOccupancy.item_id == obj.id,
  58. BizStorageSlotOccupancy.occupancy_status == "stored",
  59. BizStorageSlotOccupancy.del_flag == 0,
  60. ).first()
  61. if existing:
  62. return
  63. occupancy_svc.assert_slot_vacant(
  64. db, slot_id, tenant_no=tenant_no, exclude_product_id=obj.id,
  65. )
  66. occupancy_svc._add_stored_occupancy_no_commit(
  67. db,
  68. slot_id=slot_id,
  69. item_type="cell_product",
  70. item_id=obj.id,
  71. item_code=obj.product_code or "",
  72. item_name=obj.product_name or "",
  73. item_source=obj.source_type or "manual",
  74. tenant_no=tenant_no,
  75. user_id=user_id,
  76. create_dept_id=obj.create_dept_id or "",
  77. stored_at=now,
  78. )
  79. def _row_to_dict(c: BizCellProduct) -> Dict[str, Any]:
  80. return {
  81. "id": c.id,
  82. "productCode": c.product_code,
  83. "productName": c.product_name,
  84. "productTypeId": c.product_type_id or "",
  85. "productTypeName": "",
  86. "specId": c.spec_id or "",
  87. "specText": c.spec_text or "",
  88. "specName": c.spec_text or "",
  89. "batchNo": c.batch_no or "",
  90. "storageSlotId": c.storage_slot_id or "",
  91. "storageSlotName": "",
  92. "passage": c.passage or "",
  93. "freezeDate": c.freeze_date.strftime("%Y-%m-%d %H:%M:%S") if c.freeze_date else "",
  94. "expectedCellCount": float(c.expected_cell_count) if c.expected_cell_count is not None else None,
  95. "viability": float(c.viability) if c.viability is not None else None,
  96. "productStatus": c.product_status or "",
  97. "sourceType": c.source_type or "",
  98. "productionTaskId": c.production_task_id or "",
  99. "quantity": float(c.quantity) if c.quantity is not None else None,
  100. "unit": c.unit or "",
  101. "expiryDate": c.expiry_date.strftime("%Y-%m-%d %H:%M:%S") if c.expiry_date else "",
  102. "productImages": c.product_images or "",
  103. "tenantNo": c.tenant_no,
  104. "orderNum": c.order_num if c.order_num is not None else 0,
  105. "remark": c.remark or "",
  106. "createBy": c.create_by or "",
  107. "createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
  108. "updateBy": c.update_by or "",
  109. "updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
  110. }
  111. def _build_facility_path_label(node_id: str, by_id: Dict[str, BizStorageFacility]) -> str:
  112. parts: List[str] = []
  113. cur_id = (node_id or "").strip()
  114. visited: set[str] = set()
  115. while cur_id and cur_id != "0" and cur_id not in visited:
  116. visited.add(cur_id)
  117. node = by_id.get(cur_id)
  118. if not node:
  119. break
  120. parts.append(node.node_name or "")
  121. cur_id = (node.parent_id or "").strip()
  122. parts.reverse()
  123. return " / ".join(p for p in parts if p)
  124. def _enrich_product_dicts(
  125. db: Session,
  126. tenant_no: str,
  127. items: List[Dict[str, Any]],
  128. ) -> List[Dict[str, Any]]:
  129. if not items:
  130. return items
  131. type_ids = {i.get("productTypeId") for i in items if i.get("productTypeId")}
  132. spec_ids = {i.get("specId") for i in items if i.get("specId")}
  133. slot_ids = {i.get("storageSlotId") for i in items if i.get("storageSlotId")}
  134. type_map: Dict[str, str] = {}
  135. if type_ids:
  136. rows = (
  137. db.query(BizProductionProductType)
  138. .filter(
  139. BizProductionProductType.id.in_(type_ids),
  140. BizProductionProductType.del_flag == 0,
  141. )
  142. .all()
  143. )
  144. type_map = {r.id: r.product_type_name or "" for r in rows}
  145. spec_map: Dict[str, str] = {}
  146. if spec_ids:
  147. rows = (
  148. db.query(BizCellProductSpec)
  149. .filter(
  150. BizCellProductSpec.id.in_(spec_ids),
  151. BizCellProductSpec.del_flag == 0,
  152. )
  153. .all()
  154. )
  155. spec_map = {r.id: r.spec_name or "" for r in rows}
  156. slot_map: Dict[str, str] = {}
  157. if slot_ids:
  158. slots = (
  159. db.query(BizStorageFacilitySlot)
  160. .filter(
  161. BizStorageFacilitySlot.id.in_(slot_ids),
  162. BizStorageFacilitySlot.del_flag == 0,
  163. )
  164. .all()
  165. )
  166. facilities = (
  167. db.query(BizStorageFacility)
  168. .filter(
  169. BizStorageFacility.tenant_no == tenant_no,
  170. BizStorageFacility.del_flag == 0,
  171. )
  172. .all()
  173. )
  174. facility_by_id = {f.id: f for f in facilities}
  175. for slot in slots:
  176. slot_map[slot.id] = build_full_slot_code(
  177. slot.facility_node_id,
  178. slot.slot_code,
  179. facility_by_id,
  180. )
  181. # --- 来源信息富化 ---
  182. from app.models.models import BizProductionTask, BizProductionWorkOrder
  183. task_ids = {i.get("productionTaskId") for i in items if i.get("productionTaskId")}
  184. task_map: Dict[str, Any] = {}
  185. task_to_wo: Dict[str, str] = {}
  186. wo_map: Dict[str, Any] = {}
  187. if task_ids:
  188. tasks = (
  189. db.query(BizProductionTask)
  190. .filter(
  191. BizProductionTask.id.in_(task_ids),
  192. BizProductionTask.del_flag == 0,
  193. )
  194. .all()
  195. )
  196. for t in tasks:
  197. task_map[t.id] = t
  198. if t.work_order_id:
  199. task_to_wo[t.id] = t.work_order_id
  200. wo_ids = set(task_to_wo.values())
  201. if wo_ids:
  202. wos = (
  203. db.query(BizProductionWorkOrder)
  204. .filter(
  205. BizProductionWorkOrder.id.in_(wo_ids),
  206. BizProductionWorkOrder.del_flag == 0,
  207. )
  208. .all()
  209. )
  210. wo_map = {w.id: w for w in wos}
  211. source_type_labels = {"production": "生产入库", "manual": "手动录入"}
  212. for item in items:
  213. tid = item.get("productTypeId") or ""
  214. sid = item.get("specId") or ""
  215. slot_id = item.get("storageSlotId") or ""
  216. item["productTypeName"] = type_map.get(tid, "") if tid else ""
  217. stored_spec = (item.get("specText") or item.get("spec_text") or "").strip()
  218. if stored_spec:
  219. item["specName"] = stored_spec
  220. elif sid:
  221. item["specName"] = spec_map.get(sid, "")
  222. else:
  223. item["specName"] = (item.get("specName") or "").strip()
  224. item["storageSlotName"] = slot_map.get(slot_id, "") if slot_id else ""
  225. # 来源信息
  226. src_type = item.get("sourceType", "")
  227. item["sourceTypeLabel"] = source_type_labels.get(src_type, src_type or "")
  228. tid = item.get("productionTaskId") or ""
  229. if tid and tid in task_map:
  230. t_obj = task_map[tid]
  231. item["sourceTaskNo"] = t_obj.task_no or ""
  232. wo_id = task_to_wo.get(tid)
  233. if wo_id and wo_id in wo_map:
  234. wo_obj = wo_map[wo_id]
  235. item["sourceWorkOrderNo"] = wo_obj.work_order_no or ""
  236. item["sourceProtocolNo"] = wo_obj.protocol_no or ""
  237. item["sourceReceptionSource"] = wo_obj.reception_source or ""
  238. else:
  239. item["sourceWorkOrderNo"] = ""
  240. item["sourceProtocolNo"] = ""
  241. item["sourceReceptionSource"] = ""
  242. else:
  243. item["sourceTaskNo"] = ""
  244. item["sourceWorkOrderNo"] = ""
  245. item["sourceProtocolNo"] = ""
  246. item["sourceReceptionSource"] = ""
  247. return items
  248. def product_to_response(db: Session, tenant_no: str, obj: BizCellProduct) -> Dict[str, Any]:
  249. enriched = _enrich_product_dicts(db, tenant_no, [_row_to_dict(obj)])
  250. return enriched[0] if enriched else _row_to_dict(obj)
  251. def list_cell_products(
  252. db: Session,
  253. *,
  254. tenant_no: str,
  255. page: int = 1,
  256. page_size: int = 20,
  257. keyword: Optional[str] = None,
  258. product_code: Optional[str] = None,
  259. product_name: Optional[str] = None,
  260. product_type_id: Optional[str] = None,
  261. product_status: Optional[str] = None,
  262. passage: Optional[str] = None,
  263. source_type: Optional[str] = None,
  264. user_info=None,
  265. ) -> Dict[str, Any]:
  266. def _filtered():
  267. q0 = db.query(BizCellProduct).filter(
  268. BizCellProduct.del_flag == 0,
  269. BizCellProduct.tenant_no == tenant_no,
  270. )
  271. kw = (keyword or "").strip()
  272. if kw:
  273. like = f"%{kw}%"
  274. q0 = q0.filter(
  275. or_(
  276. BizCellProduct.product_code.ilike(like),
  277. BizCellProduct.product_name.ilike(like),
  278. )
  279. )
  280. pc = (product_code or "").strip()
  281. if pc:
  282. q0 = q0.filter(BizCellProduct.product_code.like(f"%{pc}%"))
  283. pn = (product_name or "").strip()
  284. if pn:
  285. q0 = q0.filter(BizCellProduct.product_name.like(f"%{pn}%"))
  286. if product_type_id:
  287. q0 = q0.filter(BizCellProduct.product_type_id == product_type_id)
  288. if product_status:
  289. q0 = q0.filter(BizCellProduct.product_status == product_status)
  290. if passage:
  291. q0 = q0.filter(BizCellProduct.passage == passage)
  292. if source_type:
  293. q0 = q0.filter(BizCellProduct.source_type == source_type)
  294. if user_info:
  295. from app.core.data_scope import apply_data_scope
  296. q0 = apply_data_scope(q0, BizCellProduct, user_info, db, tenant_no)
  297. return q0
  298. total = _filtered().order_by(None).with_entities(func.count(BizCellProduct.id)).scalar() or 0
  299. page = max(1, page)
  300. page_size = min(max(1, page_size), 100)
  301. rows = (
  302. _filtered()
  303. .order_by(
  304. BizCellProduct.create_time.desc(),
  305. BizCellProduct.product_code.asc(),
  306. )
  307. .offset((page - 1) * page_size)
  308. .limit(page_size)
  309. .all()
  310. )
  311. items = _enrich_product_dicts(
  312. db, tenant_no, [_row_to_dict(x) for x in rows]
  313. )
  314. return {"items": items, "total": int(total)}
  315. def get_cell_product(db: Session, id: str, tenant_no: str) -> Optional[BizCellProduct]:
  316. return (
  317. db.query(BizCellProduct)
  318. .filter(
  319. and_(
  320. BizCellProduct.id == id,
  321. BizCellProduct.tenant_no == tenant_no,
  322. BizCellProduct.del_flag == 0,
  323. )
  324. )
  325. .first()
  326. )
  327. def create_cell_product(
  328. db: Session,
  329. *,
  330. tenant_no: str,
  331. user_id: Optional[str] = None,
  332. create_dept_id: Optional[str] = None,
  333. data: Any = None,
  334. ) -> BizCellProduct:
  335. code = (data.product_code or "").strip()
  336. if not code:
  337. raise ValueError("产品编码不能为空")
  338. existing = db.query(BizCellProduct).filter(
  339. BizCellProduct.tenant_no == tenant_no,
  340. BizCellProduct.product_code == code,
  341. BizCellProduct.del_flag == 0,
  342. ).first()
  343. if existing:
  344. raise ValueError(f"产品编码 {code} 已存在")
  345. obj = BizCellProduct(
  346. id=generate_uuid(),
  347. product_code=code,
  348. product_name=(data.product_name or "").strip(),
  349. product_type_id=(data.product_type_id or "").strip() or None,
  350. spec_id=(data.spec_id or "").strip() or None,
  351. spec_text=(getattr(data, "spec_name", None) or "").strip() or None,
  352. batch_no=(data.batch_no or "").strip() or None,
  353. storage_slot_id=(data.storage_slot_id or "").strip() or None,
  354. passage=((data.passage or "").strip() or None) if getattr(data, "passage", None) else None,
  355. freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
  356. expected_cell_count=data.expected_cell_count if hasattr(data, "expected_cell_count") else None,
  357. viability=data.viability if hasattr(data, "viability") else None,
  358. product_status=data.product_status or "frozen",
  359. source_type=data.source_type or "manual",
  360. quantity=data.quantity,
  361. unit=(data.unit or "").strip() or None,
  362. expiry_date=data.expiry_date,
  363. product_images=(data.product_images or "").strip() or None,
  364. tenant_no=tenant_no,
  365. order_num=0,
  366. remark=(data.remark or "").strip() or None,
  367. create_by=user_id,
  368. create_dept_id=create_dept_id or "",
  369. create_time=datetime.now(),
  370. update_by=user_id,
  371. update_time=datetime.now(),
  372. del_flag=0,
  373. )
  374. db.add(obj)
  375. db.flush()
  376. slot_id = (obj.storage_slot_id or "").strip()
  377. if slot_id and (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES:
  378. occupancy_svc.assert_slot_vacant(db, slot_id, tenant_no=tenant_no, exclude_product_id=obj.id)
  379. occupancy_svc._add_stored_occupancy_no_commit(
  380. db,
  381. slot_id=slot_id,
  382. item_type="cell_product",
  383. item_id=obj.id,
  384. item_code=obj.product_code or "",
  385. item_name=obj.product_name or "",
  386. item_source=obj.source_type or "manual",
  387. tenant_no=tenant_no,
  388. user_id=user_id,
  389. create_dept_id=create_dept_id or "",
  390. )
  391. db.commit()
  392. db.refresh(obj)
  393. return obj
  394. def update_cell_product(
  395. db: Session,
  396. id: str,
  397. tenant_no: str,
  398. user_id: Optional[str] = None,
  399. data: Any = None,
  400. ) -> BizCellProduct:
  401. obj = get_cell_product(db, id, tenant_no)
  402. if not obj:
  403. raise ValueError("细胞产品不存在")
  404. old_slot_id = (obj.storage_slot_id or "").strip()
  405. slot_change_requested = data.storage_slot_id is not None
  406. if getattr(data, "product_code", None) is not None:
  407. code = (data.product_code or "").strip()
  408. if code and code != (obj.product_code or "").strip():
  409. if product_code_exists(db, tenant_no, code, exclude_id=obj.id):
  410. raise ValueError(f"产品编码 {code} 已存在")
  411. obj.product_code = code
  412. if data.product_name is not None:
  413. obj.product_name = (data.product_name or "").strip()
  414. if data.product_type_id is not None:
  415. obj.product_type_id = (data.product_type_id or "").strip() or None
  416. if data.spec_id is not None:
  417. obj.spec_id = (data.spec_id or "").strip() or None
  418. if getattr(data, "spec_name", None) is not None:
  419. obj.spec_text = (data.spec_name or "").strip() or None
  420. if data.batch_no is not None:
  421. obj.batch_no = (data.batch_no or "").strip() or None
  422. if data.storage_slot_id is not None:
  423. obj.storage_slot_id = (data.storage_slot_id or "").strip() or None
  424. if hasattr(data, "passage") and data.passage is not None:
  425. obj.passage = (data.passage or "").strip() or None
  426. if hasattr(data, "freeze_date") and data.freeze_date is not None:
  427. obj.freeze_date = data.freeze_date
  428. if hasattr(data, "expected_cell_count") and data.expected_cell_count is not None:
  429. obj.expected_cell_count = data.expected_cell_count
  430. if hasattr(data, "viability") and data.viability is not None:
  431. obj.viability = data.viability
  432. if data.product_status is not None and data.product_status != obj.product_status:
  433. _apply_cell_product_status_change(
  434. db,
  435. obj,
  436. data.product_status,
  437. tenant_no,
  438. user_id,
  439. datetime.now(),
  440. )
  441. if data.quantity is not None:
  442. obj.quantity = data.quantity
  443. if data.unit is not None:
  444. obj.unit = (data.unit or "").strip() or None
  445. if data.expiry_date is not None:
  446. obj.expiry_date = data.expiry_date
  447. if data.product_images is not None:
  448. obj.product_images = (data.product_images or "").strip() or None
  449. if data.remark is not None:
  450. obj.remark = (data.remark or "").strip() or None
  451. new_slot_id = (obj.storage_slot_id or "").strip()
  452. in_store = (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES
  453. if slot_change_requested and old_slot_id != new_slot_id:
  454. stored_occ = db.query(BizStorageSlotOccupancy).filter(
  455. BizStorageSlotOccupancy.item_type == "cell_product",
  456. BizStorageSlotOccupancy.item_id == obj.id,
  457. BizStorageSlotOccupancy.occupancy_status == "stored",
  458. BizStorageSlotOccupancy.del_flag == 0,
  459. ).first()
  460. if stored_occ and (stored_occ.slot_id or "").strip() != new_slot_id:
  461. occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
  462. stored_occ = None
  463. if new_slot_id and in_store and not stored_occ:
  464. occupancy_svc.assert_slot_vacant(
  465. db, new_slot_id, tenant_no=tenant_no, exclude_product_id=obj.id,
  466. )
  467. occupancy_svc._add_stored_occupancy_no_commit(
  468. db,
  469. slot_id=new_slot_id,
  470. item_type="cell_product",
  471. item_id=obj.id,
  472. item_code=obj.product_code or "",
  473. item_name=obj.product_name or "",
  474. item_source=obj.source_type or "manual",
  475. tenant_no=tenant_no,
  476. user_id=user_id,
  477. create_dept_id=obj.create_dept_id or "",
  478. )
  479. elif not new_slot_id and stored_occ:
  480. occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
  481. obj.update_by = user_id
  482. obj.update_time = datetime.now()
  483. db.add(obj)
  484. db.commit()
  485. db.refresh(obj)
  486. return obj
  487. def delete_cell_product(
  488. db: Session,
  489. id: str,
  490. tenant_no: str,
  491. user_id: Optional[str] = None,
  492. ) -> None:
  493. obj = get_cell_product(db, id, tenant_no)
  494. if not obj:
  495. raise ValueError("细胞产品不存在")
  496. occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
  497. obj.del_flag = 1
  498. obj.update_by = user_id
  499. obj.update_time = datetime.now()
  500. db.add(obj)
  501. db.commit()
  502. def _next_code_start_seq(db: Session, tenant_no: str, prefix: str) -> int:
  503. last = (
  504. db.query(BizCellProduct)
  505. .filter(
  506. BizCellProduct.tenant_no == tenant_no,
  507. BizCellProduct.product_code.like(f"{prefix}%"),
  508. BizCellProduct.del_flag == 0,
  509. )
  510. .order_by(BizCellProduct.product_code.desc())
  511. .first()
  512. )
  513. seq = 1
  514. if last and last.product_code:
  515. try:
  516. base = normalize_product_code_base(last.product_code)
  517. seq = int(base[-6:]) + 1
  518. except (ValueError, IndexError):
  519. seq = 1
  520. return seq
  521. def get_next_code(db: Session, tenant_no: str) -> str:
  522. today_str = datetime.now().strftime("%Y%m%d")
  523. prefix = f"CP{today_str}"
  524. seq = _next_code_start_seq(db, tenant_no, prefix)
  525. return f"{prefix}{seq:06d}"
  526. _PRODUCT_CODE_SUFFIX_RE = re.compile(r"^(.+)-(\d{2})$")
  527. _PASSAGE_PRODUCT_CODE_RE = re.compile(r"^(.+)-(P\d+)-(\d{2})$", re.IGNORECASE)
  528. def normalize_passage_product_base(code: str) -> str:
  529. """去掉末尾 -P{n}-XX 或 -XX,得到代次产品编码的基础前缀。"""
  530. c = (code or "").strip()
  531. if not c:
  532. return ""
  533. m = _PASSAGE_PRODUCT_CODE_RE.match(c)
  534. if m:
  535. return m.group(1).strip()
  536. return normalize_product_code_base(c)
  537. def get_max_passage_product_seq(
  538. db: Session,
  539. tenant_no: str,
  540. base_code: str,
  541. passage: str,
  542. ) -> int:
  543. """查询 `{base_code}-{passage}-XX` 形式产品编码在库中的最大两位序号(无匹配则 0)。"""
  544. base = normalize_passage_product_base(base_code)
  545. passage = (passage or "").strip()
  546. if not base or not passage:
  547. return 0
  548. prefix = f"{base}-{passage}-"
  549. suffix_re = re.compile(rf"^{re.escape(prefix)}(\d{{2}})$")
  550. rows = (
  551. db.query(BizCellProduct.product_code)
  552. .filter(
  553. BizCellProduct.tenant_no == tenant_no,
  554. BizCellProduct.product_code.like(f"{prefix}%"),
  555. BizCellProduct.del_flag == 0,
  556. )
  557. .all()
  558. )
  559. max_seq = 0
  560. for (code,) in rows:
  561. m = suffix_re.match((code or "").strip())
  562. if not m:
  563. continue
  564. try:
  565. max_seq = max(max_seq, int(m.group(1)))
  566. except ValueError:
  567. continue
  568. return max_seq
  569. def get_max_passage_product_seq_map(
  570. db: Session,
  571. tenant_no: str,
  572. base_code: str,
  573. passages: List[str],
  574. ) -> Dict[str, int]:
  575. """批量查询各代次在 `{base_code}-{passage}-XX` 规则下的最大序号。"""
  576. result: Dict[str, int] = {}
  577. seen: set[str] = set()
  578. for raw in passages or []:
  579. passage = (raw or "").strip()
  580. if not passage or passage in seen:
  581. continue
  582. seen.add(passage)
  583. result[passage] = get_max_passage_product_seq(db, tenant_no, base_code, passage)
  584. return result
  585. def get_max_product_suffix_seq(
  586. db: Session,
  587. tenant_no: str,
  588. base_code: str,
  589. ) -> int:
  590. """查询 `{base_code}-XX` 形式产品编码在库中的最大两位序号(无匹配则 0)。"""
  591. base = normalize_product_code_base((base_code or "").strip())
  592. if not base:
  593. return 0
  594. prefix = f"{base}-"
  595. suffix_re = re.compile(rf"^{re.escape(base)}-(\d{{2}})$")
  596. rows = (
  597. db.query(BizCellProduct.product_code)
  598. .filter(
  599. BizCellProduct.tenant_no == tenant_no,
  600. BizCellProduct.product_code.like(f"{prefix}%"),
  601. BizCellProduct.del_flag == 0,
  602. )
  603. .all()
  604. )
  605. max_seq = 0
  606. for (code,) in rows:
  607. m = suffix_re.match((code or "").strip())
  608. if not m:
  609. continue
  610. try:
  611. max_seq = max(max_seq, int(m.group(1)))
  612. except ValueError:
  613. continue
  614. return max_seq
  615. def product_code_exists(
  616. db: Session,
  617. tenant_no: str,
  618. product_code: str,
  619. exclude_id: Optional[str] = None,
  620. ) -> bool:
  621. code = (product_code or "").strip()
  622. if not code:
  623. return False
  624. q = db.query(BizCellProduct.id).filter(
  625. BizCellProduct.tenant_no == tenant_no,
  626. BizCellProduct.product_code == code,
  627. BizCellProduct.del_flag == 0,
  628. )
  629. if exclude_id:
  630. q = q.filter(BizCellProduct.id != exclude_id)
  631. return q.first() is not None
  632. def normalize_product_code_base(code: str) -> str:
  633. """去掉末尾 -01/-02 等序号,得到多份入库时的基础编码。"""
  634. c = (code or "").strip()
  635. if not c:
  636. return ""
  637. m = _PRODUCT_CODE_SUFFIX_RE.match(c)
  638. if m:
  639. return m.group(1).strip()
  640. return c
  641. def build_split_product_codes(base: str, count: int) -> List[str]:
  642. """按基础编码生成 基础-01、基础-02 …"""
  643. if count < 1:
  644. return []
  645. return [f"{base}-{i:02d}" for i in range(1, count + 1)]
  646. def reserve_next_codes(db: Session, tenant_no: str, count: int) -> List[str]:
  647. if count < 1:
  648. return []
  649. count = min(count, 50)
  650. today_str = datetime.now().strftime("%Y%m%d")
  651. prefix = f"CP{today_str}"
  652. start = _next_code_start_seq(db, tenant_no, prefix)
  653. return [f"{prefix}{start + i:06d}" for i in range(count)]
  654. def _validate_batch_items(
  655. db: Session,
  656. tenant_no: str,
  657. items: List[Any],
  658. *,
  659. require_storage_slot: bool = True,
  660. ) -> Tuple[List[str], List[Optional[str]]]:
  661. if not items:
  662. raise ValueError("至少需要一份产品明细")
  663. if len(items) > 50:
  664. raise ValueError("单次入库份数不能超过 50")
  665. codes: List[str] = []
  666. slot_ids: List[Optional[str]] = []
  667. for it in items:
  668. code = (getattr(it, "product_code", None) or "").strip()
  669. if not code:
  670. raise ValueError("产品编码不能为空")
  671. if code in codes:
  672. raise ValueError(f"产品编码 {code} 重复")
  673. codes.append(code)
  674. sid = (getattr(it, "storage_slot_id", None) or "").strip() or None
  675. if require_storage_slot and not sid:
  676. raise ValueError("每份产品须选择储物格位置")
  677. slot_ids.append(sid)
  678. occupancy_svc.assert_batch_slots_unique(slot_ids)
  679. existing_codes = (
  680. db.query(BizCellProduct.product_code)
  681. .filter(
  682. BizCellProduct.tenant_no == tenant_no,
  683. BizCellProduct.product_code.in_(codes),
  684. BizCellProduct.del_flag == 0,
  685. )
  686. .all()
  687. )
  688. if existing_codes:
  689. dup = existing_codes[0][0]
  690. raise ValueError(f"产品编码 {dup} 已存在")
  691. return codes, slot_ids
  692. def create_cell_products_by_quantity(
  693. db: Session,
  694. *,
  695. tenant_no: str,
  696. user_id: Optional[str] = None,
  697. create_dept_id: Optional[str] = None,
  698. data: Any = None,
  699. count: int,
  700. ) -> List[BizCellProduct]:
  701. """按入库份数拆分:基础编码-01、基础编码-02 …(须通过 items 为每份指定独立储物格)"""
  702. count = max(1, min(int(count), 50))
  703. if count > 1:
  704. raise ValueError("入库多份时请通过入库明细为每份产品分别选择储物格")
  705. base = normalize_product_code_base(getattr(data, "product_code", None) or "")
  706. if not base:
  707. raise ValueError("产品编码不能为空")
  708. codes = build_split_product_codes(base, count)
  709. slot_id = (getattr(data, "storage_slot_id", None) or "").strip() or None
  710. from types import SimpleNamespace
  711. items = [
  712. SimpleNamespace(product_code=c, storage_slot_id=slot_id) for c in codes
  713. ]
  714. return create_cell_products_batch(
  715. db,
  716. tenant_no=tenant_no,
  717. user_id=user_id,
  718. create_dept_id=create_dept_id,
  719. data=data,
  720. items=items,
  721. require_storage_slot=False,
  722. )
  723. def create_cell_products_batch(
  724. db: Session,
  725. *,
  726. tenant_no: str,
  727. user_id: Optional[str] = None,
  728. create_dept_id: Optional[str] = None,
  729. data: Any = None,
  730. items: List[Any],
  731. require_storage_slot: bool = True,
  732. ) -> List[BizCellProduct]:
  733. codes, slot_ids = _validate_batch_items(
  734. db,
  735. tenant_no,
  736. items,
  737. require_storage_slot=require_storage_slot,
  738. )
  739. for slot_id in slot_ids:
  740. if slot_id:
  741. occupancy_svc.assert_slot_vacant(db, slot_id, tenant_no=tenant_no)
  742. now = datetime.now()
  743. created: List[BizCellProduct] = []
  744. for it, slot_id in zip(items, slot_ids):
  745. code = (getattr(it, "product_code", None) or "").strip()
  746. item_passage = (getattr(it, "passage", None) or "").strip() or None
  747. if not item_passage and getattr(data, "passage", None):
  748. item_passage = (data.passage or "").strip() or None
  749. item_spec = (getattr(it, "spec_name", None) or "").strip() or None
  750. if not item_spec and getattr(data, "spec_name", None):
  751. item_spec = (data.spec_name or "").strip() or None
  752. item_expected = getattr(it, "expected_cell_count", None)
  753. if item_expected is None:
  754. item_expected = data.expected_cell_count if hasattr(data, "expected_cell_count") else None
  755. item_viability = getattr(it, "viability", None)
  756. if item_viability is None:
  757. item_viability = data.viability if hasattr(data, "viability") else None
  758. obj = BizCellProduct(
  759. id=generate_uuid(),
  760. product_code=code,
  761. product_name=(data.product_name or "").strip(),
  762. product_type_id=(data.product_type_id or "").strip() or None,
  763. spec_id=(data.spec_id or "").strip() or None,
  764. spec_text=item_spec,
  765. batch_no=(data.batch_no or "").strip() or None,
  766. storage_slot_id=slot_id,
  767. passage=item_passage,
  768. freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
  769. expected_cell_count=item_expected,
  770. viability=item_viability,
  771. product_status=data.product_status or "frozen",
  772. source_type=data.source_type or "manual",
  773. quantity=1,
  774. unit=(data.unit or "").strip() or None,
  775. expiry_date=data.expiry_date,
  776. product_images=(data.product_images or "").strip() or None,
  777. tenant_no=tenant_no,
  778. order_num=0,
  779. remark=(data.remark or "").strip() or None,
  780. create_by=user_id,
  781. create_dept_id=create_dept_id or "",
  782. create_time=now,
  783. update_by=user_id,
  784. update_time=now,
  785. del_flag=0,
  786. )
  787. db.add(obj)
  788. created.append(obj)
  789. db.flush()
  790. for obj in created:
  791. slot_id = (obj.storage_slot_id or "").strip()
  792. if slot_id and (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES:
  793. occupancy_svc._add_stored_occupancy_no_commit(
  794. db,
  795. slot_id=slot_id,
  796. item_type="cell_product",
  797. item_id=obj.id,
  798. item_code=obj.product_code,
  799. item_name=obj.product_name,
  800. item_source=obj.source_type or "manual",
  801. tenant_no=tenant_no,
  802. user_id=user_id,
  803. create_dept_id=create_dept_id or "",
  804. stored_at=now,
  805. )
  806. db.commit()
  807. for obj in created:
  808. db.refresh(obj)
  809. return created
  810. def total_pages(total: int, page_size: int) -> int:
  811. if total <= 0:
  812. return 0
  813. return int(math.ceil(total / float(page_size)))
  814. # ---------------------------------------------------------------------------
  815. # 来源链路 API
  816. # ---------------------------------------------------------------------------
  817. _MAX_CHAIN_DEPTH = 10
  818. _SOURCE_TYPE_LABELS = {"production": "生产入库", "manual": "手动录入"}
  819. def _product_node(obj: BizCellProduct, is_current: bool = False) -> Dict[str, Any]:
  820. return {
  821. "id": obj.id,
  822. "productCode": obj.product_code or "",
  823. "productName": obj.product_name or "",
  824. "nodeType": "cell_product",
  825. "productStatus": obj.product_status or "",
  826. "passage": obj.passage or "",
  827. "isCurrent": is_current,
  828. }
  829. def _nest_ancestor_parent(chain: List[Dict[str, Any]]) -> Any:
  830. """多进一时 parent 为数组;单链时为对象;无上游为 null。"""
  831. if not chain:
  832. return None
  833. if len(chain) == 1:
  834. return chain[0]
  835. return chain
  836. _WORK_ORDER_TYPE_LABELS = {
  837. "inbound": "入库生产",
  838. "temporary": "临时生产",
  839. "outbound": "出库生产",
  840. }
  841. def _edge_meta(task: Any, wo: Any = None) -> Dict[str, Any]:
  842. edge: Dict[str, Any] = {
  843. "workOrderNo": "",
  844. "workOrderType": "",
  845. "workOrderTypeLabel": "",
  846. "taskNo": "",
  847. "taskName": "",
  848. "taskId": "",
  849. }
  850. if task:
  851. edge["taskNo"] = task.task_no or ""
  852. edge["taskName"] = task.task_name or ""
  853. edge["taskId"] = task.id or ""
  854. if wo:
  855. edge["workOrderNo"] = wo.work_order_no or ""
  856. wo_type = getattr(wo, "work_order_type", None) or "inbound"
  857. edge["workOrderType"] = wo_type
  858. edge["workOrderTypeLabel"] = _WORK_ORDER_TYPE_LABELS.get(wo_type, wo_type)
  859. return edge
  860. _SOURCE_LABELS = {"cell_storage": "细胞存储", "research_donation": "科研捐赠"}
  861. def _sample_customer_dict(db: Session, protocol_id: str, tenant_no: str) -> Dict[str, Any]:
  862. if not protocol_id:
  863. return {}
  864. from app.models.models import BizProtocolPartyCustomer
  865. pc = (
  866. db.query(BizProtocolPartyCustomer)
  867. .filter(
  868. BizProtocolPartyCustomer.collection_protocol_id == protocol_id,
  869. BizProtocolPartyCustomer.tenant_no == tenant_no,
  870. BizProtocolPartyCustomer.del_flag == 0,
  871. )
  872. .first()
  873. )
  874. if not pc:
  875. return {}
  876. return {
  877. "motherName": pc.mother_name or "",
  878. "motherPhone": pc.mother_phone or "",
  879. "motherIdCard": pc.mother_id_card or "",
  880. "newbornName": pc.newborn_name or "",
  881. }
  882. def _donation_sample_customer_dict(db: Session, donation_id: str, tenant_no: str) -> Dict[str, Any]:
  883. if not donation_id:
  884. return {}
  885. from app.models.research_donation import BizResearchDonationParty
  886. party = (
  887. db.query(BizResearchDonationParty)
  888. .filter(
  889. BizResearchDonationParty.research_donation_id == donation_id,
  890. BizResearchDonationParty.tenant_no == tenant_no,
  891. BizResearchDonationParty.del_flag == 0,
  892. )
  893. .first()
  894. )
  895. if not party:
  896. return {}
  897. return {
  898. "motherName": party.mother_name or "",
  899. "motherPhone": party.mother_phone or "",
  900. "motherIdCard": party.mother_id_card or "",
  901. "newbornName": party.newborn_name or "",
  902. }
  903. def _sample_type_name_from_id(db: Session, sample_type_id: Optional[str]) -> str:
  904. if not sample_type_id:
  905. return ""
  906. from app.models.models import BizCellSampleType
  907. row = (
  908. db.query(BizCellSampleType.type_name)
  909. .filter(BizCellSampleType.id == sample_type_id, BizCellSampleType.del_flag == 0)
  910. .first()
  911. )
  912. return (row[0] if row else "") or ""
  913. def _find_protocol_sample(
  914. db: Session,
  915. tenant_no: str,
  916. input_id: str,
  917. input_code: str,
  918. ) -> Optional[Any]:
  919. from app.models.models import BizProtocolSample
  920. sample = None
  921. if input_id:
  922. sample = (
  923. db.query(BizProtocolSample)
  924. .filter(
  925. BizProtocolSample.id == input_id,
  926. BizProtocolSample.tenant_no == tenant_no,
  927. BizProtocolSample.del_flag == 0,
  928. )
  929. .first()
  930. )
  931. if not sample and input_code:
  932. sample = (
  933. db.query(BizProtocolSample)
  934. .filter(
  935. BizProtocolSample.sample_code == input_code,
  936. BizProtocolSample.tenant_no == tenant_no,
  937. BizProtocolSample.del_flag == 0,
  938. )
  939. .first()
  940. )
  941. return sample
  942. def _find_donation_sample(
  943. db: Session,
  944. tenant_no: str,
  945. input_id: str,
  946. input_code: str,
  947. wo: Any = None,
  948. ) -> Optional[Any]:
  949. from app.models.research_donation import BizResearchDonationSample
  950. from app.crud.sample_reception import _donation_sample_id_col
  951. sample = None
  952. if input_id:
  953. sample = (
  954. db.query(BizResearchDonationSample)
  955. .filter(
  956. _donation_sample_id_col(BizResearchDonationSample.id) == input_id,
  957. BizResearchDonationSample.tenant_no == tenant_no,
  958. BizResearchDonationSample.del_flag == 0,
  959. )
  960. .first()
  961. )
  962. if not sample and input_code:
  963. sample = (
  964. db.query(BizResearchDonationSample)
  965. .filter(
  966. BizResearchDonationSample.sample_code == input_code,
  967. BizResearchDonationSample.tenant_no == tenant_no,
  968. BizResearchDonationSample.del_flag == 0,
  969. )
  970. .first()
  971. )
  972. if not sample and wo and (getattr(wo, "reception_source", None) or "") == "research_donation":
  973. wo_sample_id = (getattr(wo, "protocol_sample_id", None) or "").strip()
  974. if wo_sample_id:
  975. sample = (
  976. db.query(BizResearchDonationSample)
  977. .filter(
  978. _donation_sample_id_col(BizResearchDonationSample.id) == wo_sample_id,
  979. BizResearchDonationSample.tenant_no == tenant_no,
  980. BizResearchDonationSample.del_flag == 0,
  981. )
  982. .first()
  983. )
  984. return sample
  985. def _protocol_sample_chain_node(
  986. db: Session,
  987. tenant_no: str,
  988. sample: Any,
  989. *,
  990. wo: Any,
  991. edge: Dict[str, Any],
  992. sample_type_name: str,
  993. ) -> Dict[str, Any]:
  994. reception_source = getattr(wo, "reception_source", None) if wo else ""
  995. reception_source = reception_source or "cell_storage"
  996. protocol_id = sample.collection_protocol_id or ""
  997. return {
  998. "node": {
  999. "id": sample.id,
  1000. "productCode": sample.sample_code or "",
  1001. "productName": "",
  1002. "nodeType": "sample",
  1003. "sampleCode": sample.sample_code or "",
  1004. "sampleTypeName": sample_type_name,
  1005. "protocolNo": wo.protocol_no if wo else "",
  1006. "collectionProtocolId": protocol_id,
  1007. "receptionSource": reception_source,
  1008. "receptionSourceLabel": _SOURCE_LABELS.get(reception_source, reception_source),
  1009. "customer": _sample_customer_dict(db, protocol_id, tenant_no),
  1010. "isCurrent": False,
  1011. **sample_special_to_dict(sample),
  1012. },
  1013. "edge": edge,
  1014. "parent": None,
  1015. }
  1016. def _donation_sample_chain_node(
  1017. db: Session,
  1018. tenant_no: str,
  1019. sample: Any,
  1020. *,
  1021. wo: Any,
  1022. edge: Dict[str, Any],
  1023. sample_type_name: str,
  1024. ) -> Dict[str, Any]:
  1025. donation_id = sample.research_donation_id or ""
  1026. type_name = sample_type_name or _sample_type_name_from_id(db, sample.sample_type_id)
  1027. return {
  1028. "node": {
  1029. "id": sample.id,
  1030. "productCode": sample.sample_code or "",
  1031. "productName": "",
  1032. "nodeType": "sample",
  1033. "sampleCode": sample.sample_code or "",
  1034. "sampleTypeName": type_name,
  1035. "protocolNo": wo.protocol_no if wo else "",
  1036. "collectionProtocolId": donation_id,
  1037. "receptionSource": "research_donation",
  1038. "receptionSourceLabel": _SOURCE_LABELS["research_donation"],
  1039. "customer": _donation_sample_customer_dict(db, donation_id, tenant_no),
  1040. "isCurrent": False,
  1041. **sample_special_to_dict(sample),
  1042. },
  1043. "edge": edge,
  1044. "parent": None,
  1045. }
  1046. def _ancestor_sample_chain_link(
  1047. db: Session,
  1048. tenant_no: str,
  1049. sample_input: Any,
  1050. wo: Any,
  1051. edge: Dict[str, Any],
  1052. ) -> Optional[Dict[str, Any]]:
  1053. """解析任务样本投入物,返回祖先链单段(样本终止节点)。"""
  1054. input_id = (sample_input.input_id or "").strip()
  1055. input_code = (sample_input.input_code or "").strip()
  1056. if not input_id and not input_code:
  1057. if wo and (getattr(wo, "reception_source", None) or "") == "research_donation":
  1058. input_code = (getattr(wo, "sample_code", None) or "").strip()
  1059. input_id = (getattr(wo, "protocol_sample_id", None) or "").strip()
  1060. if not input_id and not input_code:
  1061. return None
  1062. input_name = (sample_input.input_name or "").strip()
  1063. reception_source = (getattr(wo, "reception_source", None) or "") if wo else ""
  1064. donation_sample = None
  1065. if reception_source == "research_donation":
  1066. donation_sample = _find_donation_sample(db, tenant_no, input_id, input_code, wo)
  1067. if donation_sample:
  1068. return _donation_sample_chain_node(
  1069. db,
  1070. tenant_no,
  1071. donation_sample,
  1072. wo=wo,
  1073. edge=edge,
  1074. sample_type_name=input_name,
  1075. )
  1076. protocol_sample = _find_protocol_sample(db, tenant_no, input_id, input_code)
  1077. if protocol_sample:
  1078. return _protocol_sample_chain_node(
  1079. db,
  1080. tenant_no,
  1081. protocol_sample,
  1082. wo=wo,
  1083. edge=edge,
  1084. sample_type_name=input_name
  1085. or _sample_type_name_from_id(db, protocol_sample.sample_type_id),
  1086. )
  1087. if reception_source != "research_donation":
  1088. donation_sample = _find_donation_sample(db, tenant_no, input_id, input_code, wo)
  1089. if donation_sample:
  1090. return _donation_sample_chain_node(
  1091. db,
  1092. tenant_no,
  1093. donation_sample,
  1094. wo=wo,
  1095. edge=edge,
  1096. sample_type_name=input_name,
  1097. )
  1098. return None
  1099. def _find_cell_product_by_input(db: Session, inp: Any) -> Optional[BizCellProduct]:
  1100. if not (inp.input_id or inp.input_code):
  1101. return None
  1102. if inp.input_id:
  1103. product = (
  1104. db.query(BizCellProduct)
  1105. .filter(BizCellProduct.id == inp.input_id, BizCellProduct.del_flag == 0)
  1106. .first()
  1107. )
  1108. if product:
  1109. return product
  1110. if inp.input_code:
  1111. return (
  1112. db.query(BizCellProduct)
  1113. .filter(BizCellProduct.product_code == inp.input_code, BizCellProduct.del_flag == 0)
  1114. .first()
  1115. )
  1116. return None
  1117. def _ancestor_link_from_cell_product_input(
  1118. db: Session,
  1119. tenant_no: str,
  1120. inp: Any,
  1121. edge: Dict[str, Any],
  1122. depth: int,
  1123. ) -> Optional[Dict[str, Any]]:
  1124. parent_product = _find_cell_product_by_input(db, inp)
  1125. if not parent_product:
  1126. return None
  1127. parent_chain = _build_ancestor_chain(db, parent_product, depth + 1, tenant_no)
  1128. return {
  1129. "node": _product_node(parent_product),
  1130. "edge": edge,
  1131. "parent": _nest_ancestor_parent(parent_chain),
  1132. }
  1133. def _build_ancestor_chain(
  1134. db: Session, product: BizCellProduct, depth: int, tenant_no: str
  1135. ) -> List[Dict[str, Any]]:
  1136. if depth >= _MAX_CHAIN_DEPTH:
  1137. return []
  1138. task_id = product.production_task_id
  1139. if not task_id:
  1140. return []
  1141. from app.models.models import BizProductionTask, BizProductionWorkOrder
  1142. from app.models.models import BizProductionTaskInput
  1143. task = (
  1144. db.query(BizProductionTask)
  1145. .filter(BizProductionTask.id == task_id, BizProductionTask.del_flag == 0)
  1146. .first()
  1147. )
  1148. if not task:
  1149. return []
  1150. wo = None
  1151. if task.work_order_id:
  1152. wo = (
  1153. db.query(BizProductionWorkOrder)
  1154. .filter(BizProductionWorkOrder.id == task.work_order_id, BizProductionWorkOrder.del_flag == 0)
  1155. .first()
  1156. )
  1157. edge = _edge_meta(task, wo)
  1158. inputs = (
  1159. db.query(BizProductionTaskInput)
  1160. .filter(
  1161. BizProductionTaskInput.task_id == task_id,
  1162. BizProductionTaskInput.input_type.in_(("cell_product", "sample")),
  1163. BizProductionTaskInput.del_flag == 0,
  1164. )
  1165. .order_by(
  1166. BizProductionTaskInput.order_num.asc(),
  1167. BizProductionTaskInput.create_time.asc(),
  1168. )
  1169. .all()
  1170. )
  1171. if not inputs:
  1172. return []
  1173. links: List[Dict[str, Any]] = []
  1174. seen_node_ids: set = set()
  1175. for inp in inputs:
  1176. link: Optional[Dict[str, Any]] = None
  1177. if inp.input_type == "cell_product":
  1178. link = _ancestor_link_from_cell_product_input(
  1179. db, tenant_no, inp, edge, depth + 1
  1180. )
  1181. elif inp.input_type == "sample":
  1182. link = _ancestor_sample_chain_link(db, tenant_no, inp, wo, edge)
  1183. if not link:
  1184. continue
  1185. node_id = (link.get("node") or {}).get("id") or ""
  1186. if node_id and node_id in seen_node_ids:
  1187. continue
  1188. if node_id:
  1189. seen_node_ids.add(node_id)
  1190. links.append(link)
  1191. return links
  1192. def _build_descendant_chain(
  1193. db: Session, product_id: str, product_code: str, depth: int, tenant_no: str
  1194. ) -> List[Dict[str, Any]]:
  1195. if depth >= _MAX_CHAIN_DEPTH:
  1196. return []
  1197. from app.models.models import BizProductionTaskInput, BizProductionTaskOutput, BizProductionTask, BizProductionWorkOrder
  1198. # 找到引用此产品作为投入物的所有任务(id 或编码)
  1199. input_match = [BizProductionTaskInput.input_id == product_id]
  1200. if product_code:
  1201. input_match.append(BizProductionTaskInput.input_code == product_code)
  1202. input_rows = (
  1203. db.query(BizProductionTaskInput)
  1204. .filter(
  1205. BizProductionTaskInput.input_type == "cell_product",
  1206. BizProductionTaskInput.del_flag == 0,
  1207. or_(*input_match),
  1208. )
  1209. .all()
  1210. )
  1211. if not input_rows:
  1212. return []
  1213. task_ids = list({r.task_id for r in input_rows})
  1214. tasks = (
  1215. db.query(BizProductionTask)
  1216. .filter(BizProductionTask.id.in_(task_ids), BizProductionTask.del_flag == 0)
  1217. .all()
  1218. )
  1219. task_by_id = {t.id: t for t in tasks}
  1220. wo_ids = list({t.work_order_id for t in tasks if t.work_order_id})
  1221. wo_by_id: Dict[str, Any] = {}
  1222. if wo_ids:
  1223. wos = (
  1224. db.query(BizProductionWorkOrder)
  1225. .filter(BizProductionWorkOrder.id.in_(wo_ids), BizProductionWorkOrder.del_flag == 0)
  1226. .all()
  1227. )
  1228. wo_by_id = {w.id: w for w in wos}
  1229. # 对每个任务,找产出
  1230. result: List[Dict[str, Any]] = []
  1231. output_rows = (
  1232. db.query(BizProductionTaskOutput)
  1233. .filter(
  1234. BizProductionTaskOutput.task_id.in_(task_ids),
  1235. BizProductionTaskOutput.del_flag == 0,
  1236. )
  1237. .all()
  1238. )
  1239. child_product_ids = list({o.cell_product_id for o in output_rows if o.cell_product_id})
  1240. child_products = {}
  1241. if child_product_ids:
  1242. cps = (
  1243. db.query(BizCellProduct)
  1244. .filter(BizCellProduct.id.in_(child_product_ids), BizCellProduct.del_flag == 0)
  1245. .all()
  1246. )
  1247. child_products = {cp.id: cp for cp in cps}
  1248. seen_child_ids: set = set()
  1249. for o in output_rows:
  1250. cp = child_products.get(o.cell_product_id)
  1251. if not cp or cp.id in seen_child_ids:
  1252. continue
  1253. seen_child_ids.add(cp.id)
  1254. t = task_by_id.get(o.task_id)
  1255. wo_obj = wo_by_id.get(t.work_order_id) if t and t.work_order_id else None
  1256. edge = _edge_meta(t, wo_obj)
  1257. result.append({
  1258. "node": _product_node(cp),
  1259. "edge": edge,
  1260. "children": _build_descendant_chain(
  1261. db, cp.id, cp.product_code or "", depth + 1, tenant_no
  1262. ),
  1263. })
  1264. return result
  1265. def _lineage_node_key(node: Dict[str, Any]) -> str:
  1266. nt = (node.get("nodeType") or "").strip()
  1267. nid = (node.get("id") or "").strip()
  1268. if nt == "sample":
  1269. code = (node.get("sampleCode") or "").strip()
  1270. return f"sample:{(nid or code)}"
  1271. return f"{nt}:{nid}"
  1272. def _walk_ancestor_link(
  1273. link: Any,
  1274. out: List[Dict[str, Any]],
  1275. seen: set,
  1276. ) -> None:
  1277. if not link:
  1278. return
  1279. if isinstance(link, list):
  1280. for item in link:
  1281. _walk_ancestor_link(item, out, seen)
  1282. return
  1283. if not isinstance(link, dict):
  1284. return
  1285. parent = link.get("parent")
  1286. _walk_ancestor_link(parent, out, seen)
  1287. node = link.get("node")
  1288. if not isinstance(node, dict):
  1289. return
  1290. key = _lineage_node_key(node)
  1291. if not key or key in seen:
  1292. return
  1293. seen.add(key)
  1294. out.append(dict(node))
  1295. def _flatten_lineage_nodes(chain: Dict[str, Any]) -> List[Dict[str, Any]]:
  1296. """将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。"""
  1297. out: List[Dict[str, Any]] = []
  1298. seen: set = set()
  1299. for link in chain.get("ancestors") or []:
  1300. _walk_ancestor_link(link, out, seen)
  1301. current = chain.get("current")
  1302. if isinstance(current, dict):
  1303. key = _lineage_node_key(current)
  1304. if key and key not in seen:
  1305. seen.add(key)
  1306. node = dict(current)
  1307. node["isCurrent"] = True
  1308. out.append(node)
  1309. elif key:
  1310. for n in out:
  1311. if _lineage_node_key(n) == key:
  1312. n["isCurrent"] = True
  1313. break
  1314. return out
  1315. def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
  1316. product = get_cell_product(db, product_id, tenant_no)
  1317. if not product:
  1318. return None
  1319. return {
  1320. "current": _product_node(product, is_current=True),
  1321. "ancestors": _build_ancestor_chain(db, product, 0, tenant_no),
  1322. "descendants": _build_descendant_chain(
  1323. db, product.id, product.product_code or "", 0, tenant_no
  1324. ),
  1325. }
  1326. def _wo_status_label(status: str) -> str:
  1327. from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS
  1328. return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")
  1329. def _slim_task_for_lineage(
  1330. task: Dict[str, Any], results: List[Dict[str, Any]]
  1331. ) -> Dict[str, Any]:
  1332. return {
  1333. "id": task.get("id") or "",
  1334. "taskNo": task.get("taskNo") or "",
  1335. "itemName": task.get("itemName") or "",
  1336. "itemCode": task.get("itemCode") or "",
  1337. "taskType": task.get("taskType") or "",
  1338. "taskStatus": task.get("taskStatus") or "",
  1339. "taskStatusLabel": task.get("taskStatusLabel") or "",
  1340. "conclusion": task.get("conclusion") or "",
  1341. "conclusionLabel": task.get("conclusionLabel") or "",
  1342. "remark": task.get("remark") or "",
  1343. "completedTime": task.get("completedTime") or "",
  1344. "companyName": task.get("companyName") or "",
  1345. "results": [
  1346. {
  1347. "paramName": r.get("paramName") or "",
  1348. "paramValue": r.get("paramValue") or "",
  1349. "paramUnit": r.get("paramUnit") or "",
  1350. }
  1351. for r in (results or [])
  1352. ],
  1353. "attachmentFiles": task.get("attachmentFiles") or [],
  1354. }
  1355. def _load_lineage_qc_for_nodes(
  1356. db: Session, nodes: List[Dict[str, Any]], tenant_no: str
  1357. ) -> List[Dict[str, Any]]:
  1358. from app.models.models import BizQcWorkOrder
  1359. from app.crud import qc_task as task_svc
  1360. from app.crud import qc_task_result as result_svc
  1361. from app.crud.qc_work_order import list_work_orders_by_sample
  1362. product_ids = [
  1363. (n.get("id") or "").strip()
  1364. for n in nodes
  1365. if (n.get("nodeType") or "") == "cell_product" and (n.get("id") or "").strip()
  1366. ]
  1367. wo_by_product: Dict[str, List[BizQcWorkOrder]] = {pid: [] for pid in product_ids}
  1368. if product_ids:
  1369. rows = (
  1370. db.query(BizQcWorkOrder)
  1371. .filter(
  1372. BizQcWorkOrder.cell_product_id.in_(product_ids),
  1373. BizQcWorkOrder.tenant_no == tenant_no,
  1374. BizQcWorkOrder.del_flag == 0,
  1375. )
  1376. .order_by(BizQcWorkOrder.create_time.desc())
  1377. .all()
  1378. )
  1379. for wo in rows:
  1380. pid = (wo.cell_product_id or "").strip()
  1381. if pid in wo_by_product:
  1382. wo_by_product[pid].append(wo)
  1383. sample_wo_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
  1384. node_work_orders_orm: List[List[BizQcWorkOrder]] = []
  1385. for n in nodes:
  1386. if (n.get("nodeType") or "") == "cell_product":
  1387. node_work_orders_orm.append(wo_by_product.get((n.get("id") or "").strip(), []))
  1388. else:
  1389. node_work_orders_orm.append([])
  1390. sample_wo_dicts: Dict[int, List[Dict[str, Any]]] = {}
  1391. for idx, n in enumerate(nodes):
  1392. if (n.get("nodeType") or "") != "sample":
  1393. continue
  1394. code = (n.get("sampleCode") or "").strip()
  1395. rs = (n.get("receptionSource") or "").strip()
  1396. if not code or not rs:
  1397. sample_wo_dicts[idx] = []
  1398. continue
  1399. key = (code, rs)
  1400. if key not in sample_wo_cache:
  1401. sample_wo_cache[key] = list_work_orders_by_sample(
  1402. db,
  1403. sample_code=code,
  1404. reception_source=rs,
  1405. tenant_no=tenant_no,
  1406. user_info=None,
  1407. )
  1408. sample_wo_dicts[idx] = sample_wo_cache[key]
  1409. pending_wo_ids: List[str] = []
  1410. for wos in node_work_orders_orm:
  1411. for wo in wos:
  1412. pending_wo_ids.append(wo.id)
  1413. for wos in sample_wo_dicts.values():
  1414. for wo in wos:
  1415. wid = (wo.get("id") or "").strip()
  1416. if wid:
  1417. pending_wo_ids.append(wid)
  1418. unique_wo_ids = list({(x or "").strip() for x in pending_wo_ids if (x or "").strip()})
  1419. tasks_by_wo: Dict[str, List[Dict[str, Any]]] = {}
  1420. for i in range(0, len(unique_wo_ids), 100):
  1421. chunk = unique_wo_ids[i : i + 100]
  1422. chunk_tasks = task_svc.list_tasks_by_work_order_ids(db, chunk, tenant_no)
  1423. for wo_id, tasks in chunk_tasks.items():
  1424. tasks_by_wo.setdefault(wo_id, []).extend(tasks)
  1425. all_task_ids = [
  1426. (t.get("id") or "").strip()
  1427. for tasks in tasks_by_wo.values()
  1428. for t in tasks
  1429. if (t.get("id") or "").strip()
  1430. ]
  1431. results_by_task = result_svc.list_saved_results_by_task_ids(db, all_task_ids, tenant_no)
  1432. def pack_orm_wos(wos: List[BizQcWorkOrder]) -> List[Dict[str, Any]]:
  1433. packed = []
  1434. for wo in wos:
  1435. tasks = tasks_by_wo.get(wo.id, [])
  1436. packed.append(
  1437. {
  1438. "id": wo.id,
  1439. "workOrderNo": wo.work_order_no or "",
  1440. "workOrderType": wo.work_order_type or "",
  1441. "workOrderStatus": wo.work_order_status or "",
  1442. "workOrderStatusLabel": _wo_status_label(wo.work_order_status or ""),
  1443. "tasks": [
  1444. _slim_task_for_lineage(
  1445. t, results_by_task.get(t.get("id") or "", [])
  1446. )
  1447. for t in tasks
  1448. ],
  1449. }
  1450. )
  1451. return packed
  1452. def pack_dict_wos(wos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  1453. packed = []
  1454. for wo in wos:
  1455. wid = (wo.get("id") or "").strip()
  1456. tasks = tasks_by_wo.get(wid, [])
  1457. packed.append(
  1458. {
  1459. "id": wid,
  1460. "workOrderNo": wo.get("workOrderNo") or "",
  1461. "workOrderType": wo.get("workOrderType") or "",
  1462. "workOrderStatus": wo.get("workOrderStatus") or "",
  1463. "workOrderStatusLabel": wo.get("workOrderStatusLabel")
  1464. or _wo_status_label(wo.get("workOrderStatus") or ""),
  1465. "tasks": [
  1466. _slim_task_for_lineage(
  1467. t, results_by_task.get(t.get("id") or "", [])
  1468. )
  1469. for t in tasks
  1470. ],
  1471. }
  1472. )
  1473. return packed
  1474. out_nodes: List[Dict[str, Any]] = []
  1475. for idx, n in enumerate(nodes):
  1476. nt = n.get("nodeType") or ""
  1477. if nt == "sample":
  1478. label = (n.get("sampleCode") or n.get("id") or "").strip()
  1479. work_orders = pack_dict_wos(sample_wo_dicts.get(idx, []))
  1480. else:
  1481. label = (n.get("productCode") or n.get("id") or "").strip()
  1482. work_orders = pack_orm_wos(node_work_orders_orm[idx])
  1483. out_nodes.append(
  1484. {
  1485. "nodeType": nt,
  1486. "id": n.get("id") or "",
  1487. "label": label,
  1488. "productName": n.get("productName") or "",
  1489. "passage": n.get("passage") or None,
  1490. "receptionSource": n.get("receptionSource") or None,
  1491. "receptionSourceLabel": n.get("receptionSourceLabel") or None,
  1492. "isCurrent": bool(n.get("isCurrent")),
  1493. "workOrders": work_orders,
  1494. }
  1495. )
  1496. return out_nodes
  1497. def get_product_lineage_qc(
  1498. db: Session, product_id: str, tenant_no: str
  1499. ) -> Optional[Dict[str, Any]]:
  1500. chain = get_product_chain(db, product_id, tenant_no)
  1501. if not chain:
  1502. return None
  1503. current = chain.get("current") or {}
  1504. flat = _flatten_lineage_nodes(chain)
  1505. nodes = _load_lineage_qc_for_nodes(db, flat, tenant_no)
  1506. return {
  1507. "productId": current.get("id") or product_id,
  1508. "productCode": current.get("productCode") or "",
  1509. "productName": current.get("productName") or "",
  1510. "nodes": nodes,
  1511. }