storage_release_review.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  1. from __future__ import annotations
  2. import math
  3. from datetime import datetime
  4. from typing import Any, Dict, List, Optional
  5. from sqlalchemy import and_, exists, func, or_
  6. from sqlalchemy.orm import Session
  7. from app.core.db_collation import eq_unicode
  8. from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
  9. from app.core.utils import generate_uuid
  10. from app.utils.sample_special import sample_special_to_dict
  11. from app.models.models import (
  12. BizCollectionProtocol,
  13. BizProductionWorkOrder,
  14. BizProtocolPartyCustomer,
  15. BizProtocolSample,
  16. BizProtocolSampleTransport,
  17. BizQcWorkOrder,
  18. BizSampleReception,
  19. BizStorageReleaseReview,
  20. SysUser,
  21. )
  22. from app.models.research_donation import (
  23. BizResearchDonation,
  24. BizResearchDonationParty,
  25. BizResearchDonationSample,
  26. BizResearchDonationSampleTransport,
  27. )
  28. _RECEPTION_SOURCE_LABELS = {
  29. "cell_storage": "细胞存储",
  30. "research_donation": "科研捐赠",
  31. }
  32. _REVIEW_CONCLUSION_LABELS = {"tg": "通过", "btg": "不通过"}
  33. _STORAGE_STATUS_LABELS = {"drk": "待入库", "yrk": "已入库"}
  34. _PROTOCOL_STATUS_LABELS = {
  35. "dsx": "待生效",
  36. "sxz": "生效中",
  37. "ygq": "已过期",
  38. "yzf": "已作废",
  39. "sdfq": "属地废弃",
  40. "zky": "转科研",
  41. }
  42. _DONATION_STATUS_LABELS = {
  43. "dsx": "待生效",
  44. "sxz": "生效中",
  45. "ygq": "已过期",
  46. "yzf": "已作废",
  47. "sdfq": "属地废弃",
  48. "zckc": "转细胞存储",
  49. }
  50. def _fmt_dt(t: Optional[datetime]) -> str:
  51. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  52. def total_pages(total: int, page_size: int) -> int:
  53. if total <= 0:
  54. return 0
  55. return int(math.ceil(total / float(page_size)))
  56. def _row_to_dict(r: BizStorageReleaseReview) -> Dict[str, Any]:
  57. return {
  58. "id": r.id,
  59. "sampleCode": r.sample_code or "",
  60. "receptionSource": r.reception_source or "",
  61. "receptionSourceLabel": _RECEPTION_SOURCE_LABELS.get(r.reception_source or "", r.reception_source or ""),
  62. "protocolId": r.protocol_id or "",
  63. "receptionId": r.reception_id or "",
  64. "protocolNo": r.protocol_no or "",
  65. "sampleName": r.sample_name or "",
  66. "reviewConclusion": r.review_conclusion or "",
  67. "reviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(r.review_conclusion or "", r.review_conclusion or ""),
  68. "reviewRemark": r.review_remark or "",
  69. "reviewAttachments": r.review_attachments or "",
  70. "reviewBy": r.review_by or "",
  71. "reviewByName": r.review_by_name or "",
  72. "reviewTime": _fmt_dt(r.review_time),
  73. "qcReviewConclusion": r.qc_review_conclusion or "",
  74. "qcReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(r.qc_review_conclusion or "", r.qc_review_conclusion or ""),
  75. "qcReviewRemark": r.qc_review_remark or "",
  76. "qcReviewAttachments": r.qc_review_attachments or "",
  77. "qcReviewBy": r.qc_review_by or "",
  78. "qcReviewByName": r.qc_review_by_name or "",
  79. "qcReviewTime": _fmt_dt(r.qc_review_time),
  80. "prodReviewConclusion": r.prod_review_conclusion or "",
  81. "prodReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(r.prod_review_conclusion or "", r.prod_review_conclusion or ""),
  82. "prodReviewRemark": r.prod_review_remark or "",
  83. "prodReviewAttachments": r.prod_review_attachments or "",
  84. "prodReviewBy": r.prod_review_by or "",
  85. "prodReviewByName": r.prod_review_by_name or "",
  86. "prodReviewTime": _fmt_dt(r.prod_review_time),
  87. "storageStatus": r.storage_status or "drk",
  88. "storageStatusLabel": _STORAGE_STATUS_LABELS.get(r.storage_status or "drk", r.storage_status or "drk"),
  89. "storageBy": r.storage_by or "",
  90. "storageByName": r.storage_by_name or "",
  91. "storageTime": _fmt_dt(r.storage_time),
  92. "remark": r.remark or "",
  93. "createTime": _fmt_dt(r.create_time),
  94. "updateTime": _fmt_dt(r.update_time),
  95. }
  96. def create_review_from_reception(
  97. db: Session,
  98. *,
  99. reception: BizSampleReception,
  100. protocol_no: str,
  101. sample_name: str,
  102. tenant_no: str,
  103. user_id: Optional[str] = None,
  104. create_dept_id: str = "",
  105. ) -> BizStorageReleaseReview:
  106. source = getattr(reception, "reception_source", "cell_storage") or "cell_storage"
  107. if source == "research_donation":
  108. protocol_id = reception.research_donation_id or ""
  109. else:
  110. protocol_id = reception.collection_protocol_id or ""
  111. obj = BizStorageReleaseReview(
  112. id=generate_uuid(),
  113. sample_code=reception.sample_code or "",
  114. reception_source=source,
  115. protocol_id=protocol_id,
  116. reception_id=reception.id,
  117. protocol_no=protocol_no or None,
  118. sample_name=sample_name or None,
  119. storage_status="drk",
  120. tenant_no=tenant_no,
  121. create_by=user_id,
  122. create_dept_id=create_dept_id or "",
  123. update_by=user_id,
  124. create_time=datetime.now(),
  125. update_time=datetime.now(),
  126. del_flag=0,
  127. )
  128. db.add(obj)
  129. db.flush()
  130. return obj
  131. def normalize_review_phase(review_phase: Optional[str]) -> Optional[str]:
  132. """Return 'pending' | 'reviewed' or None when absent/invalid."""
  133. p = (review_phase or "").strip().lower()
  134. if p in ("pending", "reviewed"):
  135. return p
  136. return None
  137. def conclusion_blank(value: Optional[str]) -> bool:
  138. return not (value or "").strip()
  139. def aggregate_review_conclusion(
  140. prod: Optional[str], qc: Optional[str]
  141. ) -> Optional[str]:
  142. p = (prod or "").strip()
  143. q = (qc or "").strip()
  144. if p == "btg" or q == "btg":
  145. return "btg"
  146. if p == "tg" and q == "tg":
  147. return "tg"
  148. return None
  149. def storage_transition(
  150. prod: Optional[str], qc: Optional[str], storage_status: Optional[str]
  151. ) -> str:
  152. """Return 'store' | 'unstore' | 'noop'."""
  153. agg = aggregate_review_conclusion(prod, qc)
  154. st = (storage_status or "drk").strip() or "drk"
  155. if agg == "tg" and st == "drk":
  156. return "store"
  157. if agg == "btg" and st == "yrk":
  158. return "unstore"
  159. return "noop"
  160. def normalize_review_side(raw: Optional[str]) -> Optional[str]:
  161. s = (raw or "").strip().lower()
  162. if s in ("overview", "production", "qc"):
  163. return s
  164. return None
  165. def review_side_required_menu(review_side: Optional[str]) -> Optional[str]:
  166. if review_side == "production":
  167. return "production:storageReleaseReview:list"
  168. if review_side == "qc":
  169. return "quality:storageReleaseReview:list"
  170. return None
  171. def matches_dual_review_phase(
  172. review_side: Optional[str],
  173. phase: Optional[str],
  174. prod: Optional[str],
  175. qc: Optional[str],
  176. ) -> bool:
  177. """Pure predicate for Tab semantics; SQL filter must match this."""
  178. side = normalize_review_side(review_side) or "overview"
  179. ph = normalize_review_phase(phase)
  180. if not ph:
  181. return True
  182. prod_blank = conclusion_blank(prod)
  183. qc_blank = conclusion_blank(qc)
  184. if side == "production":
  185. return prod_blank if ph == "pending" else (not prod_blank)
  186. if side == "qc":
  187. return qc_blank if ph == "pending" else (not qc_blank)
  188. both_filled = (not prod_blank) and (not qc_blank)
  189. return (not both_filled) if ph == "pending" else both_filled
  190. def should_copy_legacy_to_qc(
  191. qc_review_conclusion: Optional[str],
  192. legacy_review_conclusion: Optional[str],
  193. ) -> bool:
  194. return conclusion_blank(qc_review_conclusion) and not conclusion_blank(
  195. legacy_review_conclusion
  196. )
  197. def normalize_qc_work_order_completed(raw) -> Optional[bool]:
  198. """Return True/False when recognizable; None means no filter."""
  199. if raw is None:
  200. return None
  201. if isinstance(raw, bool):
  202. return raw
  203. s = str(raw).strip().lower()
  204. if s in ("true", "1", "yes"):
  205. return True
  206. if s in ("false", "0", "no"):
  207. return False
  208. return None
  209. def normalize_ready_for_review_only(raw) -> Optional[bool]:
  210. """Return True when ready-only filter should apply; None means no filter."""
  211. if raw is None:
  212. return None
  213. if isinstance(raw, bool):
  214. return True if raw else None
  215. s = str(raw).strip().lower()
  216. if s in ("true", "1", "yes"):
  217. return True
  218. return None
  219. def is_inactive_protocol_status_for_hide(status) -> bool:
  220. """True when protocol/donation status is yzf or sdfq (list-hide rule)."""
  221. code = (status or "").strip().lower()
  222. return code in INACTIVE_PROTOCOL_STATUSES
  223. def inbound_work_orders_all_void(statuses) -> bool:
  224. """True when there is ≥1 non-empty status and every one equals yzf."""
  225. cleaned = [(s or "").strip() for s in (statuses or [])]
  226. if not cleaned or not any(s for s in cleaned):
  227. return False
  228. return all(s.lower() == "yzf" for s in cleaned)
  229. def _qc_work_order_exists_for_review_sample(
  230. tenant_no: str, *, unfinished_only: bool = False
  231. ):
  232. """Correlate biz_qc_work_order to BizStorageReleaseReview.sample_code."""
  233. conds = [
  234. BizQcWorkOrder.tenant_no == tenant_no,
  235. BizQcWorkOrder.del_flag == 0,
  236. BizQcWorkOrder.sample_code.isnot(None),
  237. BizQcWorkOrder.sample_code != "",
  238. # biz_qc_work_order 与 biz_storage_release_review 的 sample_code 排序规则不同
  239. eq_unicode(BizQcWorkOrder.sample_code, BizStorageReleaseReview.sample_code),
  240. BizStorageReleaseReview.sample_code.isnot(None),
  241. BizStorageReleaseReview.sample_code != "",
  242. ]
  243. if unfinished_only:
  244. conds.append(BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")))
  245. return (
  246. exists()
  247. .where(and_(*conds))
  248. .correlate(BizStorageReleaseReview)
  249. )
  250. def _production_work_order_unfinished_exists_for_review_sample(tenant_no: str):
  251. """True if sample has any unfinished inbound-side production WO (dpz/jxz)."""
  252. conds = [
  253. BizProductionWorkOrder.tenant_no == tenant_no,
  254. BizProductionWorkOrder.del_flag == 0,
  255. BizProductionWorkOrder.sample_code.isnot(None),
  256. BizProductionWorkOrder.sample_code != "",
  257. eq_unicode(
  258. BizProductionWorkOrder.sample_code, BizStorageReleaseReview.sample_code
  259. ),
  260. BizStorageReleaseReview.sample_code.isnot(None),
  261. BizStorageReleaseReview.sample_code != "",
  262. BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
  263. ]
  264. return (
  265. exists()
  266. .where(and_(*conds))
  267. .correlate(BizStorageReleaseReview)
  268. )
  269. def _inactive_protocol_exists_for_review():
  270. """True when linked collection/donation protocol is yzf or sdfq."""
  271. inactive_cell = and_(
  272. or_(
  273. BizStorageReleaseReview.reception_source == "cell_storage",
  274. BizStorageReleaseReview.reception_source.is_(None),
  275. BizStorageReleaseReview.reception_source == "",
  276. ),
  277. BizStorageReleaseReview.protocol_id.isnot(None),
  278. BizStorageReleaseReview.protocol_id != "",
  279. exists()
  280. .where(
  281. and_(
  282. eq_unicode(
  283. BizCollectionProtocol.id, BizStorageReleaseReview.protocol_id
  284. ),
  285. BizCollectionProtocol.del_flag == 0,
  286. BizCollectionProtocol.protocol_status.in_(INACTIVE_PROTOCOL_STATUSES),
  287. )
  288. )
  289. .correlate(BizStorageReleaseReview),
  290. )
  291. inactive_donation = and_(
  292. BizStorageReleaseReview.reception_source == "research_donation",
  293. BizStorageReleaseReview.protocol_id.isnot(None),
  294. BizStorageReleaseReview.protocol_id != "",
  295. exists()
  296. .where(
  297. and_(
  298. eq_unicode(
  299. BizResearchDonation.id, BizStorageReleaseReview.protocol_id
  300. ),
  301. BizResearchDonation.del_flag == 0,
  302. BizResearchDonation.donation_status.in_(INACTIVE_PROTOCOL_STATUSES),
  303. )
  304. )
  305. .correlate(BizStorageReleaseReview),
  306. )
  307. return or_(inactive_cell, inactive_donation)
  308. def _inbound_qc_all_void_for_review_sample(tenant_no: str):
  309. """True when sample has ≥1 inbound QC WO and every such WO is yzf."""
  310. base = [
  311. BizQcWorkOrder.tenant_no == tenant_no,
  312. BizQcWorkOrder.del_flag == 0,
  313. BizQcWorkOrder.qc_biz_type == "inbound",
  314. BizQcWorkOrder.sample_code.isnot(None),
  315. BizQcWorkOrder.sample_code != "",
  316. eq_unicode(BizQcWorkOrder.sample_code, BizStorageReleaseReview.sample_code),
  317. BizStorageReleaseReview.sample_code.isnot(None),
  318. BizStorageReleaseReview.sample_code != "",
  319. ]
  320. any_inbound = exists().where(and_(*base)).correlate(BizStorageReleaseReview)
  321. any_non_void = (
  322. exists()
  323. .where(
  324. and_(
  325. *base,
  326. or_(
  327. BizQcWorkOrder.work_order_status.is_(None),
  328. BizQcWorkOrder.work_order_status != "yzf",
  329. ),
  330. )
  331. )
  332. .correlate(BizStorageReleaseReview)
  333. )
  334. return and_(any_inbound, ~any_non_void)
  335. def _inbound_production_all_void_for_review_sample(tenant_no: str):
  336. """True when sample has ≥1 inbound production WO and every such WO is yzf."""
  337. base = [
  338. BizProductionWorkOrder.tenant_no == tenant_no,
  339. BizProductionWorkOrder.del_flag == 0,
  340. BizProductionWorkOrder.work_order_type == "inbound",
  341. BizProductionWorkOrder.sample_code.isnot(None),
  342. BizProductionWorkOrder.sample_code != "",
  343. eq_unicode(
  344. BizProductionWorkOrder.sample_code, BizStorageReleaseReview.sample_code
  345. ),
  346. BizStorageReleaseReview.sample_code.isnot(None),
  347. BizStorageReleaseReview.sample_code != "",
  348. ]
  349. any_inbound = exists().where(and_(*base)).correlate(BizStorageReleaseReview)
  350. any_non_void = (
  351. exists()
  352. .where(
  353. and_(
  354. *base,
  355. or_(
  356. BizProductionWorkOrder.work_order_status.is_(None),
  357. BizProductionWorkOrder.work_order_status != "yzf",
  358. ),
  359. )
  360. )
  361. .correlate(BizStorageReleaseReview)
  362. )
  363. return and_(any_inbound, ~any_non_void)
  364. def list_reviews(
  365. db: Session,
  366. *,
  367. tenant_no: str,
  368. page: int = 1,
  369. page_size: int = 10,
  370. reception_source: Optional[str] = None,
  371. review_conclusion: Optional[str] = None,
  372. storage_status: Optional[str] = None,
  373. keyword: Optional[str] = None,
  374. review_phase: Optional[str] = None,
  375. review_side: Optional[str] = None,
  376. prod_review_conclusion: Optional[str] = None,
  377. qc_review_conclusion: Optional[str] = None,
  378. qc_work_order_completed=None,
  379. ready_for_review_only=None,
  380. user_info=None,
  381. ) -> Dict[str, Any]:
  382. q0 = db.query(BizStorageReleaseReview).filter(
  383. BizStorageReleaseReview.tenant_no == tenant_no,
  384. BizStorageReleaseReview.del_flag == 0,
  385. )
  386. rs = (reception_source or "").strip()
  387. if rs:
  388. q0 = q0.filter(BizStorageReleaseReview.reception_source == rs)
  389. phase = normalize_review_phase(review_phase)
  390. side = normalize_review_side(review_side) or "overview"
  391. if phase:
  392. prod_blank = or_(
  393. BizStorageReleaseReview.prod_review_conclusion.is_(None),
  394. BizStorageReleaseReview.prod_review_conclusion == "",
  395. )
  396. qc_blank = or_(
  397. BizStorageReleaseReview.qc_review_conclusion.is_(None),
  398. BizStorageReleaseReview.qc_review_conclusion == "",
  399. )
  400. if side == "production":
  401. q0 = q0.filter(prod_blank if phase == "pending" else ~prod_blank)
  402. elif side == "qc":
  403. q0 = q0.filter(qc_blank if phase == "pending" else ~qc_blank)
  404. else:
  405. both_filled = and_(~prod_blank, ~qc_blank)
  406. q0 = q0.filter(~both_filled if phase == "pending" else both_filled)
  407. prc = (prod_review_conclusion or "").strip()
  408. if prc:
  409. q0 = q0.filter(BizStorageReleaseReview.prod_review_conclusion == prc)
  410. qrc = (qc_review_conclusion or "").strip()
  411. if qrc:
  412. q0 = q0.filter(BizStorageReleaseReview.qc_review_conclusion == qrc)
  413. rc = (review_conclusion or "").strip()
  414. if rc:
  415. q0 = q0.filter(BizStorageReleaseReview.review_conclusion == rc)
  416. ss = (storage_status or "").strip()
  417. if ss:
  418. q0 = q0.filter(BizStorageReleaseReview.storage_status == ss)
  419. kw = (keyword or "").strip()
  420. if kw:
  421. like = f"%{kw}%"
  422. kw_conds = [
  423. BizStorageReleaseReview.sample_code.like(like),
  424. BizStorageReleaseReview.sample_name.like(like),
  425. ]
  426. from app.crud import sample_reception as reception_svc
  427. rec_ids = reception_svc.reception_ids_matching_lab_storage_keyword(
  428. db, tenant_no, kw
  429. )
  430. if rec_ids:
  431. kw_conds.append(BizStorageReleaseReview.reception_id.in_(rec_ids))
  432. lab_codes = reception_svc.sample_codes_matching_lab_storage_keyword(db, tenant_no, kw)
  433. if lab_codes:
  434. kw_conds.append(BizStorageReleaseReview.sample_code.in_(lab_codes))
  435. q0 = q0.filter(or_(*kw_conds))
  436. qc_done = normalize_qc_work_order_completed(qc_work_order_completed)
  437. if qc_done is True:
  438. q0 = q0.filter(
  439. _qc_work_order_exists_for_review_sample(tenant_no, unfinished_only=False),
  440. ~_qc_work_order_exists_for_review_sample(tenant_no, unfinished_only=True),
  441. )
  442. elif qc_done is False:
  443. q0 = q0.filter(
  444. or_(
  445. ~_qc_work_order_exists_for_review_sample(
  446. tenant_no, unfinished_only=False
  447. ),
  448. _qc_work_order_exists_for_review_sample(
  449. tenant_no, unfinished_only=True
  450. ),
  451. )
  452. )
  453. if normalize_ready_for_review_only(ready_for_review_only) is True:
  454. # 无未完成质检/生产工单 ⇒ 该侧完成(含零工单)
  455. q0 = q0.filter(
  456. ~_qc_work_order_exists_for_review_sample(
  457. tenant_no, unfinished_only=True
  458. ),
  459. ~_production_work_order_unfinished_exists_for_review_sample(tenant_no),
  460. )
  461. # 协议无效 或 入库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻)
  462. q0 = q0.filter(
  463. ~or_(
  464. _inactive_protocol_exists_for_review(),
  465. _inbound_qc_all_void_for_review_sample(tenant_no),
  466. _inbound_production_all_void_for_review_sample(tenant_no),
  467. )
  468. )
  469. if user_info:
  470. from app.core.data_scope import apply_data_scope
  471. q0 = apply_data_scope(q0, BizStorageReleaseReview, user_info, db, tenant_no)
  472. total = q0.order_by(None).with_entities(func.count(BizStorageReleaseReview.id)).scalar() or 0
  473. page = max(1, page)
  474. page_size = min(max(1, page_size), 100)
  475. rows = (
  476. q0.order_by(BizStorageReleaseReview.create_time.desc())
  477. .offset((page - 1) * page_size)
  478. .limit(page_size)
  479. .all()
  480. )
  481. items = _enrich_review_list_items(db, [_row_to_dict(r) for r in rows])
  482. return {
  483. "list": items,
  484. "total": int(total),
  485. "page": page,
  486. "pageSize": page_size,
  487. "totalPages": total_pages(int(total), page_size),
  488. }
  489. def _enrich_review_list_items(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  490. if not items:
  491. return items
  492. from app.crud.qc_work_order import _enrich_work_order_list_items
  493. items = _enrich_work_order_list_items(db, items)
  494. cell_pids = [
  495. x["protocolId"]
  496. for x in items
  497. if (x.get("receptionSource") or "cell_storage") != "research_donation" and x.get("protocolId")
  498. ]
  499. donation_pids = [
  500. x["protocolId"]
  501. for x in items
  502. if (x.get("receptionSource") or "") == "research_donation" and x.get("protocolId")
  503. ]
  504. proto_map: Dict[str, BizCollectionProtocol] = {}
  505. if cell_pids:
  506. proto_map = {
  507. p.id: p
  508. for p in db.query(BizCollectionProtocol)
  509. .filter(BizCollectionProtocol.id.in_(cell_pids), BizCollectionProtocol.del_flag == 0)
  510. .all()
  511. }
  512. party_map: Dict[str, str] = {}
  513. if cell_pids:
  514. for party in (
  515. db.query(BizProtocolPartyCustomer)
  516. .filter(
  517. BizProtocolPartyCustomer.collection_protocol_id.in_(cell_pids),
  518. BizProtocolPartyCustomer.del_flag == 0,
  519. )
  520. .all()
  521. ):
  522. party_map[party.collection_protocol_id] = party.mother_name or ""
  523. donation_map: Dict[str, BizResearchDonation] = {}
  524. if donation_pids:
  525. donation_map = {
  526. d.id: d
  527. for d in db.query(BizResearchDonation)
  528. .filter(BizResearchDonation.id.in_(donation_pids), BizResearchDonation.del_flag == 0)
  529. .all()
  530. }
  531. dparty_map: Dict[str, str] = {}
  532. if donation_pids:
  533. for party in (
  534. db.query(BizResearchDonationParty)
  535. .filter(
  536. BizResearchDonationParty.research_donation_id.in_(donation_pids),
  537. BizResearchDonationParty.del_flag == 0,
  538. )
  539. .all()
  540. ):
  541. dparty_map[party.research_donation_id] = party.mother_name or ""
  542. for item in items:
  543. source = item.get("receptionSource") or "cell_storage"
  544. pid = item.get("protocolId") or ""
  545. item.setdefault("motherName", "")
  546. item.setdefault("protocolStatus", "")
  547. item.setdefault("protocolStatusLabel", "")
  548. if source == "research_donation":
  549. donation = donation_map.get(pid)
  550. if donation:
  551. st = donation.donation_status or ""
  552. vt = donation.void_type or ""
  553. item["protocolStatus"] = st
  554. item["voidType"] = vt
  555. from app.core.protocol_status_display import format_protocol_status_label
  556. item["protocolStatusLabel"] = format_protocol_status_label(
  557. st, vt, kind="donation", db=db, tenant_no=donation.tenant_no
  558. )
  559. item["motherName"] = dparty_map.get(pid, "")
  560. else:
  561. proto = proto_map.get(pid)
  562. if proto:
  563. st = getattr(proto, "protocol_status", "") or ""
  564. vt = getattr(proto, "void_type", "") or ""
  565. item["protocolStatus"] = st
  566. item["voidType"] = vt
  567. from app.core.protocol_status_display import format_protocol_status_label
  568. item["protocolStatusLabel"] = format_protocol_status_label(
  569. st, vt, kind="protocol", db=db, tenant_no=proto.tenant_no
  570. )
  571. item["motherName"] = party_map.get(pid, "")
  572. return items
  573. def _get_user_name(db: Session, user_id: str) -> str:
  574. u = db.query(SysUser).filter(SysUser.id == user_id).first()
  575. return (u.nick_name or "") if u else ""
  576. def get_review_detail(db: Session, review_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
  577. rev = (
  578. db.query(BizStorageReleaseReview)
  579. .filter(
  580. BizStorageReleaseReview.id == review_id,
  581. BizStorageReleaseReview.tenant_no == tenant_no,
  582. BizStorageReleaseReview.del_flag == 0,
  583. )
  584. .first()
  585. )
  586. if not rev:
  587. return None
  588. result: Dict[str, Any] = {"review": _row_to_dict(rev)}
  589. source = rev.reception_source or "cell_storage"
  590. if source == "cell_storage":
  591. protocol_info = _build_cell_storage_protocol_info(db, rev)
  592. result["protocolInfo"] = protocol_info
  593. transport_info = _build_cell_storage_transport_info(db, rev)
  594. result["transportInfo"] = transport_info
  595. else:
  596. protocol_info = _build_research_donation_protocol_info(db, rev)
  597. result["protocolInfo"] = protocol_info
  598. transport_info = _build_research_donation_transport_info(db, rev)
  599. result["transportInfo"] = transport_info
  600. from app.crud import sample_reception as sample_reception_svc
  601. reception_detail = sample_reception_svc.get_reception_by_id(
  602. db, rev.reception_id or "", tenant_no
  603. )
  604. if reception_detail:
  605. result["reception"] = reception_detail
  606. result["receptionInfo"] = {
  607. "receptionTime": reception_detail.get("receptionTime") or "",
  608. "handlerName": reception_detail.get("handlerName") or "",
  609. "receiveTemperature": reception_detail.get("receiveTemperature") or "",
  610. "volumeMl": reception_detail.get("volumeMl"),
  611. "netWeightG": reception_detail.get("netWeightG"),
  612. "acceptConclusion": reception_detail.get("acceptConclusion") or "",
  613. "acceptConclusionLabel": reception_detail.get("acceptConclusionLabel") or "",
  614. "labStorageCode": reception_detail.get("labStorageCode") or "",
  615. "remark": reception_detail.get("remark") or "",
  616. "checkResults": reception_detail.get("checkResults") or [],
  617. "receiveAttachments": reception_detail.get("receiveAttachments") or "",
  618. "receiveAttachmentFiles": reception_detail.get("receiveAttachmentFiles") or [],
  619. "transportType": reception_detail.get("transportType") or "",
  620. "expressCompanyName": reception_detail.get("expressCompanyName") or "",
  621. "waybillNo": reception_detail.get("waybillNo") or "",
  622. "carrierName": reception_detail.get("carrierName") or "",
  623. "carrierPhone": reception_detail.get("carrierPhone") or "",
  624. "etaTime": reception_detail.get("etaTime") or "",
  625. }
  626. else:
  627. result["receptionInfo"] = _build_reception_info(db, rev)
  628. result["qcInfo"] = _build_gate_qc_info(db, rev, tenant_no)
  629. result["processQcInfoList"] = _build_process_qc_info_list(db, rev, tenant_no)
  630. result["productionInfo"] = _build_production_info(db, rev, tenant_no)
  631. return result
  632. def _build_cell_storage_protocol_info(db: Session, rev: BizStorageReleaseReview) -> Dict[str, Any]:
  633. proto = (
  634. db.query(BizCollectionProtocol)
  635. .filter(
  636. BizCollectionProtocol.id == (rev.protocol_id or ""),
  637. BizCollectionProtocol.del_flag == 0,
  638. )
  639. .first()
  640. )
  641. if not proto:
  642. return {}
  643. sample = (
  644. db.query(BizProtocolSample)
  645. .filter(
  646. BizProtocolSample.collection_protocol_id == proto.id,
  647. BizProtocolSample.sample_code == (rev.sample_code or ""),
  648. BizProtocolSample.tenant_no == rev.tenant_no,
  649. BizProtocolSample.del_flag == 0,
  650. )
  651. .first()
  652. )
  653. party = (
  654. db.query(BizProtocolPartyCustomer)
  655. .filter(
  656. BizProtocolPartyCustomer.collection_protocol_id == proto.id,
  657. BizProtocolPartyCustomer.del_flag == 0,
  658. )
  659. .first()
  660. )
  661. info: Dict[str, Any] = {
  662. "protocolNo": proto.protocol_no or "",
  663. "protocolStatus": getattr(proto, "protocol_status", "") or "",
  664. "motherName": "",
  665. "motherPhone": "",
  666. "fatherName": "",
  667. "fatherPhone": "",
  668. "newbornName": "",
  669. "sampleCode": rev.sample_code or "",
  670. "sampleTypeName": "",
  671. "collectAmount": None,
  672. "storageYears": None,
  673. }
  674. if sample:
  675. from app.crud.qc_work_order import _load_sample_type_name_map
  676. type_map = _load_sample_type_name_map(db, [sample.sample_type_id]) if sample.sample_type_id else {}
  677. info["sampleTypeName"] = type_map.get(sample.sample_type_id or "", "")
  678. info["collectAmount"] = float(sample.collect_amount) if sample.collect_amount else None
  679. info["storageYears"] = sample.storage_years
  680. info.update(sample_special_to_dict(sample))
  681. if party:
  682. info["motherName"] = party.mother_name or ""
  683. info["motherPhone"] = party.mother_phone or ""
  684. info["fatherName"] = party.father_name or ""
  685. info["fatherPhone"] = party.father_phone or ""
  686. info["newbornName"] = party.newborn_name or ""
  687. return info
  688. def _build_research_donation_protocol_info(db: Session, rev: BizStorageReleaseReview) -> Dict[str, Any]:
  689. donation = (
  690. db.query(BizResearchDonation)
  691. .filter(
  692. BizResearchDonation.id == (rev.protocol_id or ""),
  693. BizResearchDonation.del_flag == 0,
  694. )
  695. .first()
  696. )
  697. if not donation:
  698. return {}
  699. dsample = (
  700. db.query(BizResearchDonationSample)
  701. .filter(
  702. BizResearchDonationSample.research_donation_id == donation.id,
  703. BizResearchDonationSample.sample_code == (rev.sample_code or ""),
  704. BizResearchDonationSample.tenant_no == rev.tenant_no,
  705. BizResearchDonationSample.del_flag == 0,
  706. )
  707. .first()
  708. )
  709. party = (
  710. db.query(BizResearchDonationParty)
  711. .filter(
  712. BizResearchDonationParty.research_donation_id == donation.id,
  713. BizResearchDonationParty.del_flag == 0,
  714. )
  715. .first()
  716. )
  717. info: Dict[str, Any] = {
  718. "protocolNo": donation.donation_no or "",
  719. "protocolStatus": donation.donation_status or "",
  720. "motherName": "",
  721. "motherPhone": "",
  722. "fatherName": "",
  723. "fatherPhone": "",
  724. "newbornName": "",
  725. "sampleCode": rev.sample_code or "",
  726. "sampleTypeName": "",
  727. "collectAmount": None,
  728. "storageYears": None,
  729. }
  730. if dsample:
  731. from app.crud.qc_work_order import _load_sample_type_name_map
  732. type_map = _load_sample_type_name_map(db, [dsample.sample_type_id]) if dsample.sample_type_id else {}
  733. info["sampleTypeName"] = type_map.get(dsample.sample_type_id or "", "")
  734. info["collectAmount"] = float(dsample.collect_amount) if dsample.collect_amount else None
  735. info["storageYears"] = dsample.storage_years
  736. info.update(sample_special_to_dict(dsample))
  737. if party:
  738. info["motherName"] = party.mother_name or ""
  739. info["motherPhone"] = party.mother_phone or ""
  740. info["fatherName"] = party.father_name or ""
  741. info["fatherPhone"] = party.father_phone or ""
  742. info["newbornName"] = party.newborn_name or ""
  743. return info
  744. def _build_cell_storage_transport_info(db: Session, rev: BizStorageReleaseReview) -> Dict[str, Any]:
  745. sample = (
  746. db.query(BizProtocolSample)
  747. .filter(
  748. BizProtocolSample.sample_code == (rev.sample_code or ""),
  749. BizProtocolSample.collection_protocol_id == (rev.protocol_id or ""),
  750. BizProtocolSample.tenant_no == rev.tenant_no,
  751. BizProtocolSample.del_flag == 0,
  752. )
  753. .first()
  754. )
  755. if not sample:
  756. return {}
  757. transport = (
  758. db.query(BizProtocolSampleTransport)
  759. .filter(
  760. BizProtocolSampleTransport.sample_id == sample.id,
  761. BizProtocolSampleTransport.collection_protocol_id == (rev.protocol_id or ""),
  762. BizProtocolSampleTransport.del_flag == 0,
  763. )
  764. .first()
  765. )
  766. if not transport:
  767. return {}
  768. return {
  769. "transportType": transport.transport_type or "",
  770. "waybillNo": transport.waybill_no or "",
  771. "expressFee": float(transport.express_fee) if transport.express_fee else None,
  772. "etaTime": _fmt_dt(transport.eta_time),
  773. "carrierName": transport.carrier_name or "",
  774. "carrierPhone": transport.carrier_phone or "",
  775. }
  776. def _build_research_donation_transport_info(db: Session, rev: BizStorageReleaseReview) -> Dict[str, Any]:
  777. dsample = (
  778. db.query(BizResearchDonationSample)
  779. .filter(
  780. BizResearchDonationSample.sample_code == (rev.sample_code or ""),
  781. BizResearchDonationSample.research_donation_id == (rev.protocol_id or ""),
  782. BizResearchDonationSample.tenant_no == rev.tenant_no,
  783. BizResearchDonationSample.del_flag == 0,
  784. )
  785. .first()
  786. )
  787. if not dsample:
  788. return {}
  789. transport = (
  790. db.query(BizResearchDonationSampleTransport)
  791. .filter(
  792. BizResearchDonationSampleTransport.sample_id == dsample.id,
  793. BizResearchDonationSampleTransport.research_donation_id == (rev.protocol_id or ""),
  794. BizResearchDonationSampleTransport.del_flag == 0,
  795. )
  796. .first()
  797. )
  798. if not transport:
  799. return {}
  800. return {
  801. "transportType": transport.transport_type or "",
  802. "waybillNo": transport.waybill_no or "",
  803. "expressFee": float(transport.express_fee) if transport.express_fee else None,
  804. "etaTime": _fmt_dt(transport.eta_time),
  805. "carrierName": transport.carrier_name or "",
  806. "carrierPhone": transport.carrier_phone or "",
  807. }
  808. def _build_reception_info(db: Session, rev: BizStorageReleaseReview) -> Dict[str, Any]:
  809. rec = (
  810. db.query(BizSampleReception)
  811. .filter(
  812. BizSampleReception.id == (rev.reception_id or ""),
  813. BizSampleReception.tenant_no == rev.tenant_no,
  814. BizSampleReception.del_flag == 0,
  815. )
  816. .first()
  817. )
  818. if not rec:
  819. return {}
  820. return {
  821. "receptionTime": _fmt_dt(rec.reception_time),
  822. "handlerName": rec.handler_name or "",
  823. "receiveTemperature": rec.receive_temperature or "",
  824. "volumeMl": float(rec.volume_ml) if rec.volume_ml else None,
  825. "netWeightG": float(rec.net_weight_g) if rec.net_weight_g else None,
  826. "acceptConclusion": rec.accept_conclusion or "",
  827. "labStorageCode": rec.lab_storage_code or "",
  828. }
  829. def _build_gate_qc_info(
  830. db: Session, rev: BizStorageReleaseReview, tenant_no: str
  831. ) -> Optional[Dict[str, Any]]:
  832. """入库放行门禁质检(仅 trigger_source=reception)。"""
  833. from app.crud import qc_work_order as qc_work_order_svc
  834. code = (rev.sample_code or "").strip()
  835. q = db.query(BizQcWorkOrder).filter(
  836. BizQcWorkOrder.tenant_no == tenant_no,
  837. BizQcWorkOrder.del_flag == 0,
  838. BizQcWorkOrder.trigger_source == "reception",
  839. )
  840. wo = q.filter(BizQcWorkOrder.sample_code == code).first() if code else None
  841. if not wo and rev.reception_id:
  842. wo = q.filter(BizQcWorkOrder.reception_id == rev.reception_id).first()
  843. if not wo:
  844. return None
  845. return qc_work_order_svc._qc_info_item(db, wo, tenant_no)
  846. def _build_process_qc_info_list(
  847. db: Session, rev: BizStorageReleaseReview, tenant_no: str
  848. ) -> List[Dict[str, Any]]:
  849. """生产过程触发的入库质检(只读展示,不参与放行判定)。"""
  850. from app.crud import qc_work_order as qc_work_order_svc
  851. return qc_work_order_svc.list_process_qc_info_for_sample(
  852. db, sample_code=rev.sample_code or "", tenant_no=tenant_no
  853. )
  854. def _build_production_info(
  855. db: Session, rev: BizStorageReleaseReview, tenant_no: str
  856. ) -> Optional[Dict[str, Any]]:
  857. from app.crud import production_work_order as production_work_order_svc
  858. code = (rev.sample_code or "").strip()
  859. q = db.query(BizProductionWorkOrder).filter(
  860. BizProductionWorkOrder.tenant_no == tenant_no,
  861. BizProductionWorkOrder.del_flag == 0,
  862. )
  863. pwo = q.filter(BizProductionWorkOrder.sample_code == code).first() if code else None
  864. if not pwo and rev.reception_id:
  865. pwo = q.filter(BizProductionWorkOrder.reception_id == rev.reception_id).first()
  866. if not pwo:
  867. return None
  868. return production_work_order_svc._production_info_item(db, pwo)
  869. def submit_review(
  870. db: Session,
  871. review_id: str,
  872. *,
  873. review_side: str,
  874. review_conclusion: str,
  875. review_remark: Optional[str] = None,
  876. review_attachments: Optional[str] = None,
  877. tenant_no: str,
  878. user_id: str,
  879. ) -> BizStorageReleaseReview:
  880. rev = (
  881. db.query(BizStorageReleaseReview)
  882. .filter(
  883. BizStorageReleaseReview.id == review_id,
  884. BizStorageReleaseReview.tenant_no == tenant_no,
  885. BizStorageReleaseReview.del_flag == 0,
  886. )
  887. .first()
  888. )
  889. if not rev:
  890. raise ValueError("审核记录不存在")
  891. side = normalize_review_side(review_side)
  892. if side not in ("production", "qc"):
  893. raise ValueError("审核侧无效")
  894. conclusion = (review_conclusion or "").strip()
  895. if conclusion not in ("tg", "btg"):
  896. raise ValueError("审核结论无效")
  897. user_name = _get_user_name(db, user_id)
  898. now = datetime.now()
  899. remark = (review_remark or "").strip() or None
  900. attachments = (review_attachments or "").strip() or None
  901. if side == "production":
  902. rev.prod_review_conclusion = conclusion
  903. rev.prod_review_remark = remark
  904. rev.prod_review_attachments = attachments
  905. rev.prod_review_by = user_id
  906. rev.prod_review_by_name = user_name
  907. rev.prod_review_time = now
  908. else:
  909. rev.qc_review_conclusion = conclusion
  910. rev.qc_review_remark = remark
  911. rev.qc_review_attachments = attachments
  912. rev.qc_review_by = user_id
  913. rev.qc_review_by_name = user_name
  914. rev.qc_review_time = now
  915. agg = aggregate_review_conclusion(rev.prod_review_conclusion, rev.qc_review_conclusion)
  916. rev.review_conclusion = agg
  917. rev.review_remark = remark
  918. rev.review_attachments = attachments
  919. rev.review_by = user_id
  920. rev.review_by_name = user_name
  921. rev.review_time = now
  922. rev.update_by = user_id
  923. rev.update_time = now
  924. db.add(rev)
  925. db.flush()
  926. action = storage_transition(
  927. rev.prod_review_conclusion, rev.qc_review_conclusion, rev.storage_status
  928. )
  929. if action == "store":
  930. store_sample(db, review_id, tenant_no=tenant_no, user_id=user_id)
  931. db.refresh(rev)
  932. elif action == "unstore":
  933. unstore_sample(db, review_id, tenant_no=tenant_no, user_id=user_id)
  934. db.refresh(rev)
  935. return rev
  936. def batch_submit_review(
  937. db: Session,
  938. ids: List[str],
  939. *,
  940. review_side: str,
  941. review_conclusion: str,
  942. review_remark: Optional[str] = None,
  943. review_attachments: Optional[str] = None,
  944. tenant_no: str,
  945. user_id: str,
  946. ) -> Dict[str, Any]:
  947. success = 0
  948. fail = 0
  949. for rid in ids:
  950. try:
  951. submit_review(
  952. db,
  953. rid,
  954. review_side=review_side,
  955. review_conclusion=review_conclusion,
  956. review_remark=review_remark,
  957. review_attachments=review_attachments,
  958. tenant_no=tenant_no,
  959. user_id=user_id,
  960. )
  961. success += 1
  962. except Exception:
  963. fail += 1
  964. return {"success": success, "fail": fail}
  965. def store_sample(
  966. db: Session,
  967. review_id: str,
  968. *,
  969. tenant_no: str,
  970. user_id: str,
  971. ) -> BizStorageReleaseReview:
  972. rev = (
  973. db.query(BizStorageReleaseReview)
  974. .filter(
  975. BizStorageReleaseReview.id == review_id,
  976. BizStorageReleaseReview.tenant_no == tenant_no,
  977. BizStorageReleaseReview.del_flag == 0,
  978. )
  979. .first()
  980. )
  981. if not rev:
  982. raise ValueError("审核记录不存在")
  983. if aggregate_review_conclusion(rev.prod_review_conclusion, rev.qc_review_conclusion) != "tg":
  984. raise ValueError("生产与质检均通过后才能入库")
  985. if rev.storage_status != "drk":
  986. raise ValueError("当前状态不允许入库操作")
  987. user_name = _get_user_name(db, user_id)
  988. rev.storage_status = "yrk"
  989. rev.storage_by = user_id
  990. rev.storage_by_name = user_name
  991. rev.storage_time = datetime.now()
  992. rev.update_by = user_id
  993. rev.update_time = datetime.now()
  994. db.add(rev)
  995. storage_in_yyyymmdd = rev.storage_time.strftime("%Y%m%d")
  996. if (rev.reception_source or "cell_storage") == "cell_storage":
  997. sample_code = (rev.sample_code or "").strip()
  998. if sample_code:
  999. ps = (
  1000. db.query(BizProtocolSample)
  1001. .filter(
  1002. BizProtocolSample.sample_code == sample_code,
  1003. BizProtocolSample.tenant_no == tenant_no,
  1004. BizProtocolSample.del_flag == 0,
  1005. )
  1006. .first()
  1007. )
  1008. if ps:
  1009. ps.sample_status = "yrk"
  1010. ps.storage_in_date = storage_in_yyyymmdd
  1011. ps.update_time = datetime.now()
  1012. db.add(ps)
  1013. db.flush()
  1014. return rev
  1015. def unstore_sample(
  1016. db: Session,
  1017. review_id: str,
  1018. *,
  1019. tenant_no: str,
  1020. user_id: str,
  1021. ) -> BizStorageReleaseReview:
  1022. rev = (
  1023. db.query(BizStorageReleaseReview)
  1024. .filter(
  1025. BizStorageReleaseReview.id == review_id,
  1026. BizStorageReleaseReview.tenant_no == tenant_no,
  1027. BizStorageReleaseReview.del_flag == 0,
  1028. )
  1029. .first()
  1030. )
  1031. if not rev:
  1032. raise ValueError("审核记录不存在")
  1033. if (rev.storage_status or "") != "yrk":
  1034. return rev
  1035. rev.storage_status = "drk"
  1036. rev.storage_by = None
  1037. rev.storage_by_name = None
  1038. rev.storage_time = None
  1039. rev.update_by = user_id
  1040. rev.update_time = datetime.now()
  1041. db.add(rev)
  1042. if (rev.reception_source or "cell_storage") == "cell_storage":
  1043. sample_code = (rev.sample_code or "").strip()
  1044. if sample_code:
  1045. ps = (
  1046. db.query(BizProtocolSample)
  1047. .filter(
  1048. BizProtocolSample.sample_code == sample_code,
  1049. BizProtocolSample.tenant_no == tenant_no,
  1050. BizProtocolSample.del_flag == 0,
  1051. )
  1052. .first()
  1053. )
  1054. if ps and (ps.sample_status or "") == "yrk":
  1055. ps.sample_status = "jcz"
  1056. ps.storage_in_date = None
  1057. ps.update_time = datetime.now()
  1058. db.add(ps)
  1059. db.flush()
  1060. return rev
  1061. def batch_store_samples(
  1062. db: Session,
  1063. ids: List[str],
  1064. *,
  1065. tenant_no: str,
  1066. user_id: str,
  1067. ) -> Dict[str, Any]:
  1068. success = 0
  1069. fail = 0
  1070. for rid in ids:
  1071. try:
  1072. store_sample(db, rid, tenant_no=tenant_no, user_id=user_id)
  1073. success += 1
  1074. except Exception:
  1075. fail += 1
  1076. return {"success": success, "fail": fail}
  1077. def get_storage_release_map_for_samples(
  1078. db: Session,
  1079. sample_ids: List[str],
  1080. tenant_no: str,
  1081. *,
  1082. sample_code_by_id: Optional[Dict[str, str]] = None,
  1083. ) -> Dict[str, Dict[str, Any]]:
  1084. """按样本批量返回存储放行审核摘要。
  1085. 当传入 sample_code_by_id 时,按租户 + 样本编码关联存储放行审核记录,
  1086. 返回以 sample_id 为 key 的审核信息映射。
  1087. """
  1088. if not tenant_no or not sample_code_by_id:
  1089. return {}
  1090. codes = list({(c or "").strip() for c in sample_code_by_id.values() if (c or "").strip()})
  1091. if not codes:
  1092. return {}
  1093. rows = (
  1094. db.query(BizStorageReleaseReview)
  1095. .filter(
  1096. BizStorageReleaseReview.tenant_no == tenant_no,
  1097. BizStorageReleaseReview.del_flag == 0,
  1098. BizStorageReleaseReview.sample_code.in_(codes),
  1099. )
  1100. .all()
  1101. )
  1102. code_to_review: Dict[str, BizStorageReleaseReview] = {}
  1103. for r in rows:
  1104. code = (r.sample_code or "").strip()
  1105. if code and code not in code_to_review:
  1106. code_to_review[code] = r
  1107. out: Dict[str, Dict[str, Any]] = {}
  1108. for sid, raw_code in sample_code_by_id.items():
  1109. code = (raw_code or "").strip()
  1110. if not code:
  1111. continue
  1112. rev = code_to_review.get(code)
  1113. if not rev:
  1114. continue
  1115. rc = rev.review_conclusion or ""
  1116. out[sid] = {
  1117. "reviewConclusion": rc,
  1118. "reviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(rc, rc),
  1119. "reviewByName": rev.review_by_name or "",
  1120. "reviewTime": _fmt_dt(rev.review_time),
  1121. "reviewRemark": rev.review_remark or "",
  1122. "reviewAttachments": rev.review_attachments or "",
  1123. "qcReviewConclusion": rev.qc_review_conclusion or "",
  1124. "qcReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(rev.qc_review_conclusion or "", rev.qc_review_conclusion or ""),
  1125. "qcReviewByName": rev.qc_review_by_name or "",
  1126. "qcReviewTime": _fmt_dt(rev.qc_review_time),
  1127. "qcReviewRemark": rev.qc_review_remark or "",
  1128. "qcReviewAttachments": rev.qc_review_attachments or "",
  1129. "prodReviewConclusion": rev.prod_review_conclusion or "",
  1130. "prodReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(rev.prod_review_conclusion or "", rev.prod_review_conclusion or ""),
  1131. "prodReviewByName": rev.prod_review_by_name or "",
  1132. "prodReviewTime": _fmt_dt(rev.prod_review_time),
  1133. "prodReviewRemark": rev.prod_review_remark or "",
  1134. "prodReviewAttachments": rev.prod_review_attachments or "",
  1135. "storageStatus": rev.storage_status or "drk",
  1136. "storageStatusLabel": _STORAGE_STATUS_LABELS.get(rev.storage_status or "drk", rev.storage_status or "drk"),
  1137. }
  1138. return out