sample_code_correction.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. """样本编码全链路纠错:预览影响面 + 事务级联更新。"""
  2. from __future__ import annotations
  3. import json
  4. from datetime import datetime
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy.orm import Session
  7. from app.core.utils import generate_uuid
  8. from app.models.models import (
  9. BizCellProduct,
  10. BizProductionTaskInput,
  11. BizProductionWorkOrder,
  12. BizProtocolPaymentSchedule,
  13. BizProtocolPaymentTransfer,
  14. BizProtocolSample,
  15. BizProtocolSamplePayment,
  16. BizProtocolSampleTransport,
  17. BizQcTask,
  18. BizQcWorkOrder,
  19. BizSampleCode,
  20. BizSampleCodeCorrectionLog,
  21. BizSampleCustomerService,
  22. BizSampleFreightItem,
  23. BizSampleInsurance,
  24. BizSampleProductionReception,
  25. BizSampleReception,
  26. BizSampleStorageCert,
  27. BizSampleStorageCertQc,
  28. BizStorageReleaseReview,
  29. )
  30. from app.models.research_donation import (
  31. BizResearchDonationSample,
  32. BizResearchDonationSampleTransport,
  33. )
  34. SOURCE_CELL = "cell_storage"
  35. SOURCE_DONATION = "research_donation"
  36. VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
  37. # (key, label, model, code_attr, has_reception_source, cell_only)
  38. _TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
  39. ("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
  40. ("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
  41. ("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
  42. ("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
  43. (
  44. "biz_research_donation_sample_transport",
  45. "捐赠样本运输",
  46. BizResearchDonationSampleTransport,
  47. "sample_code",
  48. False,
  49. False,
  50. ),
  51. ("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
  52. ("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
  53. ("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
  54. ("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
  55. ("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
  56. ("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
  57. (
  58. "biz_sample_production_reception",
  59. "生产部门接收",
  60. BizSampleProductionReception,
  61. "sample_code",
  62. True,
  63. False,
  64. ),
  65. ("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
  66. ("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
  67. ("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
  68. ("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
  69. ]
  70. _UNIQUE_TABLES = {
  71. "biz_sample_storage_cert",
  72. "biz_sample_insurance",
  73. "biz_sample_customer_service",
  74. "biz_sample_storage_cert_qc",
  75. }
  76. class SampleCodeCorrectionError(ValueError):
  77. """业务校验失败(可直接返回给前端)。"""
  78. def _norm(code: Optional[str]) -> str:
  79. return (code or "").strip()
  80. def _now() -> datetime:
  81. return datetime.now()
  82. def _load_source_sample(
  83. db: Session,
  84. *,
  85. tenant_no: str,
  86. reception_source: str,
  87. sample_code: str,
  88. ):
  89. code = _norm(sample_code)
  90. if reception_source == SOURCE_CELL:
  91. return (
  92. db.query(BizProtocolSample)
  93. .filter(
  94. BizProtocolSample.tenant_no == tenant_no,
  95. BizProtocolSample.del_flag == 0,
  96. BizProtocolSample.sample_code == code,
  97. )
  98. .first()
  99. )
  100. return (
  101. db.query(BizResearchDonationSample)
  102. .filter(
  103. BizResearchDonationSample.tenant_no == tenant_no,
  104. BizResearchDonationSample.del_flag == 0,
  105. BizResearchDonationSample.sample_code == code,
  106. )
  107. .first()
  108. )
  109. def _code_used_by_other_sample(
  110. db: Session,
  111. *,
  112. tenant_no: str,
  113. sample_code: str,
  114. exclude_sample_id: Optional[str],
  115. ) -> bool:
  116. code = _norm(sample_code)
  117. q1 = db.query(BizProtocolSample.id).filter(
  118. BizProtocolSample.tenant_no == tenant_no,
  119. BizProtocolSample.del_flag == 0,
  120. BizProtocolSample.sample_code == code,
  121. )
  122. q2 = db.query(BizResearchDonationSample.id).filter(
  123. BizResearchDonationSample.tenant_no == tenant_no,
  124. BizResearchDonationSample.del_flag == 0,
  125. BizResearchDonationSample.sample_code == code,
  126. )
  127. if exclude_sample_id:
  128. q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
  129. q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
  130. return q1.first() is not None or q2.first() is not None
  131. def _count_by_code(
  132. db: Session,
  133. model,
  134. *,
  135. tenant_no: str,
  136. code: str,
  137. code_attr: str = "sample_code",
  138. reception_source: Optional[str] = None,
  139. has_reception_source: bool = False,
  140. ) -> int:
  141. col = getattr(model, code_attr)
  142. q = db.query(model).filter(
  143. model.tenant_no == tenant_no,
  144. model.del_flag == 0,
  145. col == code,
  146. )
  147. if has_reception_source and reception_source:
  148. q = q.filter(model.reception_source == reception_source)
  149. return int(q.count())
  150. def _update_by_code(
  151. db: Session,
  152. model,
  153. *,
  154. tenant_no: str,
  155. old_code: str,
  156. new_code: str,
  157. code_attr: str = "sample_code",
  158. reception_source: Optional[str] = None,
  159. has_reception_source: bool = False,
  160. user_id: Optional[str] = None,
  161. ) -> int:
  162. col = getattr(model, code_attr)
  163. q = db.query(model).filter(
  164. model.tenant_no == tenant_no,
  165. model.del_flag == 0,
  166. col == old_code,
  167. )
  168. if has_reception_source and reception_source:
  169. q = q.filter(model.reception_source == reception_source)
  170. rows = q.all()
  171. now = _now()
  172. for row in rows:
  173. setattr(row, code_attr, new_code)
  174. if hasattr(row, "update_by"):
  175. row.update_by = user_id
  176. if hasattr(row, "update_time"):
  177. row.update_time = now
  178. return len(rows)
  179. def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
  180. return int(
  181. db.query(BizProductionTaskInput)
  182. .filter(
  183. BizProductionTaskInput.tenant_no == tenant_no,
  184. BizProductionTaskInput.del_flag == 0,
  185. BizProductionTaskInput.input_type == "sample",
  186. BizProductionTaskInput.input_code == code,
  187. )
  188. .count()
  189. )
  190. def _update_task_inputs(
  191. db: Session,
  192. *,
  193. tenant_no: str,
  194. old_code: str,
  195. new_code: str,
  196. user_id: Optional[str],
  197. ) -> int:
  198. rows = (
  199. db.query(BizProductionTaskInput)
  200. .filter(
  201. BizProductionTaskInput.tenant_no == tenant_no,
  202. BizProductionTaskInput.del_flag == 0,
  203. BizProductionTaskInput.input_type == "sample",
  204. BizProductionTaskInput.input_code == old_code,
  205. )
  206. .all()
  207. )
  208. now = _now()
  209. for row in rows:
  210. row.input_code = new_code
  211. row.update_by = user_id
  212. row.update_time = now
  213. return len(rows)
  214. def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
  215. return int(
  216. db.query(BizCellProduct)
  217. .filter(
  218. BizCellProduct.tenant_no == tenant_no,
  219. BizCellProduct.del_flag == 0,
  220. BizCellProduct.product_code == code,
  221. )
  222. .count()
  223. )
  224. def _unique_conflict(
  225. db: Session,
  226. model,
  227. *,
  228. tenant_no: str,
  229. new_code: str,
  230. ) -> bool:
  231. return (
  232. db.query(model.id)
  233. .filter(
  234. model.tenant_no == tenant_no,
  235. model.del_flag == 0,
  236. model.sample_code == new_code,
  237. )
  238. .first()
  239. is not None
  240. )
  241. def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
  242. if reception_source == SOURCE_CELL:
  243. if table_key == "biz_research_donation_sample_transport":
  244. return False
  245. return True
  246. # donation
  247. if cell_only:
  248. return False
  249. if table_key.startswith("biz_protocol_"):
  250. return False
  251. return True
  252. def preview_correction(
  253. db: Session,
  254. *,
  255. tenant_no: str,
  256. reception_source: str,
  257. old_sample_code: str,
  258. new_sample_code: str,
  259. new_sample_code_id: Optional[str] = None,
  260. ) -> Dict[str, Any]:
  261. source = _norm(reception_source) or SOURCE_CELL
  262. old_code = _norm(old_sample_code)
  263. new_code = _norm(new_sample_code)
  264. blockers: List[str] = []
  265. if source not in VALID_SOURCES:
  266. blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
  267. if not old_code:
  268. blockers.append("旧样本编码不能为空")
  269. if not new_code:
  270. blockers.append("新样本编码不能为空")
  271. if old_code and new_code and old_code == new_code:
  272. blockers.append("新样本编码不能与旧编码相同")
  273. sample = None
  274. if not blockers:
  275. sample = _load_source_sample(
  276. db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
  277. )
  278. if sample is None:
  279. blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
  280. elif _code_used_by_other_sample(
  281. db,
  282. tenant_no=tenant_no,
  283. sample_code=new_code,
  284. exclude_sample_id=sample.id,
  285. ):
  286. blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
  287. impacts: List[Dict[str, Any]] = []
  288. if sample is not None and not blockers:
  289. sot_key = (
  290. "biz_protocol_sample"
  291. if source == SOURCE_CELL
  292. else "biz_research_donation_sample"
  293. )
  294. impacts.append(
  295. {
  296. "table": sot_key,
  297. "label": "样本赋值源表",
  298. "count": 1,
  299. }
  300. )
  301. pool_count = 0
  302. if source == SOURCE_CELL:
  303. pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
  304. if pool_id:
  305. pool_row = (
  306. db.query(BizSampleCode)
  307. .filter(
  308. BizSampleCode.tenant_no == tenant_no,
  309. BizSampleCode.del_flag == 0,
  310. BizSampleCode.id == pool_id,
  311. )
  312. .first()
  313. )
  314. if pool_row and _norm(pool_row.sample_code) == old_code:
  315. pool_count = 1
  316. elif not pool_row and _norm(new_sample_code_id):
  317. blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
  318. existing_new_pool = (
  319. db.query(BizSampleCode)
  320. .filter(
  321. BizSampleCode.tenant_no == tenant_no,
  322. BizSampleCode.del_flag == 0,
  323. BizSampleCode.sample_code == new_code,
  324. )
  325. .first()
  326. )
  327. if existing_new_pool and pool_count == 0:
  328. impacts.append(
  329. {
  330. "table": "biz_sample_code",
  331. "label": "编码池(切换占用)",
  332. "count": 1,
  333. }
  334. )
  335. elif pool_count:
  336. impacts.append(
  337. {
  338. "table": "biz_sample_code",
  339. "label": "编码池(就地改码)",
  340. "count": pool_count,
  341. }
  342. )
  343. for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
  344. if not _spec_applies(source, cell_only, key):
  345. continue
  346. cnt = _count_by_code(
  347. db,
  348. model,
  349. tenant_no=tenant_no,
  350. code=old_code,
  351. code_attr=attr,
  352. reception_source=source if has_rs else None,
  353. has_reception_source=has_rs,
  354. )
  355. if cnt:
  356. impacts.append({"table": key, "label": label, "count": cnt})
  357. if (
  358. key in _UNIQUE_TABLES
  359. and source == SOURCE_CELL
  360. and cnt > 0
  361. and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
  362. ):
  363. blockers.append(
  364. f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
  365. )
  366. ti = _count_task_inputs(db, tenant_no, old_code)
  367. if ti:
  368. impacts.append(
  369. {
  370. "table": "biz_production_task_input",
  371. "label": "生产任务投入物(样本)",
  372. "count": ti,
  373. }
  374. )
  375. same_product_old = (
  376. _count_same_product_code(db, tenant_no, old_code) if old_code else 0
  377. )
  378. same_product_new = (
  379. _count_same_product_code(db, tenant_no, new_code) if new_code else 0
  380. )
  381. total = sum(int(i.get("count") or 0) for i in impacts)
  382. return {
  383. "ok": len(blockers) == 0,
  384. "blockers": blockers,
  385. "receptionSource": source,
  386. "oldSampleCode": old_code,
  387. "newSampleCode": new_code,
  388. "sampleId": getattr(sample, "id", None) if sample else None,
  389. "impacts": impacts,
  390. "totalAffectedRows": total,
  391. "hints": {
  392. "sameProductCodeAsOld": same_product_old,
  393. "sameProductCodeAsNew": same_product_new,
  394. "labStorageCodeUnchanged": True,
  395. "cellProductCodeNotAutoUpdated": True,
  396. },
  397. }
  398. def _resolve_pool(
  399. db: Session,
  400. *,
  401. tenant_no: str,
  402. sample: BizProtocolSample,
  403. old_code: str,
  404. new_code: str,
  405. new_sample_code_id: Optional[str],
  406. user_id: Optional[str],
  407. ) -> Dict[str, Any]:
  408. """返回 pool 处理结果摘要。"""
  409. now = _now()
  410. result = {"action": "none", "poolId": None}
  411. preferred_id = _norm(new_sample_code_id)
  412. existing_new = (
  413. db.query(BizSampleCode)
  414. .filter(
  415. BizSampleCode.tenant_no == tenant_no,
  416. BizSampleCode.del_flag == 0,
  417. BizSampleCode.sample_code == new_code,
  418. )
  419. .first()
  420. )
  421. if preferred_id:
  422. preferred = (
  423. db.query(BizSampleCode)
  424. .filter(
  425. BizSampleCode.tenant_no == tenant_no,
  426. BizSampleCode.del_flag == 0,
  427. BizSampleCode.id == preferred_id,
  428. )
  429. .first()
  430. )
  431. if preferred is None:
  432. raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
  433. if _norm(preferred.sample_code) not in {new_code, old_code}:
  434. # 允许指向已是新码的池行,或指向即将就地改码的旧池行
  435. if _norm(preferred.sample_code) != new_code:
  436. preferred.sample_code = new_code
  437. preferred.update_by = user_id
  438. preferred.update_time = now
  439. result["action"] = "rename_preferred"
  440. existing_new = preferred
  441. old_pool_id = _norm(getattr(sample, "sample_code_id", None))
  442. old_pool = None
  443. if old_pool_id:
  444. old_pool = (
  445. db.query(BizSampleCode)
  446. .filter(
  447. BizSampleCode.tenant_no == tenant_no,
  448. BizSampleCode.del_flag == 0,
  449. BizSampleCode.id == old_pool_id,
  450. )
  451. .first()
  452. )
  453. if existing_new is not None:
  454. sample.sample_code_id = existing_new.id
  455. sample.sample_code = new_code
  456. existing_new.issued = 1
  457. existing_new.update_by = user_id
  458. existing_new.update_time = now
  459. result["action"] = result["action"] if result["action"] != "none" else "reassign"
  460. result["poolId"] = existing_new.id
  461. if (
  462. old_pool
  463. and old_pool.id != existing_new.id
  464. and _norm(old_pool.sample_code) == old_code
  465. ):
  466. still_used = (
  467. db.query(BizProtocolSample.id)
  468. .filter(
  469. BizProtocolSample.tenant_no == tenant_no,
  470. BizProtocolSample.del_flag == 0,
  471. BizProtocolSample.sample_code_id == old_pool.id,
  472. BizProtocolSample.id != sample.id,
  473. )
  474. .first()
  475. )
  476. if still_used is None:
  477. old_pool.issued = 0
  478. old_pool.update_by = user_id
  479. old_pool.update_time = now
  480. return result
  481. if old_pool and _norm(old_pool.sample_code) == old_code:
  482. old_pool.sample_code = new_code
  483. old_pool.issued = 1
  484. old_pool.update_by = user_id
  485. old_pool.update_time = now
  486. sample.sample_code = new_code
  487. sample.sample_code_id = old_pool.id
  488. result["action"] = "rename_inplace"
  489. result["poolId"] = old_pool.id
  490. return result
  491. sample.sample_code = new_code
  492. result["action"] = "sample_text_only"
  493. return result
  494. def apply_correction(
  495. db: Session,
  496. *,
  497. tenant_no: str,
  498. reception_source: str,
  499. old_sample_code: str,
  500. new_sample_code: str,
  501. new_sample_code_id: Optional[str] = None,
  502. reason: Optional[str] = None,
  503. user_id: Optional[str] = None,
  504. ) -> Dict[str, Any]:
  505. preview = preview_correction(
  506. db,
  507. tenant_no=tenant_no,
  508. reception_source=reception_source,
  509. old_sample_code=old_sample_code,
  510. new_sample_code=new_sample_code,
  511. new_sample_code_id=new_sample_code_id,
  512. )
  513. if not preview.get("ok"):
  514. raise SampleCodeCorrectionError(
  515. ";".join(preview.get("blockers") or ["校验未通过"])
  516. )
  517. source = preview["receptionSource"]
  518. old_code = preview["oldSampleCode"]
  519. new_code = preview["newSampleCode"]
  520. sample = _load_source_sample(
  521. db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
  522. )
  523. if sample is None:
  524. raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
  525. now = _now()
  526. impact_counts: Dict[str, int] = {}
  527. if source == SOURCE_CELL:
  528. pool_info = _resolve_pool(
  529. db,
  530. tenant_no=tenant_no,
  531. sample=sample,
  532. old_code=old_code,
  533. new_code=new_code,
  534. new_sample_code_id=new_sample_code_id,
  535. user_id=user_id,
  536. )
  537. impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
  538. sample.update_by = user_id
  539. sample.update_time = now
  540. impact_counts["biz_protocol_sample"] = 1
  541. else:
  542. sample.sample_code = new_code
  543. sample.update_by = user_id
  544. sample.update_time = now
  545. impact_counts["biz_research_donation_sample"] = 1
  546. # UNIQUE 表先更新,避免其它逻辑读到半新半旧
  547. for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
  548. if key not in _UNIQUE_TABLES:
  549. continue
  550. if not _spec_applies(source, cell_only, key):
  551. continue
  552. n = _update_by_code(
  553. db,
  554. model,
  555. tenant_no=tenant_no,
  556. old_code=old_code,
  557. new_code=new_code,
  558. code_attr=attr,
  559. reception_source=source if has_rs else None,
  560. has_reception_source=has_rs,
  561. user_id=user_id,
  562. )
  563. if n:
  564. impact_counts[key] = n
  565. for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
  566. if key in _UNIQUE_TABLES:
  567. continue
  568. if not _spec_applies(source, cell_only, key):
  569. continue
  570. n = _update_by_code(
  571. db,
  572. model,
  573. tenant_no=tenant_no,
  574. old_code=old_code,
  575. new_code=new_code,
  576. code_attr=attr,
  577. reception_source=source if has_rs else None,
  578. has_reception_source=has_rs,
  579. user_id=user_id,
  580. )
  581. if n:
  582. impact_counts[key] = n
  583. ti = _update_task_inputs(
  584. db,
  585. tenant_no=tenant_no,
  586. old_code=old_code,
  587. new_code=new_code,
  588. user_id=user_id,
  589. )
  590. if ti:
  591. impact_counts["biz_production_task_input"] = ti
  592. log = BizSampleCodeCorrectionLog(
  593. id=generate_uuid(),
  594. reception_source=source,
  595. old_sample_code=old_code,
  596. new_sample_code=new_code,
  597. impact_json=json.dumps(impact_counts, ensure_ascii=False),
  598. reason=_norm(reason) or None,
  599. tenant_no=tenant_no,
  600. order_num=0,
  601. create_by=user_id,
  602. create_dept_id="",
  603. create_time=now,
  604. update_by=user_id,
  605. update_time=now,
  606. del_flag=0,
  607. )
  608. db.add(log)
  609. db.commit()
  610. return {
  611. "ok": True,
  612. "logId": log.id,
  613. "receptionSource": source,
  614. "oldSampleCode": old_code,
  615. "newSampleCode": new_code,
  616. "impactCounts": impact_counts,
  617. "totalAffectedRows": sum(impact_counts.values()),
  618. "hints": preview.get("hints") or {},
  619. }