qc_work_order_fee_summary.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. """质检工单 · 用户费用摘要(采集协议 / 基础实验)"""
  2. from __future__ import annotations
  3. from decimal import Decimal
  4. from datetime import datetime
  5. from typing import Any, Dict, List, Optional, Sequence, Tuple
  6. from sqlalchemy.orm import Session
  7. from app.models.clinical_payment import BizPilotPayment
  8. from app.models.models import (
  9. BizPilotInfusionSchedule,
  10. BizProtocolPayment,
  11. BizProtocolPaymentDetail,
  12. BizProtocolPaymentSchedule,
  13. BizProtocolPaymentTransfer,
  14. BizProtocolSamplePayment,
  15. )
  16. _PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"}
  17. _PAY_STATUS_LABELS = {
  18. "dfj": "已付款",
  19. "jfqrz": "付款确认中",
  20. "yjf": "付款完成",
  21. "tfqrz": "退费确认中",
  22. "ytf": "已退费",
  23. }
  24. # 出库工单:输注计划缴费(基础实验 / 临床应用共用 biz_pilot_payment)
  25. _OUTBOUND_FEE_BIZ_TYPES = frozenset({"basic_experiment", "clinical_application"})
  26. def _is_outbound_fee_biz_type(biz_type: Optional[str]) -> bool:
  27. return (biz_type or "").strip() in _OUTBOUND_FEE_BIZ_TYPES
  28. def _dec(val: Any) -> Optional[float]:
  29. if val is None:
  30. return None
  31. try:
  32. return float(val)
  33. except (TypeError, ValueError):
  34. return None
  35. def _received_amount_from_details(details: List[BizProtocolPaymentDetail]) -> float:
  36. total = Decimal("0")
  37. for d in details:
  38. st = (d.pay_status or "").strip()
  39. if st in ("yjf", "jfqrz"):
  40. total += d.transfer_amount or Decimal("0")
  41. return float(total)
  42. def _protocol_status_hint(pay_mode: str, statuses: List[str]) -> str:
  43. counts: Dict[str, int] = {}
  44. for st in statuses:
  45. if st:
  46. counts[st] = counts.get(st, 0) + 1
  47. paid = counts.get("yjf", 0)
  48. confirm = counts.get("jfqrz", 0)
  49. pending = counts.get("dfj", 0)
  50. if pay_mode in ("fq", "djfq"):
  51. parts: List[str] = ["分期"]
  52. if paid:
  53. parts.append(f"{paid} 期已缴")
  54. if confirm:
  55. parts.append(f"{confirm} 期待确认")
  56. if pending:
  57. parts.append(f"{pending} 期待缴费")
  58. return " · ".join(parts) if len(parts) > 1 else parts[0]
  59. if confirm:
  60. return f"{confirm} 期待确认" if confirm > 1 else "收款确认中"
  61. if pending:
  62. return "待缴费"
  63. return ""
  64. def _protocol_overall_status(
  65. statuses: List[str], contract: Optional[float], pending: Optional[float]
  66. ) -> Dict[str, str]:
  67. if "tfqrz" in statuses:
  68. return {"overallPayStatus": "tfqrz", "overallPayStatusLabel": "退费确认中", "overallPayStatusTagType": "warning"}
  69. if statuses and all(s == "ytf" for s in statuses):
  70. return {"overallPayStatus": "ytf", "overallPayStatusLabel": "已退费", "overallPayStatusTagType": "info"}
  71. if (
  72. contract is not None
  73. and pending is not None
  74. and pending <= 0
  75. and "dfj" not in statuses
  76. and "jfqrz" not in statuses
  77. ):
  78. return {"overallPayStatus": "cleared", "overallPayStatusLabel": "已缴清", "overallPayStatusTagType": "success"}
  79. if "jfqrz" in statuses:
  80. return {"overallPayStatus": "jfqrz", "overallPayStatusLabel": "收款确认中", "overallPayStatusTagType": "warning"}
  81. if "dfj" in statuses:
  82. return {"overallPayStatus": "dfj", "overallPayStatusLabel": "待缴费", "overallPayStatusTagType": "info"}
  83. return {"overallPayStatus": "", "overallPayStatusLabel": "—", "overallPayStatusTagType": "info"}
  84. def _fmt_ymd(val: Any) -> str:
  85. s = (str(val or "")).strip()
  86. if len(s) == 8 and s.isdigit():
  87. return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
  88. return s
  89. def _fmt_dt(v):
  90. if v is None:
  91. return ""
  92. if isinstance(v, datetime):
  93. return v.strftime("%Y-%m-%d %H:%M:%S")
  94. s = str(v).strip()
  95. return s
  96. def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) -> Dict[str, Any]:
  97. st = (d.pay_status or "").strip()
  98. inst_no = d.installment_no
  99. if pay_mode in ("fq", "djfq") and inst_no is not None:
  100. label = f"第{inst_no}期"
  101. else:
  102. label = f"缴费{idx + 1}"
  103. return {
  104. "installmentNo": inst_no,
  105. "installmentLabel": label,
  106. "installmentDueDate": _fmt_ymd(d.installment_due_date),
  107. "dueAmount": _dec(d.due_amount),
  108. "transferAmount": _dec(d.transfer_amount),
  109. "payStatus": st,
  110. "payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
  111. "transferTime": _fmt_dt(getattr(d, "transfer_time", None)),
  112. }
  113. def _sort_payment_details(details: List[BizProtocolPaymentDetail]) -> List[BizProtocolPaymentDetail]:
  114. return sorted(
  115. details,
  116. key=lambda d: (
  117. d.installment_no if d.installment_no is not None else 10_000,
  118. d.order_num if d.order_num is not None else 0,
  119. d.create_time or datetime.min,
  120. ),
  121. )
  122. def build_protocol_payment_summary(
  123. pay: Optional[BizProtocolPayment],
  124. details: List[BizProtocolPaymentDetail],
  125. ) -> Optional[Dict[str, Any]]:
  126. if not pay:
  127. return None
  128. pay_mode = (pay.pay_mode or "dj").strip() or "dj"
  129. contract = _dec(pay.contract_amount)
  130. sorted_details = _sort_payment_details(details)
  131. detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)]
  132. received = _received_amount_from_details(sorted_details)
  133. pending = max(contract - received, 0) if contract is not None else None
  134. statuses = [(d.pay_status or "").strip() for d in sorted_details]
  135. overall = _protocol_overall_status(statuses, contract, pending)
  136. hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
  137. return {
  138. "payMode": pay_mode,
  139. "payModeLabel": _PAY_MODE_LABELS.get(pay_mode, pay_mode),
  140. "contractAmount": contract,
  141. "receivedAmount": received,
  142. "pendingAmount": pending,
  143. "statusHint": hint,
  144. "installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
  145. "paymentDetails": detail_rows,
  146. **overall,
  147. }
  148. def pick_earliest_transfer_time_by_protocol(
  149. rows: Sequence[Tuple[Any, Any]],
  150. ) -> Dict[str, datetime]:
  151. """从 (protocol_id, transfer_time) 行中按协议取最早 transfer_time。"""
  152. earliest: Dict[str, datetime] = {}
  153. for pid, t in rows:
  154. if not pid or t is None:
  155. continue
  156. prev = earliest.get(pid)
  157. if prev is None or t < prev:
  158. earliest[pid] = t
  159. return earliest
  160. def _first_period_first_transfer_times(
  161. db: Session,
  162. protocol_ids: List[str],
  163. tenant_no: str,
  164. ) -> Dict[str, str]:
  165. """协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最早的一笔。"""
  166. ids = [x.strip() for x in protocol_ids if (x or "").strip()]
  167. if not ids:
  168. return {}
  169. rows = (
  170. db.query(
  171. BizProtocolPaymentTransfer.collection_protocol_id,
  172. BizProtocolPaymentTransfer.transfer_time,
  173. )
  174. .join(
  175. BizProtocolPaymentSchedule,
  176. BizProtocolPaymentSchedule.id == BizProtocolPaymentTransfer.schedule_id,
  177. )
  178. .filter(
  179. BizProtocolPaymentTransfer.collection_protocol_id.in_(ids),
  180. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  181. BizProtocolPaymentTransfer.del_flag == 0,
  182. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  183. BizProtocolPaymentSchedule.del_flag == 0,
  184. BizProtocolPaymentSchedule.schedule_no == 1,
  185. BizProtocolPaymentTransfer.transfer_time.isnot(None),
  186. )
  187. .all()
  188. )
  189. earliest = pick_earliest_transfer_time_by_protocol(rows)
  190. return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)}
  191. def build_protocol_payment_summaries(
  192. db: Session,
  193. protocol_ids: List[str],
  194. tenant_no: str,
  195. *,
  196. include_sample_payment_modes: bool = False,
  197. ) -> Dict[str, Dict[str, Any]]:
  198. ids = [x.strip() for x in protocol_ids if (x or "").strip()]
  199. if not ids:
  200. return {}
  201. payments = (
  202. db.query(BizProtocolPayment)
  203. .filter(
  204. BizProtocolPayment.collection_protocol_id.in_(ids),
  205. BizProtocolPayment.tenant_no == tenant_no,
  206. BizProtocolPayment.del_flag == 0,
  207. )
  208. .all()
  209. )
  210. pay_by_proto = {p.collection_protocol_id: p for p in payments}
  211. payment_ids = [p.id for p in payments]
  212. details_by_payment: Dict[str, List[BizProtocolPaymentDetail]] = {}
  213. if payment_ids:
  214. for d in (
  215. db.query(BizProtocolPaymentDetail)
  216. .filter(
  217. BizProtocolPaymentDetail.payment_id.in_(payment_ids),
  218. BizProtocolPaymentDetail.tenant_no == tenant_no,
  219. BizProtocolPaymentDetail.del_flag == 0,
  220. )
  221. .all()
  222. ):
  223. details_by_payment.setdefault(d.payment_id, []).append(d)
  224. sample_modes: Dict[str, set] = {}
  225. if include_sample_payment_modes:
  226. for pid, mode in db.query(
  227. BizProtocolSamplePayment.collection_protocol_id,
  228. BizProtocolSamplePayment.pay_mode,
  229. ).filter(
  230. BizProtocolSamplePayment.collection_protocol_id.in_(ids),
  231. BizProtocolSamplePayment.tenant_no == tenant_no,
  232. BizProtocolSamplePayment.del_flag == 0,
  233. ).all():
  234. if mode:
  235. sample_modes.setdefault(pid, set()).add(mode)
  236. first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no)
  237. out: Dict[str, Dict[str, Any]] = {}
  238. for pid in ids:
  239. pay = pay_by_proto.get(pid)
  240. summary = (
  241. build_protocol_payment_summary(pay, details_by_payment.get(pay.id, []))
  242. if pay
  243. else None
  244. )
  245. first_pay = first_pay_map.get(pid) or ""
  246. modes = sample_modes.get(pid, set())
  247. if not summary and not first_pay and not modes:
  248. continue
  249. if not summary:
  250. summary = {
  251. "payMode": "",
  252. "payModeLabel": "",
  253. "contractAmount": None,
  254. "receivedAmount": None,
  255. "pendingAmount": None,
  256. "statusHint": "",
  257. "installmentCount": None,
  258. "paymentDetails": [],
  259. "overallPayStatus": "",
  260. "overallPayStatusLabel": "—",
  261. "overallPayStatusTagType": "info",
  262. }
  263. if modes:
  264. # 协议列表按有效样本方案展示;未开启的质检费用摘要保持原语义。
  265. ordered_modes = sorted(modes, key=lambda mode: (
  266. {"dj": 0, "fq": 1, "djfq": 2, "fgd": 3}.get(mode, 99), mode
  267. ))
  268. labels = {**_PAY_MODE_LABELS, "fgd": "分改趸"}
  269. summary["payMode"] = "djfq" if modes == {"dj", "fq"} else "+".join(ordered_modes)
  270. summary["payModeLabel"] = "+".join(labels.get(mode, mode) for mode in ordered_modes)
  271. if first_pay:
  272. summary["firstPayTransferTime"] = first_pay
  273. out[pid] = summary
  274. return out
  275. def build_basic_experiment_fee_summary(
  276. schedule: Optional[BizPilotInfusionSchedule],
  277. payment: Optional[BizPilotPayment],
  278. ) -> Optional[Dict[str, Any]]:
  279. sale = _dec(schedule.amount) if schedule else None
  280. if not schedule and not payment:
  281. return None
  282. pay_status = (payment.pay_status or "").strip() if payment else ""
  283. transfer = None
  284. if payment and pay_status in ("yjf", "jfqrz"):
  285. transfer = _dec(payment.transfer_amount)
  286. return {
  287. "saleAmount": sale,
  288. "paymentAmount": _dec(payment.amount) if payment else None,
  289. "transferAmount": transfer,
  290. "payStatus": pay_status or None,
  291. "payStatusLabel": _PAY_STATUS_LABELS.get(pay_status, "—") if pay_status else None,
  292. }
  293. def build_basic_experiment_fee_summaries(
  294. db: Session,
  295. schedule_ids: List[str],
  296. tenant_no: str,
  297. ) -> Dict[str, Dict[str, Any]]:
  298. ids = [x.strip() for x in schedule_ids if (x or "").strip()]
  299. if not ids:
  300. return {}
  301. schedules = {
  302. s.id: s
  303. for s in db.query(BizPilotInfusionSchedule)
  304. .filter(
  305. BizPilotInfusionSchedule.id.in_(ids),
  306. BizPilotInfusionSchedule.tenant_no == tenant_no,
  307. BizPilotInfusionSchedule.del_flag == 0,
  308. )
  309. .all()
  310. }
  311. payments = {
  312. p.infusion_schedule_id: p
  313. for p in db.query(BizPilotPayment)
  314. .filter(
  315. BizPilotPayment.infusion_schedule_id.in_(ids),
  316. BizPilotPayment.tenant_no == tenant_no,
  317. BizPilotPayment.del_flag == 0,
  318. )
  319. .all()
  320. }
  321. out: Dict[str, Dict[str, Any]] = {}
  322. for sid in ids:
  323. summary = build_basic_experiment_fee_summary(schedules.get(sid), payments.get(sid))
  324. if summary:
  325. out[sid] = summary
  326. return out
  327. def _is_inbound_cell_storage_item(item: Dict[str, Any]) -> bool:
  328. wo_type = (item.get("workOrderType") or "inbound").strip() or "inbound"
  329. if wo_type in ("outbound", "temporary"):
  330. return False
  331. return (item.get("receptionSource") or "cell_storage").strip() in ("", "cell_storage")
  332. def _load_reception_map(db: Session, reception_ids: List[str]) -> Dict[str, Any]:
  333. from app.models.models import BizSampleReception
  334. ids = [x for x in reception_ids if x]
  335. if not ids:
  336. return {}
  337. return {
  338. r.id: r
  339. for r in db.query(BizSampleReception)
  340. .filter(BizSampleReception.id.in_(ids), BizSampleReception.del_flag == 0)
  341. .all()
  342. }
  343. def _load_protocol_id_by_no(db: Session, protocol_nos: List[str], tenant_no: str) -> Dict[str, str]:
  344. from app.models.models import BizCollectionProtocol
  345. nos = [x for x in protocol_nos if x]
  346. if not nos:
  347. return {}
  348. rows = (
  349. db.query(BizCollectionProtocol.protocol_no, BizCollectionProtocol.id)
  350. .filter(
  351. BizCollectionProtocol.protocol_no.in_(nos),
  352. BizCollectionProtocol.tenant_no == tenant_no,
  353. BizCollectionProtocol.del_flag == 0,
  354. )
  355. .all()
  356. )
  357. return {(r.protocol_no or "").strip(): r.id for r in rows if (r.protocol_no or "").strip()}
  358. def _effective_collection_protocol_id(
  359. item: Dict[str, Any],
  360. rec: Optional[Any],
  361. proto_no_map: Dict[str, str],
  362. ) -> str:
  363. pid = (item.get("collectionProtocolId") or "").strip()
  364. if pid:
  365. return pid
  366. if rec is not None:
  367. pid = (getattr(rec, "collection_protocol_id", None) or "").strip()
  368. if pid:
  369. return pid
  370. pno = (item.get("protocolNo") or "").strip()
  371. return proto_no_map.get(pno, "")
  372. def enrich_work_order_fee_summaries(
  373. db: Session,
  374. items: List[Dict[str, Any]],
  375. tenant_no: str,
  376. ) -> List[Dict[str, Any]]:
  377. if not items or not tenant_no:
  378. return items
  379. inbound_items = [x for x in items if _is_inbound_cell_storage_item(x)]
  380. rec_ids = [(x.get("receptionId") or "").strip() for x in inbound_items if (x.get("receptionId") or "").strip()]
  381. rec_map = _load_reception_map(db, rec_ids)
  382. proto_nos = list(
  383. dict.fromkeys(
  384. (x.get("protocolNo") or "").strip()
  385. for x in inbound_items
  386. if (x.get("protocolNo") or "").strip()
  387. )
  388. )
  389. proto_no_map = _load_protocol_id_by_no(db, proto_nos, tenant_no)
  390. protocol_ids: List[str] = []
  391. for item in inbound_items:
  392. rec = rec_map.get((item.get("receptionId") or "").strip())
  393. pid = _effective_collection_protocol_id(item, rec, proto_no_map)
  394. if pid:
  395. protocol_ids.append(pid)
  396. schedule_ids = list(
  397. dict.fromkeys(
  398. (x.get("infusionScheduleId") or "").strip()
  399. for x in items
  400. if (x.get("workOrderType") or "") == "outbound"
  401. and _is_outbound_fee_biz_type(x.get("bizType"))
  402. and (x.get("infusionScheduleId") or "").strip()
  403. )
  404. )
  405. payment_map = build_protocol_payment_summaries(db, list(dict.fromkeys(protocol_ids)), tenant_no)
  406. fee_map = build_basic_experiment_fee_summaries(db, schedule_ids, tenant_no)
  407. for item in items:
  408. wo_type = item.get("workOrderType") or "inbound"
  409. if wo_type == "outbound":
  410. item["paymentSummary"] = None
  411. if _is_outbound_fee_biz_type(item.get("bizType")):
  412. sid = (item.get("infusionScheduleId") or "").strip()
  413. item["feeSummary"] = fee_map.get(sid) if sid else None
  414. else:
  415. item["feeSummary"] = None
  416. elif wo_type == "temporary":
  417. item["paymentSummary"] = None
  418. item["feeSummary"] = None
  419. else:
  420. item["feeSummary"] = None
  421. if _is_inbound_cell_storage_item(item):
  422. rec = rec_map.get((item.get("receptionId") or "").strip())
  423. pid = _effective_collection_protocol_id(item, rec, proto_no_map)
  424. item["paymentSummary"] = payment_map.get(pid) if pid else None
  425. else:
  426. item["paymentSummary"] = None
  427. return items