protocol_sample_payment.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215
  1. """按样本缴费方案 / 应缴批次 / 转账笔 CRUD(payment_model_version=2)"""
  2. from __future__ import annotations
  3. import json
  4. from datetime import datetime
  5. from decimal import Decimal
  6. from typing import Any, Dict, List, Optional, Tuple
  7. from sqlalchemy import and_, exists, func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import generate_uuid, parse_datetime
  10. from app.crud.collection_protocol import urls_to_attachment_list
  11. from app.models.models import (
  12. BizCollectionProtocol,
  13. BizPresignTransfer,
  14. BizProtocolPartyCustomer,
  15. BizProtocolPaymentSchedule,
  16. BizProtocolPaymentTransfer,
  17. BizProtocolSamplePayment,
  18. )
  19. from app.services.protocol_transfer_status import (
  20. can_confirm_receipt,
  21. can_submit_receipt,
  22. schedule_paid_and_arrears,
  23. )
  24. from app.services.sample_payment_progress import compute_sample_payment_progress
  25. def _dec(v: Any) -> Optional[Decimal]:
  26. if v is None or v == "":
  27. return None
  28. return Decimal(str(v))
  29. def _now() -> datetime:
  30. return datetime.now()
  31. def _get_sample_payment_or_raise(
  32. db: Session, *, sample_payment_id: str, tenant_no: str
  33. ) -> BizProtocolSamplePayment:
  34. obj = (
  35. db.query(BizProtocolSamplePayment)
  36. .filter(
  37. BizProtocolSamplePayment.id == sample_payment_id,
  38. BizProtocolSamplePayment.tenant_no == tenant_no,
  39. BizProtocolSamplePayment.del_flag == 0,
  40. )
  41. .first()
  42. )
  43. if not obj:
  44. raise ValueError("样本缴费方案不存在")
  45. return obj
  46. def _get_schedule_or_raise(
  47. db: Session, *, schedule_id: str, tenant_no: str
  48. ) -> BizProtocolPaymentSchedule:
  49. obj = (
  50. db.query(BizProtocolPaymentSchedule)
  51. .filter(
  52. BizProtocolPaymentSchedule.id == schedule_id,
  53. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  54. BizProtocolPaymentSchedule.del_flag == 0,
  55. )
  56. .first()
  57. )
  58. if not obj:
  59. raise ValueError("应缴批次不存在")
  60. return obj
  61. def _get_transfer_or_raise(
  62. db: Session, *, transfer_id: str, tenant_no: str
  63. ) -> BizProtocolPaymentTransfer:
  64. obj = (
  65. db.query(BizProtocolPaymentTransfer)
  66. .filter(
  67. BizProtocolPaymentTransfer.id == transfer_id,
  68. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  69. BizProtocolPaymentTransfer.del_flag == 0,
  70. )
  71. .first()
  72. )
  73. if not obj:
  74. raise ValueError("转账笔不存在")
  75. return obj
  76. def has_blocking_transfer_for_sample_payment(
  77. db: Session, *, sample_payment_id: str, tenant_no: str
  78. ) -> bool:
  79. row = (
  80. db.query(BizProtocolPaymentTransfer.id)
  81. .filter(
  82. BizProtocolPaymentTransfer.sample_payment_id == sample_payment_id,
  83. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  84. BizProtocolPaymentTransfer.del_flag == 0,
  85. BizProtocolPaymentTransfer.pay_status == "jfqrz",
  86. )
  87. .first()
  88. )
  89. return row is not None
  90. def assert_sample_payment_editable(
  91. db: Session, *, sample_payment_id: str, tenant_no: str
  92. ) -> None:
  93. if has_blocking_transfer_for_sample_payment(
  94. db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
  95. ):
  96. raise ValueError("存在缴费确认中的转账笔,无法修改缴费方案或批次")
  97. def upsert_sample_payment(
  98. db: Session,
  99. *,
  100. collection_protocol_id: str,
  101. tenant_no: str,
  102. user_id: Optional[str],
  103. sample_code: str,
  104. pay_mode: str,
  105. contract_amount: Any = None,
  106. protocol_sample_id: Optional[str] = None,
  107. installment_count: Optional[int] = None,
  108. installment_interval_months: Any = None,
  109. installment_start_date: Optional[str] = None,
  110. installment_advance_days: Optional[int] = None,
  111. installment_ratios: Optional[str] = None,
  112. lump_sum_deadline: Optional[str] = None,
  113. remark: Optional[str] = None,
  114. sample_payment_id: Optional[str] = None,
  115. ) -> BizProtocolSamplePayment:
  116. code = (sample_code or "").strip()
  117. if not code:
  118. raise ValueError("样本编码不能为空")
  119. mode = (pay_mode or "").strip()
  120. if mode not in ("dj", "fq", "djfq"):
  121. raise ValueError("缴费模式无效")
  122. obj: Optional[BizProtocolSamplePayment] = None
  123. if sample_payment_id:
  124. obj = _get_sample_payment_or_raise(
  125. db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
  126. )
  127. else:
  128. obj = (
  129. db.query(BizProtocolSamplePayment)
  130. .filter(
  131. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  132. BizProtocolSamplePayment.tenant_no == tenant_no,
  133. BizProtocolSamplePayment.sample_code == code,
  134. BizProtocolSamplePayment.del_flag == 0,
  135. )
  136. .first()
  137. )
  138. now = _now()
  139. if obj:
  140. assert_sample_payment_editable(db, sample_payment_id=obj.id, tenant_no=tenant_no)
  141. if (obj.pay_mode or "") != mode:
  142. assert_sample_payment_editable(db, sample_payment_id=obj.id, tenant_no=tenant_no)
  143. obj.protocol_sample_id = (protocol_sample_id or "").strip() or None
  144. obj.sample_code = code
  145. obj.pay_mode = mode
  146. obj.contract_amount = _dec(contract_amount)
  147. obj.installment_count = installment_count
  148. obj.installment_interval_months = _dec(installment_interval_months)
  149. obj.installment_start_date = (installment_start_date or "").strip() or None
  150. obj.installment_advance_days = installment_advance_days
  151. obj.installment_ratios = (installment_ratios or "").strip() or None
  152. obj.lump_sum_deadline = (lump_sum_deadline or "").strip() or None
  153. if remark is not None:
  154. obj.remark = (remark or "").strip() or None
  155. obj.update_by = user_id
  156. obj.update_time = now
  157. db.add(obj)
  158. db.flush()
  159. return obj
  160. obj = BizProtocolSamplePayment(
  161. id=generate_uuid(),
  162. collection_protocol_id=collection_protocol_id,
  163. protocol_sample_id=(protocol_sample_id or "").strip() or None,
  164. sample_code=code,
  165. pay_mode=mode,
  166. contract_amount=_dec(contract_amount),
  167. installment_count=installment_count,
  168. installment_interval_months=_dec(installment_interval_months),
  169. installment_start_date=(installment_start_date or "").strip() or None,
  170. installment_advance_days=installment_advance_days,
  171. installment_ratios=(installment_ratios or "").strip() or None,
  172. lump_sum_deadline=(lump_sum_deadline or "").strip() or None,
  173. tenant_no=tenant_no,
  174. order_num=0,
  175. remark=(remark or "").strip() or None,
  176. create_by=user_id,
  177. create_dept_id="",
  178. create_time=now,
  179. update_by=user_id,
  180. update_time=now,
  181. del_flag=0,
  182. )
  183. db.add(obj)
  184. db.flush()
  185. return obj
  186. def replace_schedules(
  187. db: Session,
  188. *,
  189. sample_payment_id: str,
  190. tenant_no: str,
  191. user_id: Optional[str],
  192. schedules: List[Dict[str, Any]],
  193. ) -> List[BizProtocolPaymentSchedule]:
  194. """软删原批次(及其下 dfj 转账),重建批次。存在 jfqrz 时禁止。"""
  195. sp = _get_sample_payment_or_raise(
  196. db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
  197. )
  198. assert_sample_payment_editable(
  199. db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
  200. )
  201. now = _now()
  202. old_schedules = (
  203. db.query(BizProtocolPaymentSchedule)
  204. .filter(
  205. BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
  206. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  207. BizProtocolPaymentSchedule.del_flag == 0,
  208. )
  209. .all()
  210. )
  211. old_ids = [s.id for s in old_schedules]
  212. if old_ids:
  213. transfers = (
  214. db.query(BizProtocolPaymentTransfer)
  215. .filter(
  216. BizProtocolPaymentTransfer.schedule_id.in_(old_ids),
  217. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  218. BizProtocolPaymentTransfer.del_flag == 0,
  219. )
  220. .all()
  221. )
  222. for t in transfers:
  223. if t.pay_status == "jfqrz":
  224. raise ValueError("存在缴费确认中的转账笔,无法重建批次")
  225. if t.pay_status == "dfj":
  226. t.del_flag = 1
  227. t.update_by = user_id
  228. t.update_time = now
  229. db.add(t)
  230. # yjf 转账保留:所属旧批次软删后仍可历史追溯;新批次不含这些笔
  231. for s in old_schedules:
  232. s.del_flag = 1
  233. s.update_by = user_id
  234. s.update_time = now
  235. db.add(s)
  236. created: List[BizProtocolPaymentSchedule] = []
  237. for idx, raw in enumerate(schedules or []):
  238. due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
  239. schedule_status = (
  240. raw.get("schedule_status") or raw.get("scheduleStatus") or "open"
  241. )
  242. obj = BizProtocolPaymentSchedule(
  243. id=generate_uuid(),
  244. sample_payment_id=sp.id,
  245. collection_protocol_id=sp.collection_protocol_id,
  246. sample_code=sp.sample_code,
  247. schedule_no=raw.get("schedule_no")
  248. if raw.get("schedule_no") is not None
  249. else raw.get("scheduleNo"),
  250. due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
  251. due_date=due_date,
  252. schedule_status=(str(schedule_status).strip() or "open"),
  253. tenant_no=tenant_no,
  254. order_num=idx,
  255. remark=(raw.get("remark") or "").strip() or None,
  256. create_by=user_id,
  257. create_dept_id="",
  258. create_time=now,
  259. update_by=user_id,
  260. update_time=now,
  261. del_flag=0,
  262. )
  263. db.add(obj)
  264. created.append(obj)
  265. db.flush()
  266. return created
  267. def add_transfer(
  268. db: Session,
  269. *,
  270. schedule_id: str,
  271. tenant_no: str,
  272. user_id: Optional[str],
  273. transfer_amount: Any = None,
  274. transfer_time: Any = None,
  275. transfer_type: Optional[str] = None,
  276. transfer_attachments: Optional[str] = None,
  277. remark: Optional[str] = None,
  278. commit: bool = True,
  279. ) -> BizProtocolPaymentTransfer:
  280. sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
  281. now = _now()
  282. tt = None
  283. if transfer_time not in (None, ""):
  284. tt = (
  285. transfer_time
  286. if isinstance(transfer_time, datetime)
  287. else parse_datetime(str(transfer_time))
  288. )
  289. obj = BizProtocolPaymentTransfer(
  290. id=generate_uuid(),
  291. schedule_id=sch.id,
  292. sample_payment_id=sch.sample_payment_id,
  293. collection_protocol_id=sch.collection_protocol_id,
  294. sample_code=sch.sample_code,
  295. transfer_amount=_dec(transfer_amount),
  296. transfer_time=tt,
  297. transfer_type=(transfer_type or "").strip() or None,
  298. transfer_attachments=(transfer_attachments or "").strip() or None,
  299. pay_status="dfj",
  300. tenant_no=tenant_no,
  301. order_num=0,
  302. remark=(remark or "").strip() or None,
  303. create_by=user_id,
  304. create_dept_id="",
  305. create_time=now,
  306. update_by=user_id,
  307. update_time=now,
  308. del_flag=0,
  309. )
  310. db.add(obj)
  311. if commit:
  312. db.commit()
  313. db.refresh(obj)
  314. else:
  315. db.flush()
  316. return obj
  317. def update_transfer_draft(
  318. db: Session,
  319. *,
  320. transfer_id: str,
  321. tenant_no: str,
  322. user_id: Optional[str],
  323. fields: Dict[str, Any],
  324. ) -> BizProtocolPaymentTransfer:
  325. t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
  326. if t.pay_status != "dfj":
  327. raise ValueError("仅已缴费状态可编辑转账笔")
  328. if "transfer_type" in fields or "transferType" in fields:
  329. v = fields.get("transfer_type", fields.get("transferType"))
  330. t.transfer_type = (str(v) if v is not None else "").strip() or None
  331. if "transfer_amount" in fields or "transferAmount" in fields:
  332. v = fields.get("transfer_amount", fields.get("transferAmount"))
  333. amt = _dec(v)
  334. if amt is not None and amt < 0:
  335. raise ValueError("转账金额无效")
  336. t.transfer_amount = amt
  337. if "transfer_time" in fields or "transferTime" in fields:
  338. v = fields.get("transfer_time", fields.get("transferTime"))
  339. if v in (None, ""):
  340. t.transfer_time = None
  341. elif isinstance(v, datetime):
  342. t.transfer_time = v
  343. else:
  344. t.transfer_time = parse_datetime(str(v))
  345. if "transfer_attachments" in fields or "transferAttachments" in fields:
  346. v = fields.get("transfer_attachments", fields.get("transferAttachments"))
  347. t.transfer_attachments = (str(v) if v is not None else "").strip() or None
  348. if "remark" in fields:
  349. t.remark = (fields.get("remark") or "").strip() or None
  350. t.update_by = user_id
  351. t.update_time = _now()
  352. db.add(t)
  353. db.commit()
  354. db.refresh(t)
  355. return t
  356. def _validate_transfer_submit_ready(t: BizProtocolPaymentTransfer) -> None:
  357. if not (t.transfer_type or "").strip():
  358. raise ValueError("请选择转账方式")
  359. if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
  360. raise ValueError("请填写转账金额")
  361. def submit_receipt_finance(
  362. db: Session,
  363. *,
  364. transfer_id: str,
  365. tenant_no: str,
  366. user_id: Optional[str],
  367. draft_fields: Optional[Dict[str, Any]] = None,
  368. ) -> BizProtocolPaymentTransfer:
  369. t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
  370. if draft_fields:
  371. if t.pay_status != "dfj":
  372. raise ValueError("仅已缴费状态可编辑转账笔")
  373. # 就地改草稿字段(不单独 commit)
  374. if "transfer_type" in draft_fields or "transferType" in draft_fields:
  375. v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
  376. t.transfer_type = (str(v) if v is not None else "").strip() or None
  377. if "transfer_amount" in draft_fields or "transferAmount" in draft_fields:
  378. t.transfer_amount = _dec(
  379. draft_fields.get("transfer_amount", draft_fields.get("transferAmount"))
  380. )
  381. if "transfer_time" in draft_fields or "transferTime" in draft_fields:
  382. v = draft_fields.get("transfer_time", draft_fields.get("transferTime"))
  383. if v in (None, ""):
  384. t.transfer_time = None
  385. elif isinstance(v, datetime):
  386. t.transfer_time = v
  387. else:
  388. t.transfer_time = parse_datetime(str(v))
  389. if "transfer_attachments" in draft_fields or "transferAttachments" in draft_fields:
  390. v = draft_fields.get(
  391. "transfer_attachments", draft_fields.get("transferAttachments")
  392. )
  393. t.transfer_attachments = (str(v) if v is not None else "").strip() or None
  394. if not can_submit_receipt(t.pay_status):
  395. raise ValueError("仅已缴费状态可提交财务确认")
  396. _validate_transfer_submit_ready(t)
  397. t.pay_status = "jfqrz"
  398. t.update_by = user_id
  399. t.update_time = _now()
  400. db.add(t)
  401. db.commit()
  402. db.refresh(t)
  403. return t
  404. def confirm_receipt(
  405. db: Session,
  406. *,
  407. transfer_id: str,
  408. tenant_no: str,
  409. user_id: Optional[str],
  410. finance_confirm_attachments: Optional[str] = None,
  411. finance_remark: Optional[str] = None,
  412. ) -> BizProtocolPaymentTransfer:
  413. t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
  414. if t.pay_status == "yjf":
  415. raise ValueError("该转账笔已确认收款")
  416. if not can_confirm_receipt(t.pay_status):
  417. raise ValueError("当前状态不可确认收款")
  418. t.pay_status = "yjf"
  419. if finance_confirm_attachments is not None:
  420. t.finance_confirm_attachments = finance_confirm_attachments.strip() or None
  421. if finance_remark is not None:
  422. t.finance_remark = finance_remark.strip() or None
  423. t.update_by = user_id
  424. t.update_time = _now()
  425. db.add(t)
  426. db.commit()
  427. db.refresh(t)
  428. return t
  429. def reject_receipt(
  430. db: Session,
  431. *,
  432. transfer_id: str,
  433. tenant_no: str,
  434. user_id: Optional[str],
  435. ) -> BizProtocolPaymentTransfer:
  436. t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
  437. if t.pay_status != "jfqrz":
  438. raise ValueError("当前状态不可退回")
  439. t.pay_status = "dfj"
  440. t.update_by = user_id
  441. t.update_time = _now()
  442. db.add(t)
  443. db.commit()
  444. db.refresh(t)
  445. return t
  446. def _transfer_to_dict(
  447. db: Session, t: BizProtocolPaymentTransfer, tenant_no: str
  448. ) -> Dict[str, Any]:
  449. return {
  450. "id": t.id,
  451. "scheduleId": t.schedule_id,
  452. "samplePaymentId": t.sample_payment_id,
  453. "collectionProtocolId": t.collection_protocol_id,
  454. "sampleCode": t.sample_code or "",
  455. "transferAmount": float(t.transfer_amount) if t.transfer_amount is not None else None,
  456. "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if t.transfer_time else "",
  457. "transferType": t.transfer_type or "",
  458. "transferAttachments": t.transfer_attachments or "",
  459. "attachments": urls_to_attachment_list(t.transfer_attachments, db, tenant_no),
  460. "payStatus": t.pay_status or "",
  461. "financeConfirmAttachments": t.finance_confirm_attachments or "",
  462. "financeConfirmAttachmentFiles": urls_to_attachment_list(
  463. t.finance_confirm_attachments, db, tenant_no
  464. ),
  465. "financeRemark": t.finance_remark or "",
  466. "remark": t.remark or "",
  467. "source": "formal",
  468. "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
  469. "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
  470. }
  471. def _schedule_to_dict(
  472. db: Session,
  473. s: BizProtocolPaymentSchedule,
  474. transfers: List[BizProtocolPaymentTransfer],
  475. tenant_no: str,
  476. ) -> Dict[str, Any]:
  477. confirmed = [
  478. t.transfer_amount
  479. for t in transfers
  480. if (t.pay_status or "") == "yjf"
  481. ]
  482. paid, arrears = schedule_paid_and_arrears(
  483. due=s.due_amount, confirmed_transfers=confirmed
  484. )
  485. return {
  486. "id": s.id,
  487. "samplePaymentId": s.sample_payment_id,
  488. "collectionProtocolId": s.collection_protocol_id,
  489. "sampleCode": s.sample_code or "",
  490. "scheduleNo": s.schedule_no,
  491. "dueAmount": float(s.due_amount) if s.due_amount is not None else None,
  492. "dueDate": s.due_date or "",
  493. "scheduleStatus": s.schedule_status or "",
  494. "paidAmount": float(paid),
  495. "arrearsAmount": float(arrears),
  496. "remark": s.remark or "",
  497. "transfers": [_transfer_to_dict(db, t, tenant_no) for t in transfers],
  498. }
  499. def _sample_payment_to_dict(
  500. db: Session,
  501. p: BizProtocolSamplePayment,
  502. schedules: List[BizProtocolPaymentSchedule],
  503. transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
  504. tenant_no: str,
  505. ) -> Dict[str, Any]:
  506. return {
  507. "id": p.id,
  508. "collectionProtocolId": p.collection_protocol_id,
  509. "protocolSampleId": p.protocol_sample_id or "",
  510. "sampleCode": p.sample_code or "",
  511. "payMode": p.pay_mode or "",
  512. "contractAmount": float(p.contract_amount) if p.contract_amount is not None else None,
  513. "installmentCount": p.installment_count,
  514. "installmentIntervalMonths": float(p.installment_interval_months)
  515. if p.installment_interval_months is not None
  516. else None,
  517. "installmentStartDate": p.installment_start_date or "",
  518. "installmentAdvanceDays": p.installment_advance_days,
  519. "installmentRatios": p.installment_ratios or "",
  520. "lumpSumDeadline": p.lump_sum_deadline or "",
  521. "remark": p.remark or "",
  522. "schedules": [
  523. _schedule_to_dict(
  524. db, s, transfers_by_schedule.get(s.id, []), tenant_no
  525. )
  526. for s in schedules
  527. ],
  528. }
  529. def _normalize_installment_ratios(v: Any) -> Optional[str]:
  530. if v is None or v == "":
  531. return None
  532. if isinstance(v, (list, dict)):
  533. return json.dumps(v, ensure_ascii=False)
  534. return str(v).strip() or None
  535. def _soft_delete_sample_payment(
  536. db: Session,
  537. *,
  538. sp: BizProtocolSamplePayment,
  539. tenant_no: str,
  540. user_id: Optional[str],
  541. ) -> None:
  542. assert_sample_payment_editable(db, sample_payment_id=sp.id, tenant_no=tenant_no)
  543. now = _now()
  544. schedules = (
  545. db.query(BizProtocolPaymentSchedule)
  546. .filter(
  547. BizProtocolPaymentSchedule.sample_payment_id == sp.id,
  548. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  549. BizProtocolPaymentSchedule.del_flag == 0,
  550. )
  551. .all()
  552. )
  553. sids = [s.id for s in schedules]
  554. if sids:
  555. transfers = (
  556. db.query(BizProtocolPaymentTransfer)
  557. .filter(
  558. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  559. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  560. BizProtocolPaymentTransfer.del_flag == 0,
  561. )
  562. .all()
  563. )
  564. for t in transfers:
  565. if t.pay_status == "jfqrz":
  566. raise ValueError("存在缴费确认中的转账笔,无法删除样本缴费方案")
  567. if t.pay_status == "dfj":
  568. t.del_flag = 1
  569. t.update_by = user_id
  570. t.update_time = now
  571. db.add(t)
  572. for s in schedules:
  573. s.del_flag = 1
  574. s.update_by = user_id
  575. s.update_time = now
  576. db.add(s)
  577. sp.del_flag = 1
  578. sp.update_by = user_id
  579. sp.update_time = now
  580. db.add(sp)
  581. def save_sample_payments_for_protocol(
  582. db: Session,
  583. *,
  584. collection_protocol_id: str,
  585. tenant_no: str,
  586. user_id: Optional[str],
  587. sample_payments: List[Dict[str, Any]],
  588. ) -> None:
  589. """按样本保存缴费方案:upsert 方案 → 重建批次 → 写入待缴费转账笔。"""
  590. existing = (
  591. db.query(BizProtocolSamplePayment)
  592. .filter(
  593. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  594. BizProtocolSamplePayment.tenant_no == tenant_no,
  595. BizProtocolSamplePayment.del_flag == 0,
  596. )
  597. .all()
  598. )
  599. keep_ids: set = set()
  600. for raw in sample_payments or []:
  601. if not isinstance(raw, dict):
  602. continue
  603. sample_code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
  604. if not sample_code:
  605. raise ValueError("样本缴费方案缺少样本编码")
  606. pay_mode = (raw.get("pay_mode") or raw.get("payMode") or "").strip()
  607. sp_id = (raw.get("id") or raw.get("samplePaymentId") or "").strip() or None
  608. ratios = raw.get("installment_ratios")
  609. if ratios is None:
  610. ratios = raw.get("installmentRatios")
  611. sp = upsert_sample_payment(
  612. db,
  613. collection_protocol_id=collection_protocol_id,
  614. tenant_no=tenant_no,
  615. user_id=user_id,
  616. sample_code=sample_code,
  617. pay_mode=pay_mode,
  618. contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
  619. protocol_sample_id=raw.get("protocol_sample_id") or raw.get("protocolSampleId"),
  620. installment_count=raw.get("installment_count", raw.get("installmentCount")),
  621. installment_interval_months=raw.get(
  622. "installment_interval_months", raw.get("installmentIntervalMonths")
  623. ),
  624. installment_start_date=raw.get(
  625. "installment_start_date", raw.get("installmentStartDate")
  626. ),
  627. installment_advance_days=raw.get(
  628. "installment_advance_days", raw.get("installmentAdvanceDays")
  629. ),
  630. installment_ratios=_normalize_installment_ratios(ratios),
  631. lump_sum_deadline=raw.get("lump_sum_deadline", raw.get("lumpSumDeadline")),
  632. remark=raw.get("remark"),
  633. sample_payment_id=sp_id,
  634. )
  635. keep_ids.add(sp.id)
  636. schedules_payload = raw.get("schedules") or []
  637. created = replace_schedules(
  638. db,
  639. sample_payment_id=sp.id,
  640. tenant_no=tenant_no,
  641. user_id=user_id,
  642. schedules=schedules_payload,
  643. )
  644. for i, sch_raw in enumerate(schedules_payload):
  645. if i >= len(created) or not isinstance(sch_raw, dict):
  646. continue
  647. for t_raw in sch_raw.get("transfers") or []:
  648. if not isinstance(t_raw, dict):
  649. continue
  650. status = (
  651. t_raw.get("pay_status") or t_raw.get("payStatus") or "dfj"
  652. ).strip()
  653. # 仅重建待缴费草稿;jfqrz/yjf 由既有流程保留在旧批次或独立接口维护
  654. if status != "dfj":
  655. continue
  656. add_transfer(
  657. db,
  658. schedule_id=created[i].id,
  659. tenant_no=tenant_no,
  660. user_id=user_id,
  661. transfer_amount=t_raw.get(
  662. "transfer_amount", t_raw.get("transferAmount")
  663. ),
  664. transfer_time=t_raw.get(
  665. "transfer_time", t_raw.get("transferTime")
  666. ),
  667. transfer_type=t_raw.get(
  668. "transfer_type", t_raw.get("transferType")
  669. ),
  670. transfer_attachments=t_raw.get(
  671. "transfer_attachments", t_raw.get("transferAttachments")
  672. ),
  673. remark=t_raw.get("remark"),
  674. commit=False,
  675. )
  676. for sp in existing:
  677. if sp.id not in keep_ids:
  678. _soft_delete_sample_payment(
  679. db, sp=sp, tenant_no=tenant_no, user_id=user_id
  680. )
  681. db.commit()
  682. def get_presign_deposit_hint(
  683. db: Session, *, source_presign_order_id: Optional[str], tenant_no: str
  684. ) -> Optional[Dict[str, Any]]:
  685. pid = (source_presign_order_id or "").strip()
  686. if not pid:
  687. return None
  688. rows = (
  689. db.query(BizPresignTransfer)
  690. .filter(
  691. BizPresignTransfer.presign_order_id == pid,
  692. BizPresignTransfer.tenant_no == tenant_no,
  693. BizPresignTransfer.del_flag == 0,
  694. BizPresignTransfer.pay_status == "yjf",
  695. )
  696. .all()
  697. )
  698. total = Decimal("0")
  699. for r in rows:
  700. if r.transfer_amount is not None:
  701. total += Decimal(str(r.transfer_amount))
  702. return {
  703. "presignOrderId": pid,
  704. "confirmedDepositAmount": float(total),
  705. "confirmedTransferCount": len(rows),
  706. }
  707. def get_sample_payments_bundle(
  708. db: Session, collection_protocol_id: str, tenant_no: str
  709. ) -> List[Dict[str, Any]]:
  710. payments = (
  711. db.query(BizProtocolSamplePayment)
  712. .filter(
  713. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  714. BizProtocolSamplePayment.tenant_no == tenant_no,
  715. BizProtocolSamplePayment.del_flag == 0,
  716. )
  717. .order_by(
  718. BizProtocolSamplePayment.order_num.asc(),
  719. BizProtocolSamplePayment.create_time.asc(),
  720. )
  721. .all()
  722. )
  723. if not payments:
  724. return []
  725. pids = [p.id for p in payments]
  726. schedules = (
  727. db.query(BizProtocolPaymentSchedule)
  728. .filter(
  729. BizProtocolPaymentSchedule.sample_payment_id.in_(pids),
  730. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  731. BizProtocolPaymentSchedule.del_flag == 0,
  732. )
  733. .order_by(
  734. BizProtocolPaymentSchedule.order_num.asc(),
  735. BizProtocolPaymentSchedule.schedule_no.asc(),
  736. )
  737. .all()
  738. )
  739. sids = [s.id for s in schedules]
  740. transfers: List[BizProtocolPaymentTransfer] = []
  741. if sids:
  742. transfers = (
  743. db.query(BizProtocolPaymentTransfer)
  744. .filter(
  745. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  746. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  747. BizProtocolPaymentTransfer.del_flag == 0,
  748. )
  749. .order_by(BizProtocolPaymentTransfer.create_time.asc())
  750. .all()
  751. )
  752. by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  753. for t in transfers:
  754. by_schedule.setdefault(t.schedule_id, []).append(t)
  755. schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
  756. for s in schedules:
  757. schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
  758. return [
  759. _sample_payment_to_dict(
  760. db,
  761. p,
  762. schedules_by_payment.get(p.id, []),
  763. by_schedule,
  764. tenant_no,
  765. )
  766. for p in payments
  767. ]
  768. def list_transfers_for_finance(
  769. db: Session,
  770. *,
  771. pay_status: str,
  772. tenant_no: str,
  773. keyword: Optional[str] = None,
  774. page: int = 1,
  775. page_size: int = 10,
  776. user_info=None,
  777. due_date_start: Optional[str] = None,
  778. due_date_end: Optional[str] = None,
  779. ) -> Tuple[List[Dict[str, Any]], int]:
  780. if not tenant_no:
  781. return [], 0
  782. st = (pay_status or "").strip()
  783. q = (
  784. db.query(
  785. BizProtocolPaymentTransfer,
  786. BizCollectionProtocol,
  787. BizProtocolPartyCustomer,
  788. BizProtocolPaymentSchedule,
  789. BizProtocolSamplePayment,
  790. )
  791. .join(
  792. BizCollectionProtocol,
  793. and_(
  794. BizCollectionProtocol.id
  795. == BizProtocolPaymentTransfer.collection_protocol_id,
  796. BizCollectionProtocol.del_flag == 0,
  797. ),
  798. )
  799. .outerjoin(
  800. BizProtocolPartyCustomer,
  801. and_(
  802. BizProtocolPartyCustomer.collection_protocol_id
  803. == BizProtocolPaymentTransfer.collection_protocol_id,
  804. BizProtocolPartyCustomer.tenant_no == tenant_no,
  805. BizProtocolPartyCustomer.del_flag == 0,
  806. ),
  807. )
  808. .outerjoin(
  809. BizProtocolPaymentSchedule,
  810. and_(
  811. BizProtocolPaymentSchedule.id == BizProtocolPaymentTransfer.schedule_id,
  812. BizProtocolPaymentSchedule.del_flag == 0,
  813. ),
  814. )
  815. .outerjoin(
  816. BizProtocolSamplePayment,
  817. and_(
  818. BizProtocolSamplePayment.id
  819. == BizProtocolPaymentTransfer.sample_payment_id,
  820. BizProtocolSamplePayment.del_flag == 0,
  821. ),
  822. )
  823. .filter(
  824. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  825. BizProtocolPaymentTransfer.del_flag == 0,
  826. BizProtocolPaymentTransfer.pay_status == st,
  827. )
  828. )
  829. kw = (keyword or "").strip()
  830. if kw:
  831. like = f"%{kw}%"
  832. q = q.filter(
  833. or_(
  834. BizCollectionProtocol.protocol_no.like(like),
  835. BizProtocolPaymentTransfer.sample_code.like(like),
  836. BizProtocolPartyCustomer.mother_name.like(like),
  837. BizProtocolPartyCustomer.mother_id_card.like(like),
  838. BizProtocolPartyCustomer.newborn_name.like(like),
  839. BizProtocolPartyCustomer.newborn_id_card.like(like),
  840. )
  841. )
  842. ds = (due_date_start or "").strip().replace("-", "")[:8]
  843. de = (due_date_end or "").strip().replace("-", "")[:8]
  844. if ds:
  845. q = q.filter(BizProtocolPaymentSchedule.due_date >= ds)
  846. if de:
  847. q = q.filter(BizProtocolPaymentSchedule.due_date <= de)
  848. if user_info:
  849. from app.core.data_scope import apply_data_scope
  850. q = apply_data_scope(
  851. q, BizProtocolPaymentTransfer, user_info, db, tenant_no
  852. )
  853. total = q.order_by(None).with_entities(func.count(BizProtocolPaymentTransfer.id)).scalar() or 0
  854. page = max(1, page)
  855. page_size = min(max(1, page_size), 100)
  856. rows = (
  857. q.order_by(BizProtocolPaymentTransfer.create_time.desc())
  858. .offset((page - 1) * page_size)
  859. .limit(page_size)
  860. .all()
  861. )
  862. out: List[Dict[str, Any]] = []
  863. for t, proto, party, sch, sp in rows:
  864. item = _transfer_to_dict(db, t, tenant_no)
  865. item["protocolNo"] = (proto.protocol_no if proto else "") or ""
  866. item["protocolStatus"] = (proto.protocol_status if proto else "") or ""
  867. item["voidType"] = (proto.void_type if proto else "") or ""
  868. item["motherName"] = (party.mother_name if party else "") or ""
  869. item["motherIdCard"] = (party.mother_id_card if party else "") or ""
  870. item["newbornName"] = (party.newborn_name if party else "") or ""
  871. item["newbornIdCard"] = (party.newborn_id_card if party else "") or ""
  872. item["installmentNo"] = sch.schedule_no if sch else None
  873. item["installmentDueDate"] = (sch.due_date if sch else "") or ""
  874. item["dueAmount"] = (
  875. float(sch.due_amount) if sch and sch.due_amount is not None else None
  876. )
  877. item["payMode"] = (sp.pay_mode if sp else "") or ""
  878. out.append(item)
  879. return out, int(total)
  880. def _filter_and_page_progress(
  881. rows: List[Dict[str, Any]],
  882. progress_status: Optional[str],
  883. page: int,
  884. page_size: int,
  885. ) -> Tuple[List[Dict[str, Any]], int]:
  886. """先按 progressStatus 过滤,再分页切片(禁止先 LIMIT 再筛进度)。"""
  887. filtered = rows
  888. ps = (progress_status or "").strip()
  889. if ps:
  890. filtered = [r for r in rows if (r.get("progressStatus") or "") == ps]
  891. total = len(filtered)
  892. page = max(1, page)
  893. page_size = min(max(1, page_size), 100)
  894. start = (page - 1) * page_size
  895. return filtered[start : start + page_size], total
  896. def _progress_to_json_fields(progress: Dict[str, Any]) -> Dict[str, Any]:
  897. paid = progress.get("paidAmount")
  898. due_remain = progress.get("dueRemainAmount")
  899. return {
  900. "progressStatus": progress.get("progressStatus") or "",
  901. "paidAmount": float(paid) if paid is not None else 0.0,
  902. "dueRemainAmount": float(due_remain) if due_remain is not None else 0.0,
  903. "clearedScheduleCount": progress.get("clearedScheduleCount") or 0,
  904. "activeScheduleCount": progress.get("activeScheduleCount") or 0,
  905. "canConfirm": bool(progress.get("canConfirm")),
  906. }
  907. def _load_payment_children_batch(
  908. db: Session,
  909. sample_payment_ids: List[str],
  910. tenant_no: str,
  911. ) -> Tuple[
  912. Dict[str, List[BizProtocolPaymentSchedule]],
  913. Dict[str, List[BizProtocolPaymentTransfer]],
  914. ]:
  915. schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
  916. transfers_by_payment: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  917. if not sample_payment_ids:
  918. return schedules_by_payment, transfers_by_payment
  919. schedules = (
  920. db.query(BizProtocolPaymentSchedule)
  921. .filter(
  922. BizProtocolPaymentSchedule.sample_payment_id.in_(sample_payment_ids),
  923. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  924. BizProtocolPaymentSchedule.del_flag == 0,
  925. )
  926. .order_by(
  927. BizProtocolPaymentSchedule.order_num.asc(),
  928. BizProtocolPaymentSchedule.schedule_no.asc(),
  929. )
  930. .all()
  931. )
  932. sids = [s.id for s in schedules]
  933. transfers: List[BizProtocolPaymentTransfer] = []
  934. if sids:
  935. transfers = (
  936. db.query(BizProtocolPaymentTransfer)
  937. .filter(
  938. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  939. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  940. BizProtocolPaymentTransfer.del_flag == 0,
  941. )
  942. .order_by(BizProtocolPaymentTransfer.create_time.asc())
  943. .all()
  944. )
  945. for s in schedules:
  946. schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
  947. schedule_to_payment = {s.id: s.sample_payment_id for s in schedules}
  948. for t in transfers:
  949. pid = schedule_to_payment.get(t.schedule_id) or t.sample_payment_id
  950. if pid:
  951. transfers_by_payment.setdefault(pid, []).append(t)
  952. return schedules_by_payment, transfers_by_payment
  953. def _compute_progress_for_payment(
  954. schedules: List[BizProtocolPaymentSchedule],
  955. transfers: List[BizProtocolPaymentTransfer],
  956. ) -> Dict[str, Any]:
  957. sch_dicts = [
  958. {
  959. "scheduleStatus": s.schedule_status or "",
  960. "dueAmount": s.due_amount,
  961. }
  962. for s in schedules
  963. ]
  964. tr_dicts = [
  965. {
  966. "payStatus": t.pay_status or "",
  967. "transferAmount": t.transfer_amount,
  968. }
  969. for t in transfers
  970. ]
  971. return compute_sample_payment_progress(schedules=sch_dicts, transfers=tr_dicts)
  972. def _build_list_summary_row(
  973. sp: BizProtocolSamplePayment,
  974. proto: BizCollectionProtocol,
  975. party: Optional[BizProtocolPartyCustomer],
  976. progress: Dict[str, Any],
  977. ) -> Dict[str, Any]:
  978. row: Dict[str, Any] = {
  979. "id": sp.id,
  980. "collectionProtocolId": sp.collection_protocol_id,
  981. "protocolNo": (proto.protocol_no if proto else "") or "",
  982. "protocolStatus": (proto.protocol_status if proto else "") or "",
  983. "voidType": (proto.void_type if proto else "") or "",
  984. "motherName": (party.mother_name if party else "") or "",
  985. "motherPhone": (party.mother_phone if party else "") or "",
  986. "sampleCode": sp.sample_code or "",
  987. "payMode": sp.pay_mode or "",
  988. "contractAmount": float(sp.contract_amount)
  989. if sp.contract_amount is not None
  990. else None,
  991. }
  992. row.update(_progress_to_json_fields(progress))
  993. return row
  994. def list_sample_payments_page(
  995. db: Session,
  996. *,
  997. tenant_no: str,
  998. keyword: Optional[str] = None,
  999. pay_mode: Optional[str] = None,
  1000. progress_status: Optional[str] = None,
  1001. due_date_start: Optional[str] = None,
  1002. due_date_end: Optional[str] = None,
  1003. page: int = 1,
  1004. page_size: int = 10,
  1005. user_info=None,
  1006. ) -> Tuple[List[Dict[str, Any]], int]:
  1007. if not tenant_no:
  1008. return [], 0
  1009. q = (
  1010. db.query(
  1011. BizProtocolSamplePayment,
  1012. BizCollectionProtocol,
  1013. BizProtocolPartyCustomer,
  1014. )
  1015. .join(
  1016. BizCollectionProtocol,
  1017. and_(
  1018. BizCollectionProtocol.id
  1019. == BizProtocolSamplePayment.collection_protocol_id,
  1020. BizCollectionProtocol.del_flag == 0,
  1021. ),
  1022. )
  1023. .outerjoin(
  1024. BizProtocolPartyCustomer,
  1025. and_(
  1026. BizProtocolPartyCustomer.collection_protocol_id
  1027. == BizProtocolSamplePayment.collection_protocol_id,
  1028. BizProtocolPartyCustomer.tenant_no == tenant_no,
  1029. BizProtocolPartyCustomer.del_flag == 0,
  1030. ),
  1031. )
  1032. .filter(
  1033. BizProtocolSamplePayment.tenant_no == tenant_no,
  1034. BizProtocolSamplePayment.del_flag == 0,
  1035. )
  1036. )
  1037. kw = (keyword or "").strip()
  1038. if kw:
  1039. like = f"%{kw}%"
  1040. q = q.filter(
  1041. or_(
  1042. BizCollectionProtocol.protocol_no.like(like),
  1043. BizProtocolSamplePayment.sample_code.like(like),
  1044. BizProtocolPartyCustomer.mother_name.like(like),
  1045. BizProtocolPartyCustomer.mother_phone.like(like),
  1046. )
  1047. )
  1048. mode = (pay_mode or "").strip()
  1049. if mode:
  1050. q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
  1051. ds = (due_date_start or "").strip().replace("-", "")[:8]
  1052. de = (due_date_end or "").strip().replace("-", "")[:8]
  1053. if ds or de:
  1054. due_conditions = [
  1055. BizProtocolPaymentSchedule.sample_payment_id
  1056. == BizProtocolSamplePayment.id,
  1057. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  1058. BizProtocolPaymentSchedule.del_flag == 0,
  1059. BizProtocolPaymentSchedule.schedule_status != "void",
  1060. ]
  1061. if ds:
  1062. due_conditions.append(BizProtocolPaymentSchedule.due_date >= ds)
  1063. if de:
  1064. due_conditions.append(BizProtocolPaymentSchedule.due_date <= de)
  1065. q = q.filter(exists().where(and_(*due_conditions)))
  1066. if user_info:
  1067. from app.core.data_scope import apply_data_scope
  1068. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  1069. candidates = q.order_by(BizProtocolSamplePayment.create_time.desc()).all()
  1070. if not candidates:
  1071. return [], 0
  1072. pids = [sp.id for sp, _, _ in candidates]
  1073. schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
  1074. db, pids, tenant_no
  1075. )
  1076. rows: List[Dict[str, Any]] = []
  1077. for sp, proto, party in candidates:
  1078. schedules = schedules_by_payment.get(sp.id, [])
  1079. transfers = transfers_by_payment.get(sp.id, [])
  1080. progress = _compute_progress_for_payment(schedules, transfers)
  1081. rows.append(_build_list_summary_row(sp, proto, party, progress))
  1082. return _filter_and_page_progress(rows, progress_status, page, page_size)
  1083. def get_sample_payment_detail(
  1084. db: Session,
  1085. *,
  1086. sample_payment_id: str,
  1087. tenant_no: str,
  1088. user_info=None,
  1089. ) -> Optional[Dict[str, Any]]:
  1090. q = (
  1091. db.query(
  1092. BizProtocolSamplePayment,
  1093. BizCollectionProtocol,
  1094. BizProtocolPartyCustomer,
  1095. )
  1096. .join(
  1097. BizCollectionProtocol,
  1098. and_(
  1099. BizCollectionProtocol.id
  1100. == BizProtocolSamplePayment.collection_protocol_id,
  1101. BizCollectionProtocol.del_flag == 0,
  1102. ),
  1103. )
  1104. .outerjoin(
  1105. BizProtocolPartyCustomer,
  1106. and_(
  1107. BizProtocolPartyCustomer.collection_protocol_id
  1108. == BizProtocolSamplePayment.collection_protocol_id,
  1109. BizProtocolPartyCustomer.tenant_no == tenant_no,
  1110. BizProtocolPartyCustomer.del_flag == 0,
  1111. ),
  1112. )
  1113. .filter(
  1114. BizProtocolSamplePayment.id == sample_payment_id,
  1115. BizProtocolSamplePayment.tenant_no == tenant_no,
  1116. BizProtocolSamplePayment.del_flag == 0,
  1117. )
  1118. )
  1119. if user_info:
  1120. from app.core.data_scope import apply_data_scope
  1121. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  1122. row = q.first()
  1123. if not row:
  1124. return None
  1125. sp, proto, party = row
  1126. schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
  1127. db, [sp.id], tenant_no
  1128. )
  1129. schedules = schedules_by_payment.get(sp.id, [])
  1130. transfers = transfers_by_payment.get(sp.id, [])
  1131. by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  1132. for t in transfers:
  1133. by_schedule.setdefault(t.schedule_id, []).append(t)
  1134. detail = _sample_payment_to_dict(db, sp, schedules, by_schedule, tenant_no)
  1135. detail["protocolNo"] = (proto.protocol_no if proto else "") or ""
  1136. detail["protocolStatus"] = (proto.protocol_status if proto else "") or ""
  1137. detail["voidType"] = (proto.void_type if proto else "") or ""
  1138. detail["motherName"] = (party.mother_name if party else "") or ""
  1139. detail["motherPhone"] = (party.mother_phone if party else "") or ""
  1140. progress = _compute_progress_for_payment(schedules, transfers)
  1141. detail.update(_progress_to_json_fields(progress))
  1142. return detail