qc_work_order_fee_summary.py 13 KB

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