qc_sms_notification.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. """样本质检短信通知 CRUD"""
  2. from __future__ import annotations
  3. import math
  4. from datetime import datetime
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import and_, case, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.models.models import (
  10. BizCellSampleType,
  11. BizCollectionProtocol,
  12. BizProtocolPartyCustomer,
  13. BizProtocolSample,
  14. BizQcSmsNotification,
  15. BizQcTask,
  16. BizQcWorkOrder,
  17. SysUser,
  18. )
  19. # biz_qc_work_order / biz_qc_task 为 general_ci;与 unicode_ci 表 JOIN 时需 collate 对齐
  20. _UNICODE_CI = "utf8mb4_unicode_ci"
  21. def _unicode_ci(col):
  22. return col.collate(_UNICODE_CI)
  23. def _eq_ci(left, right):
  24. """列与列比较时两侧 collate;与字面量比较时仅对列侧 collate。"""
  25. if isinstance(left, str):
  26. return left == (_unicode_ci(right) if not isinstance(right, str) else right)
  27. if isinstance(right, str):
  28. return _unicode_ci(left) == right
  29. return _unicode_ci(left) == _unicode_ci(right)
  30. def total_pages(total: int, page_size: int) -> int:
  31. if total <= 0:
  32. return 0
  33. return int(math.ceil(total / float(page_size)))
  34. def _fmt_dt(t: Optional[datetime]) -> str:
  35. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  36. def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
  37. val = (value or "").strip()
  38. if len(val) == 10:
  39. return f"{val} 23:59:59" if end_of_day else f"{val} 00:00:00"
  40. return val
  41. def _derive_qc_conclusion(tasks: List[BizQcTask]) -> str:
  42. if not tasks:
  43. return ""
  44. if any((t.conclusion or "").strip() == "btg" for t in tasks):
  45. return "btg"
  46. if all((t.conclusion or "").strip() == "tg" for t in tasks):
  47. return "tg"
  48. return ""
  49. def _qc_status_label(
  50. wo: Optional[BizQcWorkOrder],
  51. *,
  52. qc_conclusion: str = "",
  53. ) -> Tuple[str, str]:
  54. if not wo:
  55. return "dzj", "待质检"
  56. st = (wo.work_order_status or "").strip()
  57. if st == "yzf":
  58. return "yzf", "已作废"
  59. if st == "ypz":
  60. return "zjz", "质检中"
  61. if st == "ywc":
  62. if qc_conclusion == "btg":
  63. return "btg", "不通过"
  64. if qc_conclusion == "tg":
  65. return "tg", "通过"
  66. return "zjz", "质检中"
  67. return "dzj", "待质检"
  68. def _fetch_completed_tasks(db: Session, work_order_id: str, tenant_no: str) -> List[BizQcTask]:
  69. if not work_order_id:
  70. return []
  71. return (
  72. db.query(BizQcTask)
  73. .filter(
  74. BizQcTask.work_order_id == work_order_id,
  75. BizQcTask.tenant_no == tenant_no,
  76. BizQcTask.del_flag == 0,
  77. BizQcTask.task_status == "ywc",
  78. )
  79. .all()
  80. )
  81. def _qc_complete_time(tasks: List[BizQcTask], wo: Optional[BizQcWorkOrder]) -> Optional[datetime]:
  82. times = [t.completed_time for t in tasks if t.completed_time]
  83. if times:
  84. return max(times)
  85. return wo.update_time if wo else None
  86. def _row_to_dict(
  87. *,
  88. notification_id: str = "",
  89. qc_work_order_id: str = "",
  90. protocol_sample_id: str = "",
  91. protocol_id: str = "",
  92. sms_notified: int = 0,
  93. sms_content: str = "",
  94. protocol_no: str = "",
  95. mother_name: str = "",
  96. newborn_name: str = "",
  97. sample_code: str = "",
  98. lab_storage_code: str = "",
  99. sample_type_name: str = "",
  100. qc_complete_time: str = "",
  101. qc_status: str = "",
  102. qc_status_label: str = "",
  103. qc_conclusion: str = "",
  104. collection_protocol_id: str = "",
  105. protocol_status: str = "",
  106. ) -> Dict[str, Any]:
  107. return {
  108. "id": notification_id,
  109. "qcWorkOrderId": qc_work_order_id,
  110. "protocolSampleId": protocol_sample_id,
  111. "protocolId": protocol_id,
  112. "protocolNo": protocol_no,
  113. "protocolStatus": protocol_status,
  114. "motherName": mother_name,
  115. "newbornName": newborn_name,
  116. "sampleCode": sample_code,
  117. "labStorageCode": lab_storage_code,
  118. "sampleTypeName": sample_type_name,
  119. "qcCompleteTime": qc_complete_time,
  120. "qcStatus": qc_status,
  121. "qcStatusLabel": qc_status_label,
  122. "qcConclusion": qc_conclusion,
  123. "qcConclusionLabel": qc_status_label if qc_status in ("tg", "btg") else "",
  124. "smsNotified": sms_notified,
  125. "smsContent": sms_content,
  126. "collectionProtocolId": collection_protocol_id,
  127. }
  128. def _base_query(db: Session, tenant_no: str):
  129. return (
  130. db.query(
  131. BizProtocolSample,
  132. BizQcWorkOrder,
  133. BizQcSmsNotification,
  134. BizCollectionProtocol,
  135. BizProtocolPartyCustomer,
  136. )
  137. .outerjoin(
  138. BizQcWorkOrder,
  139. and_(
  140. _eq_ci(BizQcWorkOrder.sample_code, BizProtocolSample.sample_code),
  141. BizQcWorkOrder.tenant_no == tenant_no,
  142. BizQcWorkOrder.del_flag == 0,
  143. BizQcWorkOrder.reception_source == "cell_storage",
  144. BizQcWorkOrder.qc_biz_type == "inbound",
  145. BizQcWorkOrder.trigger_source == "reception",
  146. ),
  147. )
  148. .outerjoin(
  149. BizQcSmsNotification,
  150. and_(
  151. _eq_ci(BizQcSmsNotification.protocol_sample_id, BizProtocolSample.id),
  152. BizQcSmsNotification.tenant_no == tenant_no,
  153. BizQcSmsNotification.del_flag == "0",
  154. ),
  155. )
  156. .join(
  157. BizCollectionProtocol,
  158. and_(
  159. BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id,
  160. BizCollectionProtocol.del_flag == 0,
  161. ),
  162. )
  163. .outerjoin(
  164. BizProtocolPartyCustomer,
  165. and_(
  166. BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
  167. BizProtocolPartyCustomer.del_flag == 0,
  168. ),
  169. )
  170. .filter(
  171. BizProtocolSample.tenant_no == tenant_no,
  172. BizProtocolSample.del_flag == 0,
  173. )
  174. )
  175. def _ensure_notification(
  176. db: Session,
  177. psample: BizProtocolSample,
  178. tenant_no: str,
  179. user_id: Optional[str] = None,
  180. *,
  181. wo: Optional[BizQcWorkOrder] = None,
  182. ) -> BizQcSmsNotification:
  183. existing = (
  184. db.query(BizQcSmsNotification)
  185. .filter(
  186. BizQcSmsNotification.protocol_sample_id == psample.id,
  187. BizQcSmsNotification.tenant_no == tenant_no,
  188. BizQcSmsNotification.del_flag == "0",
  189. )
  190. .first()
  191. )
  192. if existing:
  193. if wo and not existing.qc_work_order_id:
  194. existing.qc_work_order_id = wo.id
  195. existing.protocol_id = wo.collection_protocol_id or psample.collection_protocol_id or ""
  196. existing.update_time = datetime.now()
  197. db.commit()
  198. db.refresh(existing)
  199. return existing
  200. obj = BizQcSmsNotification(
  201. id=generate_uuid(),
  202. protocol_sample_id=psample.id,
  203. qc_work_order_id=wo.id if wo else None,
  204. protocol_id=(wo.collection_protocol_id if wo else psample.collection_protocol_id) or "",
  205. sms_notified=0,
  206. tenant_no=tenant_no,
  207. create_by=user_id,
  208. update_by=user_id,
  209. )
  210. db.add(obj)
  211. db.flush()
  212. db.commit()
  213. db.refresh(obj)
  214. return obj
  215. def resolve_notification(
  216. db: Session,
  217. *,
  218. notification_id: str,
  219. tenant_no: str,
  220. qc_work_order_id: Optional[str] = None,
  221. protocol_sample_id: Optional[str] = None,
  222. user_id: Optional[str] = None,
  223. ) -> Optional[BizQcSmsNotification]:
  224. notif = (
  225. db.query(BizQcSmsNotification)
  226. .filter(
  227. BizQcSmsNotification.id == notification_id,
  228. BizQcSmsNotification.tenant_no == tenant_no,
  229. BizQcSmsNotification.del_flag == "0",
  230. )
  231. .first()
  232. )
  233. if notif:
  234. return notif
  235. psample = None
  236. if protocol_sample_id:
  237. psample = (
  238. db.query(BizProtocolSample)
  239. .filter(
  240. BizProtocolSample.id == protocol_sample_id,
  241. BizProtocolSample.tenant_no == tenant_no,
  242. BizProtocolSample.del_flag == 0,
  243. )
  244. .first()
  245. )
  246. elif qc_work_order_id:
  247. wo = (
  248. db.query(BizQcWorkOrder)
  249. .filter(
  250. BizQcWorkOrder.id == qc_work_order_id,
  251. BizQcWorkOrder.tenant_no == tenant_no,
  252. BizQcWorkOrder.del_flag == 0,
  253. )
  254. .first()
  255. )
  256. if wo and wo.sample_code:
  257. psample = (
  258. db.query(BizProtocolSample)
  259. .filter(
  260. _eq_ci(BizProtocolSample.sample_code, wo.sample_code),
  261. BizProtocolSample.tenant_no == tenant_no,
  262. BizProtocolSample.del_flag == 0,
  263. )
  264. .first()
  265. )
  266. if not psample:
  267. return None
  268. wo = None
  269. if qc_work_order_id:
  270. wo = (
  271. db.query(BizQcWorkOrder)
  272. .filter(
  273. BizQcWorkOrder.id == qc_work_order_id,
  274. BizQcWorkOrder.tenant_no == tenant_no,
  275. BizQcWorkOrder.del_flag == 0,
  276. )
  277. .first()
  278. )
  279. return _ensure_notification(db, psample, tenant_no=tenant_no, user_id=user_id, wo=wo)
  280. def _task_stats_subquery(db: Session, tenant_no: str):
  281. return (
  282. db.query(
  283. BizQcTask.work_order_id.label("work_order_id"),
  284. func.max(case((BizQcTask.conclusion == "btg", 1), else_=0)).label("has_btg"),
  285. func.count(BizQcTask.id).label("task_count"),
  286. func.max(BizQcTask.completed_time).label("max_completed_time"),
  287. )
  288. .filter(
  289. BizQcTask.tenant_no == tenant_no,
  290. BizQcTask.del_flag == 0,
  291. BizQcTask.task_status == "ywc",
  292. )
  293. .group_by(BizQcTask.work_order_id)
  294. .subquery()
  295. )
  296. def _apply_qc_status_filter(q, qc_status: str, task_stats):
  297. if qc_status == "dzj":
  298. return q.filter(
  299. or_(
  300. BizQcWorkOrder.id.is_(None),
  301. BizQcWorkOrder.work_order_status == "dpz",
  302. )
  303. )
  304. if qc_status == "zjz":
  305. return q.filter(BizQcWorkOrder.work_order_status == "ypz")
  306. if qc_status == "yzf":
  307. return q.filter(BizQcWorkOrder.work_order_status == "yzf")
  308. if qc_status == "btg":
  309. return q.filter(
  310. BizQcWorkOrder.work_order_status == "ywc",
  311. task_stats.c.has_btg == 1,
  312. )
  313. if qc_status == "tg":
  314. return q.filter(
  315. BizQcWorkOrder.work_order_status == "ywc",
  316. task_stats.c.has_btg == 0,
  317. task_stats.c.task_count > 0,
  318. )
  319. return q
  320. def list_notifications(
  321. db: Session,
  322. *,
  323. page: int = 1,
  324. page_size: int = 10,
  325. tenant_no: str,
  326. keyword: Optional[str] = None,
  327. qc_status: Optional[str] = None,
  328. qc_conclusion: Optional[str] = None,
  329. sms_notified: Optional[int] = None,
  330. qc_complete_time_start: Optional[str] = None,
  331. qc_complete_time_end: Optional[str] = None,
  332. user_info=None,
  333. ) -> Tuple[List[Dict[str, Any]], int]:
  334. if not tenant_no:
  335. return [], 0
  336. status_filter = (qc_status or qc_conclusion or "").strip()
  337. task_stats = _task_stats_subquery(db, tenant_no)
  338. q = _base_query(db, tenant_no).outerjoin(
  339. task_stats,
  340. _eq_ci(task_stats.c.work_order_id, BizQcWorkOrder.id),
  341. )
  342. if keyword and keyword.strip():
  343. kw = f"%{keyword.strip()}%"
  344. kw_conds = [
  345. BizProtocolPartyCustomer.mother_name.like(kw),
  346. BizProtocolSample.sample_code.like(kw),
  347. ]
  348. from app.crud import sample_reception as reception_svc
  349. lab_codes = reception_svc.sample_codes_matching_lab_storage_keyword(
  350. db, tenant_no, keyword, reception_source="cell_storage"
  351. )
  352. if lab_codes:
  353. kw_conds.append(BizProtocolSample.sample_code.in_(lab_codes))
  354. q = q.filter(or_(*kw_conds))
  355. if status_filter:
  356. q = _apply_qc_status_filter(q, status_filter, task_stats)
  357. if sms_notified is not None:
  358. if sms_notified == 0:
  359. q = q.filter(
  360. or_(
  361. BizQcSmsNotification.sms_notified == 0,
  362. BizQcSmsNotification.id.is_(None),
  363. )
  364. )
  365. else:
  366. q = q.filter(BizQcSmsNotification.sms_notified == sms_notified)
  367. if qc_complete_time_start:
  368. q = q.filter(
  369. BizProtocolSample.create_time >= _normalize_date_bound(qc_complete_time_start)
  370. )
  371. if qc_complete_time_end:
  372. q = q.filter(
  373. BizProtocolSample.create_time
  374. <= _normalize_date_bound(qc_complete_time_end, end_of_day=True)
  375. )
  376. if user_info:
  377. from app.core.data_scope import apply_data_scope
  378. q = apply_data_scope(q, BizProtocolSample, user_info, db, tenant_no)
  379. total = q.count()
  380. page = max(1, page)
  381. page_size = min(max(1, page_size), 100)
  382. rows = (
  383. q.order_by(
  384. BizProtocolSample.create_time.desc(),
  385. BizProtocolSample.id.desc(),
  386. )
  387. .offset((page - 1) * page_size)
  388. .limit(page_size)
  389. .all()
  390. )
  391. sample_type_ids = list({
  392. psample.sample_type_id
  393. for psample, _, _, _, _ in rows
  394. if psample and psample.sample_type_id
  395. })
  396. type_map: Dict[str, str] = {}
  397. if sample_type_ids:
  398. for t in (
  399. db.query(BizCellSampleType)
  400. .filter(BizCellSampleType.id.in_(sample_type_ids), BizCellSampleType.del_flag == 0)
  401. .all()
  402. ):
  403. type_map[t.id] = t.type_name or ""
  404. items: List[Dict[str, Any]] = []
  405. for psample, wo, notif, proto, party in rows:
  406. if notif is None:
  407. notif = _ensure_notification(db, psample, tenant_no=tenant_no, wo=wo)
  408. tasks = _fetch_completed_tasks(db, wo.id if wo else "", tenant_no)
  409. conclusion = _derive_qc_conclusion(tasks) if wo else ""
  410. status_code, status_label = _qc_status_label(wo, qc_conclusion=conclusion)
  411. complete_dt = _qc_complete_time(tasks, wo) if status_code in ("tg", "btg") else None
  412. sample_type_name = ""
  413. if psample.sample_type_id:
  414. sample_type_name = type_map.get(psample.sample_type_id, "")
  415. items.append(
  416. _row_to_dict(
  417. notification_id=notif.id,
  418. qc_work_order_id=(wo.id if wo else ""),
  419. protocol_sample_id=psample.id,
  420. protocol_id=(notif.protocol_id or "") if notif else "",
  421. sms_notified=(notif.sms_notified or 0) if notif else 0,
  422. sms_content=(notif.sms_content or "") if notif else "",
  423. protocol_no=(proto.protocol_no or "") if proto else "",
  424. mother_name=(party.mother_name or "") if party else "",
  425. newborn_name=(party.newborn_name or "") if party else "",
  426. sample_code=psample.sample_code or (wo.sample_code if wo else "") or "",
  427. sample_type_name=sample_type_name,
  428. qc_complete_time=_fmt_dt(complete_dt) if complete_dt else "",
  429. qc_status=status_code,
  430. qc_status_label=status_label,
  431. qc_conclusion=conclusion,
  432. collection_protocol_id=(proto.id or "") if proto else "",
  433. protocol_status=(proto.protocol_status or "") if proto else "",
  434. )
  435. )
  436. if items:
  437. from app.crud import sample_reception as reception_svc
  438. codes = [i["sampleCode"] for i in items if i.get("sampleCode")]
  439. code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(
  440. db, tenant_no, codes, reception_source="cell_storage"
  441. )
  442. for item in items:
  443. sc = (item.get("sampleCode") or "").strip()
  444. item["labStorageCode"] = code_map.get(sc, "") if sc else ""
  445. return items, int(total)
  446. def toggle_notification_status(
  447. db: Session,
  448. *,
  449. notification_id: str,
  450. tenant_no: str,
  451. sms_notified: int,
  452. user_id: Optional[str] = None,
  453. qc_work_order_id: Optional[str] = None,
  454. protocol_sample_id: Optional[str] = None,
  455. ) -> Optional[BizQcSmsNotification]:
  456. notif = resolve_notification(
  457. db,
  458. notification_id=notification_id,
  459. tenant_no=tenant_no,
  460. qc_work_order_id=qc_work_order_id,
  461. protocol_sample_id=protocol_sample_id,
  462. user_id=user_id,
  463. )
  464. if not notif:
  465. return None
  466. notif.sms_notified = sms_notified
  467. if sms_notified == 1:
  468. notif.notified_by = user_id
  469. notif.notified_time = datetime.now()
  470. _user = db.query(SysUser.nick_name).filter(SysUser.id == user_id).first()
  471. notif.notified_by_name = _user.nick_name if _user else None
  472. else:
  473. notif.notified_by = None
  474. notif.notified_by_name = None
  475. notif.notified_time = None
  476. notif.update_by = user_id
  477. notif.update_time = datetime.now()
  478. db.commit()
  479. db.refresh(notif)
  480. return notif
  481. def generate_sms_content(
  482. db: Session,
  483. *,
  484. notification_id: str,
  485. tenant_no: str,
  486. qc_work_order_id: Optional[str] = None,
  487. protocol_sample_id: Optional[str] = None,
  488. user_id: Optional[str] = None,
  489. ) -> Optional[str]:
  490. notif = resolve_notification(
  491. db,
  492. notification_id=notification_id,
  493. tenant_no=tenant_no,
  494. qc_work_order_id=qc_work_order_id,
  495. protocol_sample_id=protocol_sample_id,
  496. user_id=user_id,
  497. )
  498. if not notif:
  499. return None
  500. psample = (
  501. db.query(BizProtocolSample)
  502. .filter(
  503. BizProtocolSample.id == notif.protocol_sample_id,
  504. BizProtocolSample.tenant_no == tenant_no,
  505. BizProtocolSample.del_flag == 0,
  506. )
  507. .first()
  508. )
  509. wo = None
  510. if notif.qc_work_order_id:
  511. wo = (
  512. db.query(BizQcWorkOrder)
  513. .filter(
  514. _eq_ci(BizQcWorkOrder.id, notif.qc_work_order_id),
  515. BizQcWorkOrder.tenant_no == tenant_no,
  516. BizQcWorkOrder.del_flag == 0,
  517. )
  518. .first()
  519. )
  520. if not wo and psample and psample.sample_code:
  521. wo = (
  522. db.query(BizQcWorkOrder)
  523. .filter(
  524. _eq_ci(BizQcWorkOrder.sample_code, psample.sample_code),
  525. BizQcWorkOrder.tenant_no == tenant_no,
  526. BizQcWorkOrder.del_flag == 0,
  527. BizQcWorkOrder.reception_source == "cell_storage",
  528. BizQcWorkOrder.qc_biz_type == "inbound",
  529. BizQcWorkOrder.trigger_source == "reception",
  530. )
  531. .first()
  532. )
  533. tasks = _fetch_completed_tasks(db, wo.id if wo else "", tenant_no)
  534. conclusion = _derive_qc_conclusion(tasks)
  535. if not conclusion:
  536. status_code, status_label = _qc_status_label(wo, qc_conclusion="")
  537. if status_code == "dzj":
  538. return "暂无短信内容,样本尚未开始质检"
  539. if status_code == "zjz":
  540. return "暂无短信内容,样本质检尚未完成"
  541. return "暂无短信内容,样本尚未完成质检确认"
  542. mother_name = ""
  543. proto_id = (wo.collection_protocol_id if wo else psample.collection_protocol_id) if psample else ""
  544. if proto_id:
  545. party = (
  546. db.query(BizProtocolPartyCustomer)
  547. .filter(
  548. BizProtocolPartyCustomer.collection_protocol_id == proto_id,
  549. BizProtocolPartyCustomer.del_flag == 0,
  550. )
  551. .first()
  552. )
  553. if party:
  554. mother_name = party.mother_name or ""
  555. sample_code = (psample.sample_code if psample else "") or (wo.sample_code if wo else "") or ""
  556. if conclusion == "tg":
  557. content = (
  558. f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
  559. f"已完成质检,结果合格。如有疑问请联系客服。"
  560. )
  561. else:
  562. content = (
  563. f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
  564. f"质检未通过。如有疑问请联系客服。"
  565. )
  566. notif.sms_content = content
  567. if wo and not notif.qc_work_order_id:
  568. notif.qc_work_order_id = wo.id
  569. notif.update_time = datetime.now()
  570. db.commit()
  571. db.refresh(notif)
  572. return content