qc_work_order_fee_summary.py 15 KB

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