qc_task.py 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  1. """质检任务 CRUD"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import and_, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.crud.collection_protocol import lookup_attachments_by_urls, urls_to_attachment_list
  10. from app.crud import sample_reception as sample_reception_svc
  11. from app.crud.qc_work_order import (
  12. _load_sample_type_name_map,
  13. _qc_biz_type_label,
  14. _reception_source_label,
  15. _resolve_reception_id_for_work_order_detail,
  16. _sample_type_name_for_reception,
  17. _work_order_qc_biz_type,
  18. _OUTBOUND_BIZ_TYPE_LABELS,
  19. build_work_order_list_summaries,
  20. )
  21. from app.core.db_collation import eq_unicode, unicode_ci
  22. from app.utils.sample_special import apply_special_to_item_from_samples
  23. from app.crud import qc_task_assignee as assignee_svc
  24. from app.crud.qc_inbound_lab_storage_sort import (
  25. inbound_lab_storage_order_by_clauses,
  26. uses_inbound_lab_storage_list_sort,
  27. )
  28. from app.models.models import (
  29. BizAttachment,
  30. BizCollectionProtocol,
  31. BizProtocolSample,
  32. BizQcTask,
  33. BizQcTaskAssignee,
  34. BizQcWorkOrder,
  35. BizQcWorkOrderLabItem,
  36. BizQcWorkOrderThirdItem,
  37. BizSampleReception,
  38. )
  39. from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
  40. def _dec(v: Any) -> Optional[float]:
  41. if v is None:
  42. return None
  43. return float(v)
  44. def _fmt_dt(t: Optional[datetime]) -> str:
  45. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  46. def _gen_task_no(db: Session, work_order, seq: int) -> str:
  47. return f"{work_order.work_order_no}-{seq:02d}"
  48. _CONCLUSION_LABELS = {"tg": "通过", "btg": "不通过"}
  49. _PROTOCOL_STATUS_LABELS = {
  50. "dsx": "待生效", "sxz": "生效中", "ygq": "已过期", "yzf": "已作废", "sdfq": "属地废弃", "zky": "转科研"
  51. }
  52. _DONATION_STATUS_LABELS = {
  53. "dsx": "待生效", "sxz": "生效中", "ygq": "已过期", "yzf": "已作废", "sdfq": "属地废弃", "zckc": "转细胞存储"
  54. }
  55. def _conclusion_label(v: Optional[str]) -> str:
  56. key = (v or "").strip()
  57. return _CONCLUSION_LABELS.get(key, key)
  58. def _split_attachment_urls(urls_str: Optional[str]) -> List[str]:
  59. if not urls_str:
  60. return []
  61. return [u.strip() for u in urls_str.split(",") if u and u.strip()]
  62. def _task_to_dict(
  63. t: BizQcTask,
  64. work_order_no: str = "",
  65. db: Optional[Session] = None,
  66. tenant_no: Optional[str] = None,
  67. *,
  68. attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
  69. ) -> Dict[str, Any]:
  70. tn = tenant_no or t.tenant_no
  71. if db:
  72. attachment_files = urls_to_attachment_list(
  73. t.third_party_attachments,
  74. db,
  75. tn,
  76. attachment_by_url=attachment_by_url,
  77. )
  78. else:
  79. attachment_files = []
  80. return {
  81. "id": t.id,
  82. "workOrderId": t.work_order_id,
  83. "batchId": getattr(t, "batch_id", None) or "",
  84. "orderSource": getattr(t, "order_source", "inbound") or "inbound",
  85. "workOrderNo": work_order_no,
  86. "taskNo": t.task_no,
  87. "taskType": t.task_type,
  88. "sourceLineId": t.source_line_id,
  89. "itemCode": t.item_code,
  90. "itemName": t.item_name,
  91. "companyId": t.company_id or "",
  92. "companyName": t.company_name or "",
  93. "feeAmount": _dec(t.fee_amount),
  94. "taskStatus": t.task_status,
  95. "conclusion": t.conclusion or "",
  96. "conclusionLabel": _conclusion_label(t.conclusion),
  97. "thirdPartyAttachments": t.third_party_attachments or "",
  98. "attachmentFiles": attachment_files,
  99. "completedTime": _fmt_dt(t.completed_time),
  100. "completedBy": t.completed_by or "",
  101. "remark": t.remark or "",
  102. "createTime": _fmt_dt(t.create_time),
  103. "updateTime": _fmt_dt(t.update_time),
  104. }
  105. def _task_work_order_context(wo: BizQcWorkOrder, t: BizQcTask) -> Dict[str, Any]:
  106. biz = _work_order_qc_biz_type(wo)
  107. wo_type = getattr(wo, "work_order_type", None) or "inbound"
  108. if wo_type == "temporary":
  109. order_source = "temporary"
  110. recv_source = ""
  111. recv_label = ""
  112. elif wo_type == "outbound":
  113. order_source = "outbound"
  114. recv_source = getattr(wo, "biz_type", "") or ""
  115. recv_label = _OUTBOUND_BIZ_TYPE_LABELS.get(recv_source, recv_source) if recv_source else ""
  116. else:
  117. order_source = getattr(t, "order_source", "inbound") or "inbound"
  118. recv_source = getattr(wo, "reception_source", "cell_storage") or "cell_storage"
  119. recv_label = _reception_source_label(recv_source)
  120. return {
  121. "orderSource": order_source,
  122. "receptionId": wo.reception_id,
  123. "protocolNo": wo.protocol_no or "",
  124. "sampleCode": wo.sample_code or t.sample_code or "",
  125. "qcBizType": biz,
  126. "qcBizTypeLabel": _qc_biz_type_label(biz),
  127. "receptionSource": recv_source,
  128. "receptionSourceLabel": recv_label,
  129. "cellProductId": getattr(wo, "cell_product_id", None) or "",
  130. "cellProductCode": "",
  131. "cellProductName": "",
  132. "cellProductTypeName": "",
  133. "cellProductPassage": "",
  134. "cellProductSpecName": "",
  135. "receptionTime": "",
  136. "handlerName": "",
  137. "sampleTypeName": "",
  138. "workOrderStatus": wo.work_order_status or "",
  139. }
  140. _WORK_ORDER_SUMMARY_COPY_KEYS = (
  141. "triggerSource",
  142. "triggerSourceLabel",
  143. "hasLab",
  144. "hasThirdParty",
  145. "labStorageCode",
  146. "protocolSampleId",
  147. "collectionProtocolId",
  148. "applicationNo",
  149. "applicationName",
  150. "projectTitle",
  151. "customerName",
  152. "channelName",
  153. "patientName",
  154. "infusionTime",
  155. "sortNo",
  156. "totalCourseCount",
  157. "products",
  158. "cellType",
  159. "specText",
  160. "productionWorkOrderId",
  161. "productionWorkOrderNo",
  162. "cellProductCode",
  163. "cellProductName",
  164. "cellProductTypeName",
  165. "cellProductPassage",
  166. "cellProductSpecName",
  167. "paymentSummary",
  168. "feeSummary",
  169. "bizType",
  170. "bizTypeLabel",
  171. "motherName",
  172. "newbornName",
  173. "isSpecial",
  174. "specialRemark",
  175. "unfinishedBadgeCount",
  176. "protocolSiblingSamples",
  177. )
  178. def _merge_work_order_summary_into_task(item: Dict[str, Any], summary: Dict[str, Any]) -> None:
  179. """将列表级工单摘要合并进任务行,供「我的质检任务」前端聚合展示。"""
  180. for key in _WORK_ORDER_SUMMARY_COPY_KEYS:
  181. if key in summary:
  182. item[key] = summary[key]
  183. if summary.get("createTime"):
  184. item["workOrderCreateTime"] = summary["createTime"]
  185. if "remark" in summary:
  186. item["workOrderRemark"] = summary.get("remark") or ""
  187. for key in (
  188. "receptionTime",
  189. "handlerName",
  190. "sampleTypeName",
  191. "protocolStatus",
  192. "protocolStatusLabel",
  193. "motherName",
  194. "newbornName",
  195. "isSpecial",
  196. "specialRemark",
  197. ):
  198. if not item.get(key) and summary.get(key):
  199. item[key] = summary[key]
  200. def _enrich_mine_tasks_with_work_order_summaries(
  201. db: Session,
  202. rows: List[Tuple[BizQcTask, BizQcWorkOrder]],
  203. items: List[Dict[str, Any]],
  204. tenant_no: Optional[str],
  205. ) -> List[Dict[str, Any]]:
  206. if not rows or not items:
  207. return items
  208. summaries = build_work_order_list_summaries(
  209. db, [wo for _, wo in rows], tenant_no
  210. )
  211. if not summaries:
  212. return items
  213. for item in items:
  214. summary = summaries.get(item.get("workOrderId") or "")
  215. if summary:
  216. _merge_work_order_summary_into_task(item, summary)
  217. return items
  218. def _enrich_tasks_reception_fields(
  219. db: Session, items: List[Dict[str, Any]]
  220. ) -> List[Dict[str, Any]]:
  221. if not items:
  222. return items
  223. rec_ids = [x.get("receptionId") for x in items if x.get("receptionId")]
  224. if not rec_ids:
  225. return items
  226. rec_map = {
  227. r.id: r
  228. for r in db.query(BizSampleReception)
  229. .filter(BizSampleReception.id.in_(rec_ids), BizSampleReception.del_flag == 0)
  230. .all()
  231. }
  232. cell_sample_ids = [
  233. r.protocol_sample_id
  234. for r in rec_map.values()
  235. if (r.reception_source or "cell_storage") != "research_donation" and r.protocol_sample_id
  236. ]
  237. donation_sample_ids = [
  238. r.donation_sample_id
  239. for r in rec_map.values()
  240. if (r.reception_source or "") == "research_donation" and r.donation_sample_id
  241. ]
  242. protocol_samples: Dict[str, BizProtocolSample] = {}
  243. if cell_sample_ids:
  244. protocol_samples = {
  245. s.id: s
  246. for s in db.query(BizProtocolSample)
  247. .filter(BizProtocolSample.id.in_(cell_sample_ids), BizProtocolSample.del_flag == 0)
  248. .all()
  249. }
  250. donation_samples: Dict[str, BizResearchDonationSample] = {}
  251. if donation_sample_ids:
  252. donation_samples = {
  253. s.id: s
  254. for s in db.query(BizResearchDonationSample)
  255. .filter(
  256. BizResearchDonationSample.id.in_(donation_sample_ids),
  257. BizResearchDonationSample.del_flag == 0,
  258. )
  259. .all()
  260. }
  261. type_ids = list(
  262. {
  263. s.sample_type_id
  264. for s in list(protocol_samples.values()) + list(donation_samples.values())
  265. if s.sample_type_id
  266. }
  267. )
  268. type_map = _load_sample_type_name_map(db, type_ids)
  269. cell_proto_ids = [
  270. rec.collection_protocol_id
  271. for rec in rec_map.values()
  272. if (rec.reception_source or "cell_storage") != "research_donation" and rec.collection_protocol_id
  273. ]
  274. donation_ids = [
  275. rec.research_donation_id
  276. for rec in rec_map.values()
  277. if (rec.reception_source or "") == "research_donation" and rec.research_donation_id
  278. ]
  279. proto_status_map: Dict[str, str] = {}
  280. proto_void_type_map: Dict[str, str] = {}
  281. tenant_no_for_status = ""
  282. if cell_proto_ids:
  283. for p in (
  284. db.query(
  285. BizCollectionProtocol.id,
  286. BizCollectionProtocol.protocol_status,
  287. BizCollectionProtocol.void_type,
  288. BizCollectionProtocol.tenant_no,
  289. )
  290. .filter(BizCollectionProtocol.id.in_(cell_proto_ids), BizCollectionProtocol.del_flag == 0)
  291. .all()
  292. ):
  293. proto_status_map[p.id] = p.protocol_status or ""
  294. proto_void_type_map[p.id] = p.void_type or ""
  295. if not tenant_no_for_status and (p.tenant_no or "").strip():
  296. tenant_no_for_status = (p.tenant_no or "").strip()
  297. donation_status_map: Dict[str, str] = {}
  298. donation_void_type_map: Dict[str, str] = {}
  299. if donation_ids:
  300. for d in (
  301. db.query(
  302. BizResearchDonation.id,
  303. BizResearchDonation.donation_status,
  304. BizResearchDonation.void_type,
  305. BizResearchDonation.tenant_no,
  306. )
  307. .filter(BizResearchDonation.id.in_(donation_ids), BizResearchDonation.del_flag == 0)
  308. .all()
  309. ):
  310. donation_status_map[d.id] = d.donation_status or ""
  311. donation_void_type_map[d.id] = d.void_type or ""
  312. if not tenant_no_for_status and (d.tenant_no or "").strip():
  313. tenant_no_for_status = (d.tenant_no or "").strip()
  314. for item in items:
  315. if item.get("orderSource") in ("temporary", "outbound"):
  316. item.setdefault("receptionSource", "")
  317. item.setdefault("receptionSourceLabel", "")
  318. continue
  319. rec = rec_map.get(item.get("receptionId") or "")
  320. if not rec:
  321. continue
  322. item["receptionTime"] = _fmt_dt(rec.reception_time)
  323. item["handlerName"] = rec.handler_name or ""
  324. item["sampleTypeName"] = _sample_type_name_for_reception(
  325. db,
  326. rec,
  327. type_map,
  328. protocol_samples=protocol_samples,
  329. donation_samples=donation_samples,
  330. )
  331. source = item.get("receptionSource") or (rec.reception_source if rec else "cell_storage")
  332. if source == "research_donation":
  333. _did = rec.research_donation_id if rec else ""
  334. _st = donation_status_map.get(_did or "", "")
  335. _vt = donation_void_type_map.get(_did or "", "")
  336. item["protocolStatus"] = _st
  337. item["voidType"] = _vt
  338. from app.core.protocol_status_display import format_protocol_status_label
  339. item["protocolStatusLabel"] = format_protocol_status_label(
  340. _st, _vt, kind="donation", db=db, tenant_no=tenant_no_for_status or None
  341. )
  342. else:
  343. _pid = rec.collection_protocol_id if rec else ""
  344. _st = proto_status_map.get(_pid or "", "")
  345. _vt = proto_void_type_map.get(_pid or "", "")
  346. item["protocolStatus"] = _st
  347. item["voidType"] = _vt
  348. from app.core.protocol_status_display import format_protocol_status_label
  349. item["protocolStatusLabel"] = format_protocol_status_label(
  350. _st, _vt, kind="protocol", db=db, tenant_no=tenant_no_for_status or None
  351. )
  352. apply_special_to_item_from_samples(
  353. item,
  354. source=source,
  355. protocol_sample_id=rec.protocol_sample_id or "",
  356. donation_sample_id=rec.donation_sample_id or "",
  357. protocol_samples=protocol_samples,
  358. donation_samples=donation_samples,
  359. )
  360. return items
  361. def _task_row_to_dict(
  362. t: BizQcTask,
  363. wo: BizQcWorkOrder,
  364. db: Session,
  365. tenant_no: Optional[str],
  366. *,
  367. attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
  368. ) -> Dict[str, Any]:
  369. out = _task_to_dict(
  370. t,
  371. wo.work_order_no or "",
  372. db,
  373. tenant_no,
  374. attachment_by_url=attachment_by_url,
  375. )
  376. out.update(_task_work_order_context(wo, t))
  377. return out
  378. def _attachment_map_for_task_rows(
  379. db: Session,
  380. rows: List[Tuple[BizQcTask, BizQcWorkOrder]],
  381. tenant_no: Optional[str],
  382. ) -> Dict[str, BizAttachment]:
  383. urls: List[str] = []
  384. for t, _wo in rows:
  385. urls.extend(_split_attachment_urls(t.third_party_attachments))
  386. if not urls:
  387. return {}
  388. return lookup_attachments_by_urls(db, urls, tenant_no)
  389. def _task_rows_to_dicts(
  390. db: Session,
  391. rows: List[Tuple[BizQcTask, BizQcWorkOrder]],
  392. tenant_no: Optional[str],
  393. ) -> List[Dict[str, Any]]:
  394. """将任务行批量转为字典;附件元数据一次预取,避免 N+1。"""
  395. att_map = _attachment_map_for_task_rows(db, rows, tenant_no)
  396. return [
  397. _task_row_to_dict(t, wo, db, tenant_no, attachment_by_url=att_map)
  398. for t, wo in rows
  399. ]
  400. def create_task_for_line(
  401. db: Session,
  402. *,
  403. work_order: BizQcWorkOrder,
  404. task_type: str,
  405. source_line_id: str,
  406. item_code: str,
  407. item_name: str,
  408. tenant_no: str,
  409. user_id: Optional[str] = None,
  410. create_dept_id: str = "",
  411. seq: int = 1,
  412. company_id: Optional[str] = None,
  413. company_name: Optional[str] = None,
  414. fee_amount: Optional[Decimal] = None,
  415. order_source: str = "inbound",
  416. batch_id: Optional[str] = None,
  417. ) -> BizQcTask:
  418. obj = BizQcTask(
  419. id=generate_uuid(),
  420. work_order_id=work_order.id,
  421. batch_id=batch_id,
  422. order_source=order_source,
  423. task_no=_gen_task_no(db, work_order, seq),
  424. task_type=task_type,
  425. source_line_id=source_line_id,
  426. sample_code=getattr(work_order, "sample_code", None) or None,
  427. item_code=item_code,
  428. item_name=item_name,
  429. company_id=company_id,
  430. company_name=company_name,
  431. fee_amount=fee_amount,
  432. task_status="dzx",
  433. tenant_no=tenant_no,
  434. order_num=seq,
  435. create_by=user_id,
  436. create_dept_id=create_dept_id or "",
  437. create_time=datetime.now(),
  438. update_by=user_id,
  439. update_time=datetime.now(),
  440. del_flag=0,
  441. )
  442. db.add(obj)
  443. db.flush()
  444. return obj
  445. def list_tasks(
  446. db: Session,
  447. *,
  448. page: int = 1,
  449. page_size: int = 10,
  450. keyword: Optional[str] = None,
  451. task_type: Optional[str] = None,
  452. task_status: Optional[str] = None,
  453. work_order_id: Optional[str] = None,
  454. qc_biz_type: Optional[str] = None,
  455. tenant_no: Optional[str] = None,
  456. user_info=None,
  457. ) -> Tuple[List[Dict[str, Any]], int]:
  458. def _filtered():
  459. q0 = (
  460. db.query(BizQcTask, BizQcWorkOrder)
  461. .join(
  462. BizQcWorkOrder,
  463. and_(
  464. BizQcWorkOrder.id == BizQcTask.work_order_id,
  465. BizQcWorkOrder.del_flag == 0,
  466. ),
  467. )
  468. .filter(BizQcTask.del_flag == 0)
  469. )
  470. if tenant_no:
  471. q0 = q0.filter(BizQcTask.tenant_no == tenant_no)
  472. if work_order_id:
  473. q0 = q0.filter(BizQcTask.work_order_id == work_order_id)
  474. kw = (keyword or "").strip()
  475. if kw:
  476. like = f"%{kw}%"
  477. q0 = q0.filter(
  478. or_(
  479. BizQcTask.task_no.like(like),
  480. BizQcTask.item_name.like(like),
  481. BizQcWorkOrder.work_order_no.like(like),
  482. BizQcWorkOrder.sample_code.like(like),
  483. )
  484. )
  485. tt = (task_type or "").strip()
  486. if tt:
  487. q0 = q0.filter(BizQcTask.task_type == tt)
  488. st = (task_status or "").strip()
  489. if st:
  490. q0 = q0.filter(BizQcTask.task_status == st)
  491. bt = (qc_biz_type or "").strip()
  492. if bt:
  493. if bt == "temporary":
  494. q0 = q0.filter(
  495. or_(
  496. BizQcWorkOrder.qc_biz_type == "temporary",
  497. BizQcWorkOrder.work_order_type == "temporary",
  498. )
  499. )
  500. else:
  501. q0 = q0.filter(BizQcWorkOrder.qc_biz_type == bt)
  502. if user_info:
  503. from app.core.data_scope import apply_data_scope
  504. q0 = apply_data_scope(q0, BizQcTask, user_info, db, tenant_no)
  505. return q0
  506. total = _filtered().order_by(None).with_entities(func.count(BizQcTask.id)).scalar() or 0
  507. page = max(1, page)
  508. page_size = min(max(1, page_size), 100)
  509. rows = (
  510. _filtered()
  511. .order_by(
  512. BizQcTask.create_time.desc(),
  513. BizQcWorkOrder.work_order_no.desc(),
  514. BizQcTask.task_no.asc(),
  515. )
  516. .offset((page - 1) * page_size)
  517. .limit(page_size)
  518. .all()
  519. )
  520. items = _task_rows_to_dicts(db, rows, tenant_no)
  521. items = _enrich_tasks_reception_fields(db, items)
  522. return assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no), int(total)
  523. def list_mine_tasks(
  524. db: Session,
  525. *,
  526. assignee_user_id: str,
  527. page: int = 1,
  528. page_size: int = 10,
  529. keyword: Optional[str] = None,
  530. task_type: Optional[str] = None,
  531. task_status: Optional[str] = None,
  532. qc_biz_type: Optional[str] = None,
  533. reception_source: Optional[str] = None,
  534. biz_type: Optional[str] = None,
  535. tenant_no: Optional[str] = None,
  536. ) -> Tuple[List[Dict[str, Any]], int]:
  537. def _filtered():
  538. q0 = (
  539. db.query(BizQcTask, BizQcWorkOrder)
  540. .join(
  541. BizQcWorkOrder,
  542. and_(
  543. BizQcWorkOrder.id == BizQcTask.work_order_id,
  544. BizQcWorkOrder.del_flag == 0,
  545. ),
  546. )
  547. .join(
  548. BizQcTaskAssignee,
  549. and_(
  550. eq_unicode(BizQcTaskAssignee.task_id, BizQcTask.id),
  551. unicode_ci(BizQcTaskAssignee.user_id) == assignee_user_id,
  552. BizQcTaskAssignee.del_flag == 0,
  553. ),
  554. )
  555. .filter(BizQcTask.del_flag == 0)
  556. )
  557. if tenant_no:
  558. q0 = q0.filter(BizQcTask.tenant_no == tenant_no)
  559. kw = (keyword or "").strip()
  560. if kw:
  561. like = f"%{kw}%"
  562. q0 = q0.filter(
  563. or_(
  564. BizQcTask.task_no.like(like),
  565. BizQcTask.item_name.like(like),
  566. BizQcWorkOrder.work_order_no.like(like),
  567. BizQcWorkOrder.sample_code.like(like),
  568. )
  569. )
  570. tt = (task_type or "").strip()
  571. if tt:
  572. q0 = q0.filter(BizQcTask.task_type == tt)
  573. st = (task_status or "").strip()
  574. if st:
  575. q0 = q0.filter(BizQcTask.task_status == st)
  576. bt = (qc_biz_type or "").strip()
  577. if bt:
  578. if bt == "temporary":
  579. q0 = q0.filter(
  580. or_(
  581. BizQcWorkOrder.qc_biz_type == "temporary",
  582. BizQcWorkOrder.work_order_type == "temporary",
  583. )
  584. )
  585. else:
  586. q0 = q0.filter(BizQcWorkOrder.qc_biz_type == bt)
  587. rs = (reception_source or "").strip()
  588. if rs:
  589. q0 = q0.filter(BizQcWorkOrder.reception_source == rs)
  590. bt_out = (biz_type or "").strip()
  591. if bt_out:
  592. q0 = q0.filter(BizQcWorkOrder.biz_type == bt_out)
  593. return q0
  594. total = _filtered().order_by(None).with_entities(func.count(BizQcTask.id)).scalar() or 0
  595. page = max(1, page)
  596. page_size = min(max(1, page_size), 100)
  597. q_page = _filtered()
  598. if uses_inbound_lab_storage_list_sort(reception_source):
  599. q_page = q_page.order_by(
  600. *inbound_lab_storage_order_by_clauses(),
  601. BizQcWorkOrder.work_order_no.desc(),
  602. BizQcTask.task_no.asc(),
  603. )
  604. else:
  605. q_page = q_page.order_by(
  606. BizQcTask.create_time.desc(),
  607. BizQcWorkOrder.work_order_no.desc(),
  608. BizQcTask.task_no.asc(),
  609. )
  610. rows = (
  611. q_page
  612. .offset((page - 1) * page_size)
  613. .limit(page_size)
  614. .all()
  615. )
  616. items = _task_rows_to_dicts(db, rows, tenant_no)
  617. items = _enrich_tasks_reception_fields(db, items)
  618. items = _enrich_mine_tasks_with_work_order_summaries(db, rows, items, tenant_no)
  619. return assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no), int(total)
  620. def assert_task_assignee(
  621. db: Session, task_id: str, user_id: str, tenant_no: str
  622. ) -> None:
  623. if not assignee_svc.is_user_assignee(db, task_id, user_id, tenant_no):
  624. raise PermissionError("您不是该任务的负责人,无权操作")
  625. def list_tasks_by_work_order(db: Session, work_order_id: str, tenant_no: str) -> List[Dict[str, Any]]:
  626. wo_id = (work_order_id or "").strip()
  627. if not wo_id:
  628. return []
  629. return list_tasks_by_work_order_ids(db, [wo_id], tenant_no).get(wo_id, [])
  630. def list_tasks_by_work_order_ids(
  631. db: Session, work_order_ids: List[str], tenant_no: str
  632. ) -> Dict[str, List[Dict[str, Any]]]:
  633. ids = list({(x or "").strip() for x in work_order_ids if (x or "").strip()})
  634. if not ids or not tenant_no:
  635. return {}
  636. ids = ids[:100]
  637. rows = (
  638. db.query(BizQcTask, BizQcWorkOrder)
  639. .join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
  640. .filter(
  641. BizQcTask.work_order_id.in_(ids),
  642. BizQcTask.tenant_no == tenant_no,
  643. BizQcTask.del_flag == 0,
  644. BizQcWorkOrder.del_flag == 0,
  645. )
  646. .order_by(BizQcTask.work_order_id.asc(), BizQcTask.order_num.asc())
  647. .all()
  648. )
  649. items = _task_rows_to_dicts(db, rows, tenant_no)
  650. items = _enrich_tasks_reception_fields(db, items)
  651. items = assignee_svc.enrich_tasks_with_assignees(db, items, tenant_no)
  652. grouped: Dict[str, List[Dict[str, Any]]] = {wo_id: [] for wo_id in ids}
  653. for item in items:
  654. wo_id = (item.get("workOrderId") or "").strip()
  655. if wo_id in grouped:
  656. grouped[wo_id].append(item)
  657. return grouped
  658. def get_task(
  659. db: Session, row_id: str, tenant_no: Optional[str] = None
  660. ) -> Optional[BizQcTask]:
  661. q = db.query(BizQcTask).filter(BizQcTask.id == row_id, BizQcTask.del_flag == 0)
  662. if tenant_no:
  663. q = q.filter(BizQcTask.tenant_no == tenant_no)
  664. return q.first()
  665. def get_task_detail(db: Session, row_id: str, tenant_no: str) -> Dict[str, Any]:
  666. row = (
  667. db.query(BizQcTask, BizQcWorkOrder)
  668. .join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
  669. .filter(
  670. BizQcTask.id == row_id,
  671. BizQcTask.tenant_no == tenant_no,
  672. BizQcTask.del_flag == 0,
  673. BizQcWorkOrder.del_flag == 0,
  674. )
  675. .first()
  676. )
  677. if not row:
  678. raise ValueError("任务不存在")
  679. t, wo = row
  680. out = _task_row_to_dict(t, wo, db, tenant_no)
  681. out = _enrich_tasks_reception_fields(db, [out])[0]
  682. out = assignee_svc.enrich_tasks_with_assignees(db, [out], tenant_no)[0]
  683. from app.crud.qc_work_order import _enrich_work_order_qc_targets
  684. out = _enrich_work_order_qc_targets(db, tenant_no, [out])[0]
  685. if (out.get("qcBizType") or "") != "temporary":
  686. resolved_reception_id = _resolve_reception_id_for_work_order_detail(db, wo, tenant_no)
  687. reception_detail = (
  688. sample_reception_svc.get_reception_by_id(db, resolved_reception_id, tenant_no)
  689. if resolved_reception_id
  690. else None
  691. )
  692. if reception_detail:
  693. out["reception"] = reception_detail
  694. return out
  695. def start_task(db: Session, row_id: str, *, tenant_no: str, user_id: Optional[str]) -> BizQcTask:
  696. t = get_task(db, row_id, tenant_no=tenant_no)
  697. if not t:
  698. raise ValueError("任务不存在")
  699. if t.task_type != "sysz":
  700. raise ValueError("仅实验室自检任务可开始")
  701. if t.task_status != "dzx":
  702. raise ValueError("当前状态不可开始")
  703. t.task_status = "jxz"
  704. t.update_by = user_id
  705. t.update_time = datetime.now()
  706. db.add(t)
  707. db.commit()
  708. db.refresh(t)
  709. return t
  710. def _set_task_remark(t: BizQcTask, remark: Optional[str]) -> None:
  711. t.remark = (remark or "").strip() or None
  712. def _require_dsfjc_attachments(t: BizQcTask, *, for_status: str = "ywc") -> None:
  713. """第三方检测且任务状态为已完成时,须至少有一个检测材料附件。"""
  714. if (t.task_type or "").strip() != "dsfjc":
  715. return
  716. if (for_status or "").strip() != "ywc":
  717. return
  718. if not (t.third_party_attachments or "").strip():
  719. raise ValueError("第三方检测须上传检测材料附件")
  720. def complete_task(
  721. db: Session,
  722. row_id: str,
  723. *,
  724. tenant_no: str,
  725. user_id: Optional[str],
  726. conclusion: Optional[str] = None,
  727. remark: Optional[str] = None,
  728. commit: bool = True,
  729. ) -> BizQcTask:
  730. t = get_task(db, row_id, tenant_no=tenant_no)
  731. if not t:
  732. raise ValueError("任务不存在")
  733. concl = (conclusion or "").strip() if conclusion is not None else ""
  734. if concl and concl not in ("tg", "btg"):
  735. raise ValueError("检测结论无效")
  736. if t.task_type == "sysz":
  737. if t.task_status not in ("dzx", "jxz"):
  738. raise ValueError("当前状态不可完成")
  739. elif t.task_type != "dsfjc":
  740. raise ValueError("任务类型无效")
  741. _require_dsfjc_attachments(t)
  742. t.task_status = "ywc"
  743. if concl:
  744. t.conclusion = concl
  745. _set_task_remark(t, remark)
  746. t.completed_time = datetime.now()
  747. t.completed_by = user_id
  748. t.update_by = user_id
  749. t.update_time = datetime.now()
  750. db.add(t)
  751. if commit:
  752. db.commit()
  753. db.refresh(t)
  754. from app.crud import qc_work_order as wo_svc
  755. wo_svc.try_complete_work_order(db, t.work_order_id, tenant_no)
  756. else:
  757. db.flush()
  758. return t
  759. _QC_TASK_RESULT_STATUSES = frozenset({"dzx", "jxz", "ywc"})
  760. def resolve_task_result_conclusion(
  761. conclusion: Optional[str],
  762. target_status: str,
  763. ) -> str:
  764. """Normalize conclusion for apply_task_result.
  765. Returns '' | 'tg' | 'btg'. Empty allowed only when target is dzx/jxz.
  766. """
  767. concl = (conclusion or "").strip()
  768. if concl and concl not in ("tg", "btg"):
  769. raise ValueError("检测结论无效")
  770. if target_status == "ywc" and concl not in ("tg", "btg"):
  771. raise ValueError("请选择检测结论(通过/不通过)")
  772. return concl
  773. def apply_task_result(
  774. db: Session,
  775. row_id: str,
  776. *,
  777. tenant_no: str,
  778. user_id: Optional[str],
  779. conclusion: Optional[str] = None,
  780. remark: Optional[str] = None,
  781. material_usages: Optional[List[Dict[str, Any]]] = None,
  782. task_status: Optional[str] = None,
  783. commit: bool = True,
  784. ) -> BizQcTask:
  785. """保存检测结果与结论,并按指定任务状态落库(默认已完成)。"""
  786. from app.crud import qc_material_usage as mu_svc
  787. from app.crud import qc_work_order as wo_svc
  788. t = get_task(db, row_id, tenant_no=tenant_no)
  789. if not t:
  790. raise ValueError("任务不存在")
  791. target = (task_status or "").strip() or "ywc"
  792. if target not in _QC_TASK_RESULT_STATUSES:
  793. raise ValueError("任务状态无效")
  794. concl = resolve_task_result_conclusion(conclusion, target)
  795. _require_dsfjc_attachments(t, for_status=target)
  796. if material_usages is not None:
  797. mu_svc.deduct_for_task(
  798. db,
  799. row_id,
  800. task_no=t.task_no or "",
  801. material_usages=material_usages,
  802. tenant_no=tenant_no,
  803. user_id=user_id,
  804. )
  805. was_done = (t.task_status or "").strip() == "ywc"
  806. t.conclusion = concl or None
  807. _set_task_remark(t, remark if remark is not None else "")
  808. t.task_status = target
  809. t.update_by = user_id
  810. t.update_time = datetime.now()
  811. if target == "ywc":
  812. t.completed_time = datetime.now()
  813. t.completed_by = user_id
  814. else:
  815. t.completed_time = None
  816. t.completed_by = None
  817. db.add(t)
  818. if commit:
  819. db.commit()
  820. db.refresh(t)
  821. if target == "ywc":
  822. wo_svc.try_complete_work_order(db, t.work_order_id, tenant_no)
  823. elif was_done:
  824. wo_svc.try_reopen_work_order(db, t.work_order_id, tenant_no)
  825. else:
  826. db.flush()
  827. return t
  828. def update_task_attachments(
  829. db: Session,
  830. row_id: str,
  831. *,
  832. attachments: str,
  833. tenant_no: str,
  834. user_id: Optional[str],
  835. commit: bool = True,
  836. ) -> BizQcTask:
  837. t = get_task(db, row_id, tenant_no=tenant_no)
  838. if not t:
  839. raise ValueError("任务不存在")
  840. t.third_party_attachments = (attachments or "").strip() or None
  841. if (
  842. t.task_type == "dsfjc"
  843. and t.task_status == "dzx"
  844. and t.third_party_attachments
  845. ):
  846. t.task_status = "jxz"
  847. t.update_by = user_id
  848. t.update_time = datetime.now()
  849. db.add(t)
  850. if commit:
  851. db.commit()
  852. db.refresh(t)
  853. else:
  854. db.flush()
  855. return t
  856. def create_qc_task_manual(
  857. db: Session,
  858. *,
  859. tenant_no: str,
  860. user_id: Optional[str] = None,
  861. create_dept_id: str = "",
  862. task_type: str = "sysz",
  863. item_code: str = "",
  864. item_name: str = "",
  865. company_id: Optional[str] = None,
  866. company_name: Optional[str] = None,
  867. fee_amount: Optional[float] = None,
  868. sample_code: str = "",
  869. work_order_id: str = "",
  870. remark: str = "",
  871. ) -> BizQcTask:
  872. """手动创建质检任务(可独立于工单)"""
  873. # 生成任务号:ZJRW + 日期 + 序号
  874. day = datetime.now().strftime("%Y%m%d")
  875. like = f"ZJRW{day}%"
  876. cnt = db.query(BizQcTask).filter(
  877. BizQcTask.tenant_no == tenant_no,
  878. BizQcTask.task_no.like(like),
  879. ).count()
  880. task_no = f"ZJRW{day}{cnt + 1:04d}"
  881. task = BizQcTask(
  882. id=generate_uuid(),
  883. work_order_id=work_order_id or "",
  884. task_no=task_no,
  885. task_type=task_type,
  886. source_line_id="",
  887. sample_code=sample_code,
  888. item_code=item_code,
  889. item_name=item_name,
  890. company_id=company_id,
  891. company_name=company_name,
  892. fee_amount=fee_amount,
  893. task_status="dzx",
  894. tenant_no=tenant_no,
  895. remark=remark,
  896. create_by=user_id,
  897. create_dept_id=create_dept_id,
  898. )
  899. db.add(task)
  900. db.flush()
  901. return task
  902. def _soft_delete_source_line_for_task(
  903. db: Session,
  904. task: BizQcTask,
  905. *,
  906. tenant_no: str,
  907. user_id: Optional[str],
  908. now: datetime,
  909. ) -> None:
  910. """删除任务时同步软删批次下对应 lab/third 明细行,避免配置弹窗仍勾选该项目。"""
  911. sid = (task.source_line_id or "").strip()
  912. if not sid:
  913. return
  914. task_type = (task.task_type or "").strip()
  915. line = None
  916. if task_type == "sysz":
  917. line = (
  918. db.query(BizQcWorkOrderLabItem)
  919. .filter(
  920. BizQcWorkOrderLabItem.id == sid,
  921. BizQcWorkOrderLabItem.tenant_no == tenant_no,
  922. BizQcWorkOrderLabItem.del_flag == 0,
  923. )
  924. .first()
  925. )
  926. elif task_type == "dsfjc":
  927. line = (
  928. db.query(BizQcWorkOrderThirdItem)
  929. .filter(
  930. BizQcWorkOrderThirdItem.id == sid,
  931. BizQcWorkOrderThirdItem.tenant_no == tenant_no,
  932. BizQcWorkOrderThirdItem.del_flag == 0,
  933. )
  934. .first()
  935. )
  936. else:
  937. line = (
  938. db.query(BizQcWorkOrderLabItem)
  939. .filter(
  940. BizQcWorkOrderLabItem.id == sid,
  941. BizQcWorkOrderLabItem.tenant_no == tenant_no,
  942. BizQcWorkOrderLabItem.del_flag == 0,
  943. )
  944. .first()
  945. )
  946. if not line:
  947. line = (
  948. db.query(BizQcWorkOrderThirdItem)
  949. .filter(
  950. BizQcWorkOrderThirdItem.id == sid,
  951. BizQcWorkOrderThirdItem.tenant_no == tenant_no,
  952. BizQcWorkOrderThirdItem.del_flag == 0,
  953. )
  954. .first()
  955. )
  956. if not line:
  957. return
  958. line.del_flag = 1
  959. line.update_by = user_id
  960. line.update_time = now
  961. db.add(line)
  962. def _refresh_work_order_item_flags(
  963. db: Session,
  964. wo: BizQcWorkOrder,
  965. *,
  966. tenant_no: str,
  967. user_id: Optional[str],
  968. now: datetime,
  969. ) -> None:
  970. """按未删除明细行回写工单 has_lab / has_third_party / 第三方费用合计。"""
  971. lab_count = (
  972. db.query(BizQcWorkOrderLabItem)
  973. .filter(
  974. BizQcWorkOrderLabItem.work_order_id == wo.id,
  975. BizQcWorkOrderLabItem.tenant_no == tenant_no,
  976. BizQcWorkOrderLabItem.del_flag == 0,
  977. )
  978. .count()
  979. )
  980. third_rows = (
  981. db.query(BizQcWorkOrderThirdItem)
  982. .filter(
  983. BizQcWorkOrderThirdItem.work_order_id == wo.id,
  984. BizQcWorkOrderThirdItem.tenant_no == tenant_no,
  985. BizQcWorkOrderThirdItem.del_flag == 0,
  986. )
  987. .all()
  988. )
  989. wo.has_lab = "1" if lab_count else "0"
  990. wo.has_third_party = "1" if third_rows else "0"
  991. if third_rows:
  992. fee_total = sum((r.fee_amount or Decimal("0")) for r in third_rows)
  993. wo.third_party_fee_total = fee_total
  994. else:
  995. wo.third_party_fee_total = None
  996. wo.update_by = user_id
  997. wo.update_time = now
  998. db.add(wo)
  999. def delete_qc_task(db: Session, task_id: str, tenant_no: str, user_id: Optional[str]) -> None:
  1000. """软删除质检任务、检测结果,并同步解除批次中的项目关联。"""
  1001. task = db.query(BizQcTask).filter(
  1002. and_(
  1003. BizQcTask.id == task_id,
  1004. BizQcTask.tenant_no == tenant_no,
  1005. BizQcTask.del_flag == 0,
  1006. )
  1007. ).first()
  1008. if not task:
  1009. raise ValueError("任务不存在")
  1010. work_order_id = task.work_order_id
  1011. now = datetime.now()
  1012. # 软删除任务结果
  1013. from app.models.qc_param import BizQcTaskResult
  1014. results = db.query(BizQcTaskResult).filter(
  1015. and_(BizQcTaskResult.task_id == task_id, BizQcTaskResult.del_flag == 0)
  1016. ).all()
  1017. for r in results:
  1018. r.del_flag = 1
  1019. r.update_by = user_id
  1020. r.update_time = now
  1021. # 同步软删批次明细行(配置弹窗勾选来源)
  1022. _soft_delete_source_line_for_task(
  1023. db, task, tenant_no=tenant_no, user_id=user_id, now=now
  1024. )
  1025. # 软删除任务负责人
  1026. assignee_svc.soft_delete_assignees_by_task_ids(db, [task_id], tenant_no)
  1027. # 软删除任务本体
  1028. task.del_flag = 1
  1029. task.update_by = user_id
  1030. task.update_time = now
  1031. db.flush()
  1032. from app.crud.qc_work_order import get_work_order, try_complete_work_order
  1033. wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
  1034. if wo:
  1035. _refresh_work_order_item_flags(
  1036. db, wo, tenant_no=tenant_no, user_id=user_id, now=now
  1037. )
  1038. if (wo.work_order_status or "").strip() == "ywc":
  1039. pending = (
  1040. db.query(BizQcTask)
  1041. .filter(
  1042. BizQcTask.work_order_id == work_order_id,
  1043. BizQcTask.tenant_no == tenant_no,
  1044. BizQcTask.del_flag == 0,
  1045. BizQcTask.task_status != "ywc",
  1046. )
  1047. .count()
  1048. )
  1049. if pending:
  1050. wo.work_order_status = "ypz"
  1051. wo.update_by = user_id
  1052. wo.update_time = now
  1053. db.add(wo)
  1054. db.commit()
  1055. try_complete_work_order(db, work_order_id, tenant_no)