protocol_sample_payment.py 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262
  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. BizCellSampleType,
  13. BizCollectionProtocol,
  14. BizPresignTransfer,
  15. BizProtocolCollectionInfo,
  16. BizProtocolPartyCustomer,
  17. BizProtocolPaymentSchedule,
  18. BizProtocolPaymentTransfer,
  19. BizProtocolSample,
  20. BizProtocolSamplePayment,
  21. SysUser,
  22. )
  23. from app.services.payment_chase import (
  24. chase_period_money,
  25. is_chase_candidate,
  26. parse_due_date_ymd,
  27. )
  28. from app.services.protocol_transfer_status import (
  29. can_confirm_receipt,
  30. can_edit_transfer,
  31. can_submit_receipt,
  32. can_withdraw_receipt,
  33. )
  34. from app.services.sample_payment_fgd_convert import plan_fq_to_fgd_merge
  35. from app.services.sample_payment_period_summary import summarize_period
  36. from app.services.sample_payment_schedule_validate import schedule_dues_match_contract
  37. from app.services.sample_payment_progress import (
  38. compute_sample_payment_progress,
  39. is_schedule_effectively_cleared,
  40. )
  41. def _fmt_ymd8(v: Any) -> str:
  42. s = str(v or "").strip()
  43. if len(s) == 8 and s.isdigit():
  44. return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
  45. return s
  46. def _build_periods_array(
  47. schedules: List[BizProtocolPaymentSchedule],
  48. transfers: List[BizProtocolPaymentTransfer],
  49. ) -> List[Optional[Dict[str, Any]]]:
  50. """固定长度 6;无对应 schedule_no 的槽位为 None。"""
  51. by_sch: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  52. for t in transfers:
  53. by_sch.setdefault(t.schedule_id or "", []).append(t)
  54. by_no: Dict[int, BizProtocolPaymentSchedule] = {}
  55. for s in schedules:
  56. if (s.schedule_status or "") == "void":
  57. continue
  58. no = int(s.schedule_no or 0)
  59. if 1 <= no <= 6:
  60. by_no[no] = s
  61. periods: List[Optional[Dict[str, Any]]] = []
  62. for no in range(1, 7):
  63. s = by_no.get(no)
  64. if not s:
  65. periods.append(None)
  66. continue
  67. trs = by_sch.get(s.id, [])
  68. summary = summarize_period(
  69. due_amount=s.due_amount,
  70. transfers=[
  71. {
  72. "transferAmount": t.transfer_amount,
  73. "transferType": t.transfer_type or "",
  74. "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
  75. if t.transfer_time
  76. else "",
  77. "payStatus": t.pay_status or "",
  78. }
  79. for t in trs
  80. ],
  81. )
  82. periods.append(
  83. {
  84. "scheduleId": s.id,
  85. "scheduleNo": no,
  86. "dueAmount": float(s.due_amount) if s.due_amount is not None else None,
  87. "dueDate": _fmt_ymd8(s.due_date),
  88. "displayPaidAmount": float(summary["displayPaidAmount"]),
  89. "arrearsAmount": float(summary["arrearsAmount"]),
  90. "payDate": summary.get("payDate") or "",
  91. "payTypeSummary": summary.get("payTypeSummary") or "",
  92. "financeAudit": summary.get("financeAudit") or "dfj",
  93. }
  94. )
  95. return periods
  96. def _dec(v: Any) -> Optional[Decimal]:
  97. if v is None or v == "":
  98. return None
  99. return Decimal(str(v))
  100. def _now() -> datetime:
  101. return datetime.now()
  102. def _get_sample_payment_or_raise(
  103. db: Session, *, sample_payment_id: str, tenant_no: str
  104. ) -> BizProtocolSamplePayment:
  105. obj = (
  106. db.query(BizProtocolSamplePayment)
  107. .filter(
  108. BizProtocolSamplePayment.id == sample_payment_id,
  109. BizProtocolSamplePayment.tenant_no == tenant_no,
  110. BizProtocolSamplePayment.del_flag == 0,
  111. )
  112. .first()
  113. )
  114. if not obj:
  115. raise ValueError("样本缴费方案不存在")
  116. return obj
  117. def _lock_sample_payment_graph(
  118. db: Session,
  119. *,
  120. sample_payment_id: str,
  121. tenant_no: str,
  122. ) -> Tuple[
  123. BizProtocolSamplePayment,
  124. List[BizProtocolPaymentSchedule],
  125. List[BizProtocolPaymentTransfer],
  126. ]:
  127. """按方案→批次→转账顺序加行锁,供普通方案保存做最终一致性校验。"""
  128. sp = (
  129. db.query(BizProtocolSamplePayment)
  130. .filter(
  131. BizProtocolSamplePayment.id == sample_payment_id,
  132. BizProtocolSamplePayment.tenant_no == tenant_no,
  133. BizProtocolSamplePayment.del_flag == 0,
  134. )
  135. .populate_existing()
  136. .with_for_update()
  137. .first()
  138. )
  139. if not sp:
  140. raise ValueError("样本缴费方案不存在")
  141. schedules = (
  142. db.query(BizProtocolPaymentSchedule)
  143. .filter(
  144. BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
  145. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  146. BizProtocolPaymentSchedule.del_flag == 0,
  147. )
  148. .order_by(BizProtocolPaymentSchedule.schedule_no.asc())
  149. .populate_existing()
  150. .with_for_update()
  151. .all()
  152. )
  153. schedule_ids = [row.id for row in schedules]
  154. transfers: List[BizProtocolPaymentTransfer] = []
  155. if schedule_ids:
  156. transfers = (
  157. db.query(BizProtocolPaymentTransfer)
  158. .filter(
  159. BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids),
  160. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  161. BizProtocolPaymentTransfer.del_flag == 0,
  162. )
  163. .order_by(BizProtocolPaymentTransfer.id.asc())
  164. .populate_existing()
  165. .with_for_update()
  166. .all()
  167. )
  168. return sp, schedules, transfers
  169. def _locked_schedule_ids(
  170. schedules: List[BizProtocolPaymentSchedule],
  171. transfers: List[BizProtocolPaymentTransfer],
  172. ) -> set:
  173. locked = {
  174. row.id
  175. for row in schedules
  176. if (row.schedule_status or "").strip() != "open"
  177. }
  178. locked.update(
  179. row.schedule_id
  180. for row in transfers
  181. if (row.pay_status or "").strip() != "dfj"
  182. )
  183. return locked
  184. def _assert_locked_schedule_structure(
  185. schedules: List[BizProtocolPaymentSchedule],
  186. transfers: List[BizProtocolPaymentTransfer],
  187. payload: List[Dict[str, Any]],
  188. ) -> None:
  189. locked_ids = _locked_schedule_ids(schedules, transfers)
  190. if not locked_ids:
  191. return
  192. current_nos = {int(row.schedule_no or 0) for row in schedules}
  193. submitted_no_list = [
  194. _schedule_no_of(raw, idx + 1)
  195. for idx, raw in enumerate(payload or [])
  196. if isinstance(raw, dict)
  197. ]
  198. if len(submitted_no_list) != len(set(submitted_no_list)):
  199. raise ValueError("应缴批次期号重复")
  200. submitted_nos = set(submitted_no_list)
  201. locked_nos = {
  202. int(row.schedule_no or 0) for row in schedules if row.id in locked_ids
  203. }
  204. if submitted_nos != current_nos or not locked_nos.issubset(submitted_nos):
  205. raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
  206. by_id = {row.id: int(row.schedule_no or 0) for row in schedules}
  207. for idx, raw in enumerate(payload or []):
  208. if not isinstance(raw, dict):
  209. continue
  210. schedule_id = str(raw.get("id") or raw.get("scheduleId") or "").strip()
  211. if schedule_id in locked_ids and _schedule_no_of(raw, idx + 1) != by_id[schedule_id]:
  212. raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
  213. def _get_schedule_or_raise(
  214. db: Session, *, schedule_id: str, tenant_no: str
  215. ) -> BizProtocolPaymentSchedule:
  216. obj = (
  217. db.query(BizProtocolPaymentSchedule)
  218. .filter(
  219. BizProtocolPaymentSchedule.id == schedule_id,
  220. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  221. BizProtocolPaymentSchedule.del_flag == 0,
  222. )
  223. .first()
  224. )
  225. if not obj:
  226. raise ValueError("应缴批次不存在")
  227. return obj
  228. def _get_transfer_or_raise(
  229. db: Session, *, transfer_id: str, tenant_no: str
  230. ) -> BizProtocolPaymentTransfer:
  231. obj = (
  232. db.query(BizProtocolPaymentTransfer)
  233. .filter(
  234. BizProtocolPaymentTransfer.id == transfer_id,
  235. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  236. BizProtocolPaymentTransfer.del_flag == 0,
  237. )
  238. .first()
  239. )
  240. if not obj:
  241. raise ValueError("转账笔不存在")
  242. return obj
  243. def _get_transfer_with_schedule_lock(
  244. db: Session,
  245. *,
  246. transfer_id: str,
  247. tenant_no: str,
  248. user_info: Optional[dict] = None,
  249. ) -> BizProtocolPaymentTransfer:
  250. """Authorize the complete parent chain, then lock schedule before transfer."""
  251. parent = (
  252. db.query(
  253. BizProtocolPaymentTransfer.schedule_id,
  254. BizProtocolPaymentTransfer.sample_payment_id,
  255. BizProtocolPaymentTransfer.collection_protocol_id,
  256. )
  257. .filter(
  258. BizProtocolPaymentTransfer.id == transfer_id,
  259. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  260. BizProtocolPaymentTransfer.del_flag == 0,
  261. )
  262. .first()
  263. )
  264. if not parent:
  265. if user_info is not None:
  266. raise PermissionError("无权操作该协议")
  267. raise ValueError("转账笔不存在")
  268. schedule_id = parent.schedule_id
  269. schedule_query = (
  270. db.query(BizProtocolPaymentSchedule)
  271. .join(
  272. BizProtocolSamplePayment,
  273. and_(
  274. BizProtocolSamplePayment.id == parent.sample_payment_id,
  275. BizProtocolSamplePayment.id
  276. == BizProtocolPaymentSchedule.sample_payment_id,
  277. BizProtocolSamplePayment.collection_protocol_id
  278. == parent.collection_protocol_id,
  279. BizProtocolSamplePayment.tenant_no == tenant_no,
  280. BizProtocolSamplePayment.del_flag == 0,
  281. ),
  282. )
  283. .join(
  284. BizCollectionProtocol,
  285. and_(
  286. BizCollectionProtocol.id == parent.collection_protocol_id,
  287. BizCollectionProtocol.id
  288. == BizProtocolPaymentSchedule.collection_protocol_id,
  289. BizCollectionProtocol.tenant_no == tenant_no,
  290. BizCollectionProtocol.del_flag == 0,
  291. ),
  292. )
  293. .filter(
  294. BizProtocolPaymentSchedule.id == schedule_id,
  295. BizProtocolPaymentSchedule.sample_payment_id
  296. == parent.sample_payment_id,
  297. BizProtocolPaymentSchedule.collection_protocol_id
  298. == parent.collection_protocol_id,
  299. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  300. BizProtocolPaymentSchedule.del_flag == 0,
  301. )
  302. )
  303. if user_info is not None:
  304. from app.core.data_scope import apply_data_scope
  305. schedule_query = apply_data_scope(
  306. schedule_query,
  307. BizCollectionProtocol,
  308. user_info,
  309. db,
  310. tenant_no,
  311. )
  312. schedule = schedule_query.populate_existing().with_for_update().first()
  313. if not schedule:
  314. if user_info is not None:
  315. raise PermissionError("无权操作该协议")
  316. raise ValueError("应缴批次不存在")
  317. transfer = (
  318. db.query(BizProtocolPaymentTransfer)
  319. .filter(
  320. BizProtocolPaymentTransfer.id == transfer_id,
  321. BizProtocolPaymentTransfer.schedule_id == schedule_id,
  322. BizProtocolPaymentTransfer.sample_payment_id
  323. == parent.sample_payment_id,
  324. BizProtocolPaymentTransfer.collection_protocol_id
  325. == parent.collection_protocol_id,
  326. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  327. BizProtocolPaymentTransfer.del_flag == 0,
  328. )
  329. .populate_existing()
  330. .with_for_update()
  331. .first()
  332. )
  333. if not transfer:
  334. raise ValueError("转账笔不存在")
  335. return transfer
  336. def has_blocking_transfer_for_sample_payment(
  337. db: Session, *, sample_payment_id: str, tenant_no: str
  338. ) -> bool:
  339. row = (
  340. db.query(BizProtocolPaymentTransfer.id)
  341. .filter(
  342. BizProtocolPaymentTransfer.sample_payment_id == sample_payment_id,
  343. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  344. BizProtocolPaymentTransfer.del_flag == 0,
  345. BizProtocolPaymentTransfer.pay_status == "jfqrz",
  346. )
  347. .first()
  348. )
  349. return row is not None
  350. def assert_sample_payment_structure_editable(
  351. db: Session, *, sample_payment_id: str, tenant_no: str
  352. ) -> None:
  353. """已废弃:付款确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
  354. return
  355. # 兼容旧调用名
  356. assert_sample_payment_editable = assert_sample_payment_structure_editable
  357. def find_active_sample_payment(
  358. db: Session,
  359. *,
  360. tenant_no: str,
  361. sample_payment_id: Optional[str] = None,
  362. collection_protocol_id: Optional[str] = None,
  363. sample_code: Optional[str] = None,
  364. ) -> Optional[BizProtocolSamplePayment]:
  365. if sample_payment_id:
  366. query = db.query(BizProtocolSamplePayment).filter(
  367. BizProtocolSamplePayment.id == sample_payment_id,
  368. BizProtocolSamplePayment.tenant_no == tenant_no,
  369. BizProtocolSamplePayment.del_flag == 0,
  370. )
  371. if collection_protocol_id:
  372. query = query.filter(
  373. BizProtocolSamplePayment.collection_protocol_id
  374. == collection_protocol_id
  375. )
  376. if sample_code:
  377. query = query.filter(
  378. BizProtocolSamplePayment.sample_code == sample_code.strip()
  379. )
  380. return query.first()
  381. code = (sample_code or "").strip()
  382. pid = (collection_protocol_id or "").strip()
  383. if not code or not pid:
  384. return None
  385. return (
  386. db.query(BizProtocolSamplePayment)
  387. .filter(
  388. BizProtocolSamplePayment.collection_protocol_id == pid,
  389. BizProtocolSamplePayment.tenant_no == tenant_no,
  390. BizProtocolSamplePayment.sample_code == code,
  391. BizProtocolSamplePayment.del_flag == 0,
  392. )
  393. .first()
  394. )
  395. def sample_payment_has_yjf(
  396. db: Session, *, sample_payment_id: str, tenant_no: str
  397. ) -> bool:
  398. """样本缴费方案下是否存在已财务确认(yjf)转账。"""
  399. q = (
  400. db.query(BizProtocolPaymentTransfer.id)
  401. .filter(
  402. BizProtocolPaymentTransfer.sample_payment_id == sample_payment_id,
  403. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  404. BizProtocolPaymentTransfer.del_flag == 0,
  405. BizProtocolPaymentTransfer.pay_status == "yjf",
  406. )
  407. .limit(1)
  408. .first()
  409. )
  410. return q is not None
  411. def apply_fq_to_fgd_merge(
  412. db: Session,
  413. *,
  414. sample_payment_id: str,
  415. tenant_no: str,
  416. user_id: Optional[str],
  417. target_pay_mode: str = "fgd",
  418. ) -> Dict[str, Any]:
  419. """将分期方案并期为趸交/分改趸:更新目标期应缴、软删后续期及其 dfj 转账。"""
  420. sp = _get_sample_payment_or_raise(
  421. db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
  422. )
  423. schedules = (
  424. db.query(BizProtocolPaymentSchedule)
  425. .filter(
  426. BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
  427. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  428. BizProtocolPaymentSchedule.del_flag == 0,
  429. )
  430. .order_by(BizProtocolPaymentSchedule.schedule_no.asc())
  431. .all()
  432. )
  433. sch_ids = [s.id for s in schedules]
  434. transfers = []
  435. if sch_ids:
  436. transfers = (
  437. db.query(BizProtocolPaymentTransfer)
  438. .filter(
  439. BizProtocolPaymentTransfer.schedule_id.in_(sch_ids),
  440. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  441. BizProtocolPaymentTransfer.del_flag == 0,
  442. )
  443. .all()
  444. )
  445. sch_dicts = [
  446. {
  447. "id": s.id,
  448. "scheduleNo": s.schedule_no,
  449. "dueAmount": s.due_amount,
  450. "scheduleStatus": s.schedule_status or "open",
  451. }
  452. for s in schedules
  453. ]
  454. tr_dicts = [
  455. {
  456. "scheduleId": t.schedule_id,
  457. "payStatus": t.pay_status or "",
  458. "transferAmount": t.transfer_amount,
  459. }
  460. for t in transfers
  461. ]
  462. plan = plan_fq_to_fgd_merge(schedules=sch_dicts, transfers=tr_dicts)
  463. now = _now()
  464. target = next((s for s in schedules if s.id == plan["targetScheduleId"]), None)
  465. if target is None:
  466. raise ValueError("并期目标期次不存在")
  467. target.due_amount = plan["newDueAmount"]
  468. target.update_by = user_id
  469. target.update_time = now
  470. db.add(target)
  471. soft_ids = set(plan["softDeleteScheduleIds"] or [])
  472. for s in schedules:
  473. if s.id not in soft_ids:
  474. continue
  475. s.del_flag = 1
  476. s.update_by = user_id
  477. s.update_time = now
  478. db.add(s)
  479. if soft_ids:
  480. for t in transfers:
  481. if t.schedule_id in soft_ids and (t.pay_status or "") == "dfj":
  482. t.del_flag = 1
  483. t.update_by = user_id
  484. t.update_time = now
  485. db.add(t)
  486. mode = (target_pay_mode or "fgd").strip()
  487. if mode not in ("dj", "fgd"):
  488. mode = "fgd"
  489. sp.pay_mode = mode
  490. sp.installment_count = plan["newInstallmentCount"]
  491. sp.update_by = user_id
  492. sp.update_time = now
  493. db.add(sp)
  494. db.flush()
  495. return plan
  496. def upsert_sample_payment(
  497. db: Session,
  498. *,
  499. collection_protocol_id: str,
  500. tenant_no: str,
  501. user_id: Optional[str],
  502. sample_code: str,
  503. pay_mode: str,
  504. contract_amount: Any = None,
  505. protocol_sample_id: Optional[str] = None,
  506. installment_count: Optional[int] = None,
  507. installment_interval_months: Any = None,
  508. installment_start_date: Optional[str] = None,
  509. installment_advance_days: Optional[int] = None,
  510. installment_ratios: Optional[str] = None,
  511. lump_sum_deadline: Optional[str] = None,
  512. remark: Optional[str] = None,
  513. sample_payment_id: Optional[str] = None,
  514. ) -> BizProtocolSamplePayment:
  515. code = (sample_code or "").strip()
  516. if not code:
  517. raise ValueError("样本编码不能为空")
  518. mode = (pay_mode or "").strip()
  519. if mode not in ("dj", "fq", "djfq", "fgd"):
  520. raise ValueError("缴费模式无效")
  521. obj: Optional[BizProtocolSamplePayment] = None
  522. if sample_payment_id:
  523. obj = find_active_sample_payment(
  524. db,
  525. sample_payment_id=sample_payment_id,
  526. collection_protocol_id=collection_protocol_id,
  527. sample_code=code,
  528. tenant_no=tenant_no,
  529. )
  530. if obj is None:
  531. raise ValueError("样本缴费方案不存在或不属于当前协议样本")
  532. else:
  533. obj = (
  534. db.query(BizProtocolSamplePayment)
  535. .filter(
  536. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  537. BizProtocolSamplePayment.tenant_no == tenant_no,
  538. BizProtocolSamplePayment.sample_code == code,
  539. BizProtocolSamplePayment.del_flag == 0,
  540. )
  541. .first()
  542. )
  543. now = _now()
  544. if obj:
  545. obj.protocol_sample_id = (protocol_sample_id or "").strip() or None
  546. obj.sample_code = code
  547. obj.pay_mode = mode
  548. obj.contract_amount = _dec(contract_amount)
  549. obj.installment_count = installment_count
  550. obj.installment_interval_months = _dec(installment_interval_months)
  551. obj.installment_start_date = (installment_start_date or "").strip() or None
  552. obj.installment_advance_days = installment_advance_days
  553. obj.installment_ratios = (installment_ratios or "").strip() or None
  554. obj.lump_sum_deadline = (lump_sum_deadline or "").strip() or None
  555. if remark is not None:
  556. obj.remark = (remark or "").strip() or None
  557. obj.update_by = user_id
  558. obj.update_time = now
  559. db.add(obj)
  560. db.flush()
  561. return obj
  562. obj = BizProtocolSamplePayment(
  563. id=generate_uuid(),
  564. collection_protocol_id=collection_protocol_id,
  565. protocol_sample_id=(protocol_sample_id or "").strip() or None,
  566. sample_code=code,
  567. pay_mode=mode,
  568. contract_amount=_dec(contract_amount),
  569. installment_count=installment_count,
  570. installment_interval_months=_dec(installment_interval_months),
  571. installment_start_date=(installment_start_date or "").strip() or None,
  572. installment_advance_days=installment_advance_days,
  573. installment_ratios=(installment_ratios or "").strip() or None,
  574. lump_sum_deadline=(lump_sum_deadline or "").strip() or None,
  575. tenant_no=tenant_no,
  576. order_num=0,
  577. remark=(remark or "").strip() or None,
  578. create_by=user_id,
  579. create_dept_id="",
  580. create_time=now,
  581. update_by=user_id,
  582. update_time=now,
  583. del_flag=0,
  584. )
  585. db.add(obj)
  586. db.flush()
  587. return obj
  588. def _schedule_no_of(raw: Dict[str, Any], fallback: int) -> int:
  589. v = raw.get("schedule_no")
  590. if v is None:
  591. v = raw.get("scheduleNo")
  592. if v is None:
  593. return fallback
  594. try:
  595. return int(v)
  596. except (TypeError, ValueError):
  597. return fallback
  598. def _apply_transfer_editable_fields(
  599. t: BizProtocolPaymentTransfer, raw: Dict[str, Any]
  600. ) -> None:
  601. """更新转账可编辑字段;不改 pay_status。"""
  602. if "transfer_type" in raw or "transferType" in raw:
  603. v = raw.get("transfer_type", raw.get("transferType"))
  604. t.transfer_type = (str(v) if v is not None else "").strip() or None
  605. if "transfer_amount" in raw or "transferAmount" in raw:
  606. amt = _dec(raw.get("transfer_amount", raw.get("transferAmount")))
  607. if amt is not None and amt < 0:
  608. raise ValueError("转账金额无效")
  609. t.transfer_amount = amt
  610. if "transfer_time" in raw or "transferTime" in raw:
  611. v = raw.get("transfer_time", raw.get("transferTime"))
  612. if v in (None, ""):
  613. t.transfer_time = None
  614. elif isinstance(v, datetime):
  615. t.transfer_time = v
  616. else:
  617. t.transfer_time = parse_datetime(str(v))
  618. if "transfer_attachments" in raw or "transferAttachments" in raw:
  619. v = raw.get("transfer_attachments", raw.get("transferAttachments"))
  620. t.transfer_attachments = (str(v) if v is not None else "").strip() or None
  621. if "remark" in raw:
  622. t.remark = (raw.get("remark") or "").strip() or None
  623. def _sync_transfers_for_schedule(
  624. db: Session,
  625. *,
  626. sch: BizProtocolPaymentSchedule,
  627. tenant_no: str,
  628. user_id: Optional[str],
  629. transfers: List[Dict[str, Any]],
  630. ) -> None:
  631. now = _now()
  632. existing = (
  633. db.query(BizProtocolPaymentTransfer)
  634. .filter(
  635. BizProtocolPaymentTransfer.schedule_id == sch.id,
  636. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  637. BizProtocolPaymentTransfer.del_flag == 0,
  638. )
  639. .order_by(BizProtocolPaymentTransfer.id.asc())
  640. .populate_existing()
  641. .with_for_update()
  642. .all()
  643. )
  644. by_id = {t.id: t for t in existing}
  645. keep_ids: set = set()
  646. for raw in transfers or []:
  647. if not isinstance(raw, dict):
  648. continue
  649. tid = (raw.get("id") or "").strip()
  650. if tid and tid in by_id:
  651. t = by_id[tid]
  652. keep_ids.add(tid)
  653. if (t.pay_status or "").strip() != "dfj":
  654. # 非草稿转账由专用状态流转接口维护,普通方案保存整行只读。
  655. continue
  656. _apply_transfer_editable_fields(t, raw)
  657. t.update_by = user_id
  658. t.update_time = now
  659. db.add(t)
  660. continue
  661. if tid:
  662. raise ValueError("转账笔不存在或不属于当前批次")
  663. # 新增转账笔(始终以待缴费入库;提交财务走独立接口)
  664. add_transfer(
  665. db,
  666. schedule_id=sch.id,
  667. tenant_no=tenant_no,
  668. user_id=user_id,
  669. transfer_amount=raw.get("transfer_amount", raw.get("transferAmount")),
  670. transfer_time=raw.get("transfer_time", raw.get("transferTime")),
  671. transfer_type=raw.get("transfer_type", raw.get("transferType")),
  672. transfer_attachments=raw.get(
  673. "transfer_attachments", raw.get("transferAttachments")
  674. ),
  675. remark=raw.get("remark"),
  676. commit=False,
  677. )
  678. for tid, t in by_id.items():
  679. if tid in keep_ids:
  680. continue
  681. if (t.pay_status or "").strip() != "dfj":
  682. continue
  683. t.del_flag = 1
  684. t.update_by = user_id
  685. t.update_time = now
  686. db.add(t)
  687. def replace_schedules(
  688. db: Session,
  689. *,
  690. sample_payment_id: str,
  691. tenant_no: str,
  692. user_id: Optional[str],
  693. schedules: List[Dict[str, Any]],
  694. ) -> List[BizProtocolPaymentSchedule]:
  695. """仅在全部批次为 open、全部转账为 dfj 时重建批次。"""
  696. sp, old_schedules, transfers = _lock_sample_payment_graph(
  697. db,
  698. sample_payment_id=sample_payment_id,
  699. tenant_no=tenant_no,
  700. )
  701. if _locked_schedule_ids(old_schedules, transfers):
  702. raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
  703. now = _now()
  704. if old_schedules:
  705. for t in transfers:
  706. t.del_flag = 1
  707. t.update_by = user_id
  708. t.update_time = now
  709. db.add(t)
  710. for s in old_schedules:
  711. s.del_flag = 1
  712. s.update_by = user_id
  713. s.update_time = now
  714. db.add(s)
  715. created: List[BizProtocolPaymentSchedule] = []
  716. for idx, raw in enumerate(schedules or []):
  717. due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
  718. schedule_no = _schedule_no_of(raw, idx + 1)
  719. obj = BizProtocolPaymentSchedule(
  720. id=generate_uuid(),
  721. sample_payment_id=sp.id,
  722. collection_protocol_id=sp.collection_protocol_id,
  723. sample_code=sp.sample_code,
  724. schedule_no=schedule_no,
  725. due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
  726. due_date=due_date,
  727. schedule_status="open",
  728. tenant_no=tenant_no,
  729. order_num=idx,
  730. remark=(raw.get("remark") or "").strip() or None,
  731. create_by=user_id,
  732. create_dept_id="",
  733. create_time=now,
  734. update_by=user_id,
  735. update_time=now,
  736. del_flag=0,
  737. )
  738. db.add(obj)
  739. created.append(obj)
  740. db.flush()
  741. return created
  742. def sync_schedules_and_transfers(
  743. db: Session,
  744. *,
  745. sample_payment_id: str,
  746. tenant_no: str,
  747. user_id: Optional[str],
  748. schedules: List[Dict[str, Any]],
  749. ) -> List[BizProtocolPaymentSchedule]:
  750. """
  751. 同步应缴批次与转账笔:
  752. - 有非 open 批次或非 dfj 转账:结构固定,状态与非草稿转账只读保留
  753. - 全部为 open + dfj:允许重建批次,旧草稿转账随旧批次软删
  754. """
  755. _, old_schedules, locked_transfers = _lock_sample_payment_graph(
  756. db,
  757. sample_payment_id=sample_payment_id,
  758. tenant_no=tenant_no,
  759. )
  760. now = _now()
  761. by_no = {int(s.schedule_no or 0): s for s in old_schedules}
  762. payload = list(schedules or [])
  763. _assert_locked_schedule_structure(old_schedules, locked_transfers, payload)
  764. new_nos: List[int] = []
  765. for idx, raw in enumerate(payload):
  766. if not isinstance(raw, dict):
  767. continue
  768. new_nos.append(_schedule_no_of(raw, idx + 1))
  769. old_nos = set(by_no.keys())
  770. new_nos_set = set(new_nos)
  771. structure_changed = old_nos != new_nos_set
  772. if structure_changed:
  773. created = replace_schedules(
  774. db,
  775. sample_payment_id=sample_payment_id,
  776. tenant_no=tenant_no,
  777. user_id=user_id,
  778. schedules=payload,
  779. )
  780. for i, sch_raw in enumerate(payload):
  781. if i >= len(created) or not isinstance(sch_raw, dict):
  782. continue
  783. _sync_transfers_for_schedule(
  784. db,
  785. sch=created[i],
  786. tenant_no=tenant_no,
  787. user_id=user_id,
  788. transfers=sch_raw.get("transfers") or [],
  789. )
  790. return created
  791. result: List[BizProtocolPaymentSchedule] = []
  792. for idx, raw in enumerate(payload):
  793. if not isinstance(raw, dict):
  794. continue
  795. sno = _schedule_no_of(raw, idx + 1)
  796. sch = by_no.get(sno)
  797. if not sch:
  798. raise ValueError("应缴批次数据不一致,请刷新后重试")
  799. due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
  800. sch.due_amount = _dec(
  801. raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
  802. )
  803. sch.due_date = due_date
  804. sch.order_num = idx
  805. if "remark" in raw:
  806. sch.remark = (raw.get("remark") or "").strip() or None
  807. sch.update_by = user_id
  808. sch.update_time = now
  809. db.add(sch)
  810. _sync_transfers_for_schedule(
  811. db,
  812. sch=sch,
  813. tenant_no=tenant_no,
  814. user_id=user_id,
  815. transfers=raw.get("transfers") or [],
  816. )
  817. result.append(sch)
  818. db.flush()
  819. return result
  820. def add_transfer(
  821. db: Session,
  822. *,
  823. schedule_id: str,
  824. tenant_no: str,
  825. user_id: Optional[str],
  826. transfer_amount: Any = None,
  827. transfer_time: Any = None,
  828. transfer_type: Optional[str] = None,
  829. transfer_attachments: Optional[str] = None,
  830. remark: Optional[str] = None,
  831. create_dept_id: str = "",
  832. commit: bool = True,
  833. ) -> BizProtocolPaymentTransfer:
  834. sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
  835. now = _now()
  836. tt = None
  837. if transfer_time not in (None, ""):
  838. tt = (
  839. transfer_time
  840. if isinstance(transfer_time, datetime)
  841. else parse_datetime(str(transfer_time))
  842. )
  843. obj = BizProtocolPaymentTransfer(
  844. id=generate_uuid(),
  845. schedule_id=sch.id,
  846. sample_payment_id=sch.sample_payment_id,
  847. collection_protocol_id=sch.collection_protocol_id,
  848. sample_code=sch.sample_code,
  849. transfer_amount=_dec(transfer_amount),
  850. transfer_time=tt,
  851. transfer_type=(transfer_type or "").strip() or None,
  852. transfer_attachments=(transfer_attachments or "").strip() or None,
  853. pay_status="dfj",
  854. tenant_no=tenant_no,
  855. order_num=0,
  856. remark=(remark or "").strip() or None,
  857. create_by=user_id,
  858. create_dept_id=(create_dept_id or "").strip(),
  859. create_time=now,
  860. update_by=user_id,
  861. update_time=now,
  862. del_flag=0,
  863. )
  864. db.add(obj)
  865. if commit:
  866. db.commit()
  867. db.refresh(obj)
  868. else:
  869. db.flush()
  870. return obj
  871. def update_transfer_draft(
  872. db: Session,
  873. *,
  874. transfer_id: str,
  875. tenant_no: str,
  876. user_id: Optional[str],
  877. fields: Dict[str, Any],
  878. locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
  879. ) -> BizProtocolPaymentTransfer:
  880. t = locked_transfer or _get_transfer_or_raise(
  881. db, transfer_id=transfer_id, tenant_no=tenant_no
  882. )
  883. if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
  884. raise ValueError("转账笔不存在")
  885. if not can_edit_transfer(t.pay_status):
  886. raise ValueError("财务已确认收款的转账笔不可编辑")
  887. _apply_transfer_editable_fields(t, fields)
  888. t.update_by = user_id
  889. t.update_time = _now()
  890. db.add(t)
  891. db.commit()
  892. db.refresh(t)
  893. return t
  894. def _validate_transfer_submit_ready(t: BizProtocolPaymentTransfer) -> None:
  895. if not (t.transfer_type or "").strip():
  896. raise ValueError("请选择转账方式")
  897. if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
  898. raise ValueError("请填写转账金额")
  899. def submit_receipt_finance(
  900. db: Session,
  901. *,
  902. transfer_id: str,
  903. tenant_no: str,
  904. user_id: Optional[str],
  905. draft_fields: Optional[Dict[str, Any]] = None,
  906. locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
  907. ) -> BizProtocolPaymentTransfer:
  908. t = locked_transfer or _get_transfer_or_raise(
  909. db, transfer_id=transfer_id, tenant_no=tenant_no
  910. )
  911. if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
  912. raise ValueError("转账笔不存在")
  913. if draft_fields:
  914. if t.pay_status != "dfj":
  915. raise ValueError("仅已付款状态可编辑转账笔")
  916. # 就地改草稿字段(不单独 commit)
  917. if "transfer_type" in draft_fields or "transferType" in draft_fields:
  918. v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
  919. t.transfer_type = (str(v) if v is not None else "").strip() or None
  920. if "transfer_amount" in draft_fields or "transferAmount" in draft_fields:
  921. t.transfer_amount = _dec(
  922. draft_fields.get("transfer_amount", draft_fields.get("transferAmount"))
  923. )
  924. if "transfer_time" in draft_fields or "transferTime" in draft_fields:
  925. v = draft_fields.get("transfer_time", draft_fields.get("transferTime"))
  926. if v in (None, ""):
  927. t.transfer_time = None
  928. elif isinstance(v, datetime):
  929. t.transfer_time = v
  930. else:
  931. t.transfer_time = parse_datetime(str(v))
  932. if "transfer_attachments" in draft_fields or "transferAttachments" in draft_fields:
  933. v = draft_fields.get(
  934. "transfer_attachments", draft_fields.get("transferAttachments")
  935. )
  936. t.transfer_attachments = (str(v) if v is not None else "").strip() or None
  937. if not can_submit_receipt(t.pay_status):
  938. raise ValueError("仅已付款状态可提交财务确认")
  939. _validate_transfer_submit_ready(t)
  940. t.pay_status = "jfqrz"
  941. t.update_by = user_id
  942. t.update_time = _now()
  943. db.add(t)
  944. db.commit()
  945. db.refresh(t)
  946. return t
  947. def confirm_receipt(
  948. db: Session,
  949. *,
  950. transfer_id: str,
  951. tenant_no: str,
  952. user_id: Optional[str],
  953. finance_confirm_attachments: Optional[str] = None,
  954. finance_remark: Optional[str] = None,
  955. locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
  956. ) -> BizProtocolPaymentTransfer:
  957. t = locked_transfer or _get_transfer_with_schedule_lock(
  958. db, transfer_id=transfer_id, tenant_no=tenant_no
  959. )
  960. if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
  961. raise ValueError("转账笔不存在")
  962. if t.pay_status == "yjf":
  963. raise ValueError("该转账笔已确认收款")
  964. if not can_confirm_receipt(t.pay_status):
  965. raise ValueError("当前状态不可确认收款")
  966. t.pay_status = "yjf"
  967. if finance_confirm_attachments is not None:
  968. t.finance_confirm_attachments = finance_confirm_attachments.strip() or None
  969. if finance_remark is not None:
  970. t.finance_remark = finance_remark.strip() or None
  971. t.update_by = user_id
  972. t.update_time = _now()
  973. db.add(t)
  974. # 批次已在 _get_transfer_with_schedule_lock 中加锁,此处不再二次 for_update
  975. schedule = (
  976. db.query(BizProtocolPaymentSchedule)
  977. .filter(
  978. BizProtocolPaymentSchedule.id == t.schedule_id,
  979. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  980. BizProtocolPaymentSchedule.del_flag == 0,
  981. )
  982. .first()
  983. )
  984. _sync_schedule_cleared_after_confirm(
  985. db,
  986. schedule=schedule,
  987. tenant_no=tenant_no,
  988. user_id=user_id,
  989. )
  990. db.commit()
  991. db.refresh(t)
  992. return t
  993. def _sync_schedule_cleared_after_confirm(
  994. db: Session,
  995. *,
  996. schedule: Optional[BizProtocolPaymentSchedule],
  997. tenant_no: str,
  998. user_id: Optional[str],
  999. ) -> None:
  1000. """财务确认后:实缴覆盖应缴则回写 schedule_status=cleared(调用方须已持有批次锁)。"""
  1001. if not schedule:
  1002. return
  1003. status = (schedule.schedule_status or "").strip()
  1004. if status in ("cleared", "void"):
  1005. return
  1006. confirmed = (
  1007. db.query(func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0))
  1008. .filter(
  1009. BizProtocolPaymentTransfer.schedule_id == schedule.id,
  1010. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  1011. BizProtocolPaymentTransfer.del_flag == 0,
  1012. BizProtocolPaymentTransfer.pay_status == "yjf",
  1013. )
  1014. .scalar()
  1015. or Decimal("0")
  1016. )
  1017. due = Decimal(str(schedule.due_amount or 0))
  1018. if Decimal(str(confirmed)) < due:
  1019. return
  1020. schedule.schedule_status = "cleared"
  1021. schedule.update_by = user_id
  1022. schedule.update_time = _now()
  1023. db.add(schedule)
  1024. def reject_receipt(
  1025. db: Session,
  1026. *,
  1027. transfer_id: str,
  1028. tenant_no: str,
  1029. user_id: Optional[str],
  1030. locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
  1031. ) -> BizProtocolPaymentTransfer:
  1032. t = locked_transfer or _get_transfer_with_schedule_lock(
  1033. db, transfer_id=transfer_id, tenant_no=tenant_no
  1034. )
  1035. if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
  1036. raise ValueError("转账笔不存在")
  1037. if t.pay_status != "jfqrz":
  1038. raise ValueError("当前状态不可退回")
  1039. t.pay_status = "dfj"
  1040. t.update_by = user_id
  1041. t.update_time = _now()
  1042. db.add(t)
  1043. db.commit()
  1044. db.refresh(t)
  1045. return t
  1046. def withdraw_receipt(
  1047. db: Session,
  1048. *,
  1049. transfer_id: str,
  1050. tenant_no: str,
  1051. user_id: Optional[str],
  1052. locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
  1053. ) -> BizProtocolPaymentTransfer:
  1054. """业务撤回:jfqrz → dfj,保留 finance_remark / finance_confirm_attachments。"""
  1055. t = locked_transfer or _get_transfer_with_schedule_lock(
  1056. db, transfer_id=transfer_id, tenant_no=tenant_no
  1057. )
  1058. if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
  1059. raise ValueError("转账笔不存在")
  1060. if not can_withdraw_receipt(t.pay_status):
  1061. raise ValueError("仅付款确认中可撤回")
  1062. t.pay_status = "dfj"
  1063. t.update_by = user_id
  1064. t.update_time = _now()
  1065. db.add(t)
  1066. db.commit()
  1067. db.refresh(t)
  1068. return t
  1069. def soft_delete_payment_transfer(
  1070. db: Session,
  1071. *,
  1072. transfer_id: str,
  1073. tenant_no: str,
  1074. user_id: Optional[str],
  1075. ) -> None:
  1076. t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
  1077. if t.pay_status != "dfj":
  1078. raise ValueError("仅已付款状态转账笔可删除")
  1079. t.del_flag = 1
  1080. t.update_by = user_id
  1081. t.update_time = _now()
  1082. db.add(t)
  1083. db.commit()
  1084. def _money_value(value: Any, as_string: bool = False) -> Any:
  1085. if value is None:
  1086. return None
  1087. amount = Decimal(str(value)).quantize(Decimal('0.01'))
  1088. return format(amount, 'f') if as_string else float(amount)
  1089. def _transfer_to_dict(
  1090. db: Session,
  1091. t: BizProtocolPaymentTransfer,
  1092. tenant_no: str,
  1093. money_as_string: bool = False,
  1094. ) -> Dict[str, Any]:
  1095. return {
  1096. "id": t.id,
  1097. "scheduleId": t.schedule_id,
  1098. "samplePaymentId": t.sample_payment_id,
  1099. "collectionProtocolId": t.collection_protocol_id,
  1100. "sampleCode": t.sample_code or "",
  1101. "transferAmount": _money_value(t.transfer_amount, money_as_string),
  1102. "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if t.transfer_time else "",
  1103. "transferType": t.transfer_type or "",
  1104. "transferAttachments": t.transfer_attachments or "",
  1105. "attachments": urls_to_attachment_list(t.transfer_attachments, db, tenant_no),
  1106. "payStatus": t.pay_status or "",
  1107. "financeConfirmAttachments": t.finance_confirm_attachments or "",
  1108. "financeConfirmAttachmentFiles": urls_to_attachment_list(
  1109. t.finance_confirm_attachments, db, tenant_no
  1110. ),
  1111. "financeRemark": t.finance_remark or "",
  1112. "remark": t.remark or "",
  1113. "source": "formal",
  1114. "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
  1115. "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
  1116. }
  1117. def _schedule_to_dict(
  1118. db: Session,
  1119. s: BizProtocolPaymentSchedule,
  1120. transfers: List[BizProtocolPaymentTransfer],
  1121. tenant_no: str,
  1122. money_as_string: bool = False,
  1123. ) -> Dict[str, Any]:
  1124. # 展示口径:已缴仅计 yjf(与 summarize_period / chase_period_money 一致)
  1125. paid, arrears = chase_period_money(
  1126. due_amount=s.due_amount,
  1127. transfers=[
  1128. {
  1129. "transferAmount": t.transfer_amount,
  1130. "transferType": t.transfer_type or "",
  1131. "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
  1132. if t.transfer_time
  1133. else "",
  1134. "payStatus": t.pay_status or "",
  1135. }
  1136. for t in transfers
  1137. ],
  1138. )
  1139. schedule_status = s.schedule_status or ""
  1140. if schedule_status != "void" and is_schedule_effectively_cleared(
  1141. {"scheduleStatus": schedule_status, "dueAmount": s.due_amount},
  1142. confirmed_paid=Decimal(str(paid or 0)),
  1143. ):
  1144. schedule_status = "cleared"
  1145. return {
  1146. "id": s.id,
  1147. "samplePaymentId": s.sample_payment_id,
  1148. "collectionProtocolId": s.collection_protocol_id,
  1149. "sampleCode": s.sample_code or "",
  1150. "scheduleNo": s.schedule_no,
  1151. "dueAmount": _money_value(s.due_amount, money_as_string),
  1152. "dueDate": s.due_date or "",
  1153. "scheduleStatus": schedule_status,
  1154. "paidAmount": _money_value(paid, money_as_string),
  1155. "arrearsAmount": _money_value(arrears, money_as_string),
  1156. "remark": s.remark or "",
  1157. "transfers": [
  1158. _transfer_to_dict(db, t, tenant_no, money_as_string) for t in transfers
  1159. ],
  1160. }
  1161. def _sample_payment_to_dict(
  1162. db: Session,
  1163. p: BizProtocolSamplePayment,
  1164. schedules: List[BizProtocolPaymentSchedule],
  1165. transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
  1166. tenant_no: str,
  1167. money_as_string: bool = False,
  1168. ) -> Dict[str, Any]:
  1169. return {
  1170. "id": p.id,
  1171. "collectionProtocolId": p.collection_protocol_id,
  1172. "protocolSampleId": p.protocol_sample_id or "",
  1173. "sampleCode": p.sample_code or "",
  1174. "payMode": p.pay_mode or "",
  1175. "contractAmount": _money_value(p.contract_amount, money_as_string),
  1176. "installmentCount": p.installment_count,
  1177. "installmentIntervalMonths": float(p.installment_interval_months)
  1178. if p.installment_interval_months is not None
  1179. else None,
  1180. "installmentStartDate": p.installment_start_date or "",
  1181. "installmentAdvanceDays": p.installment_advance_days,
  1182. "installmentRatios": p.installment_ratios or "",
  1183. "lumpSumDeadline": p.lump_sum_deadline or "",
  1184. "remark": p.remark or "",
  1185. "schedules": [
  1186. _schedule_to_dict(
  1187. db, s, transfers_by_schedule.get(s.id, []), tenant_no, money_as_string
  1188. )
  1189. for s in schedules
  1190. ],
  1191. }
  1192. def _normalize_installment_ratios(v: Any) -> Optional[str]:
  1193. if v is None or v == "":
  1194. return None
  1195. if isinstance(v, (list, dict)):
  1196. def normalize_decimal(value: Any) -> Any:
  1197. if isinstance(value, Decimal):
  1198. return format(value.quantize(Decimal("0.01")), "f")
  1199. if isinstance(value, list):
  1200. return [normalize_decimal(item) for item in value]
  1201. if isinstance(value, dict):
  1202. return {
  1203. key: normalize_decimal(item)
  1204. for key, item in value.items()
  1205. }
  1206. return value
  1207. return json.dumps(normalize_decimal(v), ensure_ascii=False)
  1208. return str(v).strip() or None
  1209. def _soft_delete_sample_payment(
  1210. db: Session,
  1211. *,
  1212. sp: BizProtocolSamplePayment,
  1213. tenant_no: str,
  1214. user_id: Optional[str],
  1215. ) -> None:
  1216. now = _now()
  1217. schedules = (
  1218. db.query(BizProtocolPaymentSchedule)
  1219. .filter(
  1220. BizProtocolPaymentSchedule.sample_payment_id == sp.id,
  1221. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  1222. BizProtocolPaymentSchedule.del_flag == 0,
  1223. )
  1224. .all()
  1225. )
  1226. sids = [s.id for s in schedules]
  1227. if sids:
  1228. transfers = (
  1229. db.query(BizProtocolPaymentTransfer)
  1230. .filter(
  1231. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  1232. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  1233. BizProtocolPaymentTransfer.del_flag == 0,
  1234. )
  1235. .all()
  1236. )
  1237. for t in transfers:
  1238. # 方案删除时一并软删待缴费/确认中;已确认笔保留挂接供追溯
  1239. if t.pay_status in ("dfj", "jfqrz"):
  1240. t.del_flag = 1
  1241. t.update_by = user_id
  1242. t.update_time = now
  1243. db.add(t)
  1244. for s in schedules:
  1245. s.del_flag = 1
  1246. s.update_by = user_id
  1247. s.update_time = now
  1248. db.add(s)
  1249. sp.del_flag = 1
  1250. sp.update_by = user_id
  1251. sp.update_time = now
  1252. db.add(sp)
  1253. def save_sample_payments_for_protocol(
  1254. db: Session,
  1255. *,
  1256. collection_protocol_id: str,
  1257. tenant_no: str,
  1258. user_id: Optional[str],
  1259. sample_payments: List[Dict[str, Any]],
  1260. ) -> None:
  1261. """按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
  1262. existing = (
  1263. db.query(BizProtocolSamplePayment)
  1264. .filter(
  1265. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  1266. BizProtocolSamplePayment.tenant_no == tenant_no,
  1267. BizProtocolSamplePayment.del_flag == 0,
  1268. )
  1269. .order_by(BizProtocolSamplePayment.id.asc())
  1270. .populate_existing()
  1271. .with_for_update()
  1272. .all()
  1273. )
  1274. payload_rows = [raw for raw in (sample_payments or []) if isinstance(raw, dict)]
  1275. payload_by_id = {
  1276. str(raw.get("id") or raw.get("samplePaymentId") or "").strip(): raw
  1277. for raw in payload_rows
  1278. if str(raw.get("id") or raw.get("samplePaymentId") or "").strip()
  1279. }
  1280. payload_by_code = {
  1281. str(raw.get("sample_code") or raw.get("sampleCode") or "").strip(): raw
  1282. for raw in payload_rows
  1283. if str(raw.get("sample_code") or raw.get("sampleCode") or "").strip()
  1284. }
  1285. for current in existing:
  1286. _, schedules, transfers = _lock_sample_payment_graph(
  1287. db,
  1288. sample_payment_id=current.id,
  1289. tenant_no=tenant_no,
  1290. )
  1291. if not _locked_schedule_ids(schedules, transfers):
  1292. continue
  1293. raw = payload_by_id.get(current.id) or payload_by_code.get(current.sample_code)
  1294. if raw is None:
  1295. raise ValueError("存在锁定缴费方案,不能省略或删除")
  1296. submitted_mode = str(raw.get("pay_mode") or raw.get("payMode") or "").strip()
  1297. if submitted_mode and submitted_mode != (current.pay_mode or "").strip():
  1298. raise ValueError("存在锁定缴费方案,不能变更缴费模式或重建")
  1299. _assert_locked_schedule_structure(
  1300. schedules,
  1301. transfers,
  1302. list(raw.get("schedules") or []),
  1303. )
  1304. keep_ids: set = set()
  1305. for raw in payload_rows:
  1306. if not isinstance(raw, dict):
  1307. continue
  1308. sample_code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
  1309. if not sample_code:
  1310. raise ValueError("样本缴费方案缺少样本编码")
  1311. pay_mode = (raw.get("pay_mode") or raw.get("payMode") or "").strip()
  1312. sp_id = (raw.get("id") or raw.get("samplePaymentId") or "").strip() or None
  1313. ratios = raw.get("installment_ratios")
  1314. if ratios is None:
  1315. ratios = raw.get("installmentRatios")
  1316. prev = find_active_sample_payment(
  1317. db,
  1318. tenant_no=tenant_no,
  1319. sample_payment_id=sp_id,
  1320. collection_protocol_id=collection_protocol_id,
  1321. sample_code=sample_code,
  1322. )
  1323. prev_mode = (prev.pay_mode or "").strip() if prev else ""
  1324. sp = upsert_sample_payment(
  1325. db,
  1326. collection_protocol_id=collection_protocol_id,
  1327. tenant_no=tenant_no,
  1328. user_id=user_id,
  1329. sample_code=sample_code,
  1330. pay_mode=pay_mode,
  1331. contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
  1332. protocol_sample_id=raw.get("protocol_sample_id") or raw.get("protocolSampleId"),
  1333. installment_count=raw.get("installment_count", raw.get("installmentCount")),
  1334. installment_interval_months=raw.get(
  1335. "installment_interval_months", raw.get("installmentIntervalMonths")
  1336. ),
  1337. installment_start_date=raw.get(
  1338. "installment_start_date", raw.get("installmentStartDate")
  1339. ),
  1340. installment_advance_days=raw.get(
  1341. "installment_advance_days", raw.get("installmentAdvanceDays")
  1342. ),
  1343. installment_ratios=_normalize_installment_ratios(ratios),
  1344. lump_sum_deadline=raw.get("lump_sum_deadline", raw.get("lumpSumDeadline")),
  1345. remark=raw.get("remark"),
  1346. sample_payment_id=sp_id,
  1347. )
  1348. keep_ids.add(sp.id)
  1349. if (
  1350. prev_mode == "fq"
  1351. and pay_mode in ("fgd", "dj")
  1352. and sample_payment_has_yjf(
  1353. db, sample_payment_id=sp.id, tenant_no=tenant_no
  1354. )
  1355. ):
  1356. apply_fq_to_fgd_merge(
  1357. db,
  1358. sample_payment_id=sp.id,
  1359. tenant_no=tenant_no,
  1360. user_id=user_id,
  1361. target_pay_mode=pay_mode,
  1362. )
  1363. continue
  1364. schedules_payload = raw.get("schedules") or []
  1365. require_fields = pay_mode in ("fq", "fgd", "djfq")
  1366. ok, mismatch_msg = schedule_dues_match_contract(
  1367. contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
  1368. schedules=schedules_payload,
  1369. sample_code=sample_code,
  1370. require_schedule_fields=require_fields,
  1371. )
  1372. if not ok:
  1373. raise ValueError(mismatch_msg or "各期应缴合计与合同金额不一致")
  1374. sync_schedules_and_transfers(
  1375. db,
  1376. sample_payment_id=sp.id,
  1377. tenant_no=tenant_no,
  1378. user_id=user_id,
  1379. schedules=schedules_payload,
  1380. )
  1381. for sp in existing:
  1382. if sp.id not in keep_ids:
  1383. _soft_delete_sample_payment(
  1384. db, sp=sp, tenant_no=tenant_no, user_id=user_id
  1385. )
  1386. db.commit()
  1387. def list_deposit_transfers_for_protocol(
  1388. db: Session,
  1389. *,
  1390. source_presign_order_id: Optional[str],
  1391. tenant_no: str,
  1392. money_as_string: bool = False,
  1393. ) -> List[Dict[str, Any]]:
  1394. """正式协议意向金列表:始终读预签订单 biz_presign_transfer,保证财务审批两边一致。"""
  1395. pid = (source_presign_order_id or "").strip()
  1396. if not pid:
  1397. return []
  1398. from app.crud.presign_order import list_transfers
  1399. result = list_transfers(db, presign_order_id=pid, tenant_no=tenant_no)
  1400. if money_as_string and result:
  1401. rows = (
  1402. db.query(BizPresignTransfer)
  1403. .filter(
  1404. BizPresignTransfer.id.in_([item.get('id') for item in result]),
  1405. BizPresignTransfer.tenant_no == tenant_no,
  1406. BizPresignTransfer.del_flag == 0,
  1407. )
  1408. .all()
  1409. )
  1410. amount_by_id = {str(row.id): _money_value(row.transfer_amount, True) for row in rows}
  1411. for item in result:
  1412. item['transferAmount'] = amount_by_id.get(str(item.get('id') or ''))
  1413. return result
  1414. def get_presign_deposit_hint(
  1415. db: Session,
  1416. *,
  1417. source_presign_order_id: Optional[str],
  1418. tenant_no: str,
  1419. money_as_string: bool = False,
  1420. ) -> Optional[Dict[str, Any]]:
  1421. pid = (source_presign_order_id or "").strip()
  1422. if not pid:
  1423. return None
  1424. rows = (
  1425. db.query(BizPresignTransfer)
  1426. .filter(
  1427. BizPresignTransfer.presign_order_id == pid,
  1428. BizPresignTransfer.tenant_no == tenant_no,
  1429. BizPresignTransfer.del_flag == 0,
  1430. BizPresignTransfer.pay_status == "yjf",
  1431. )
  1432. .all()
  1433. )
  1434. total = Decimal("0")
  1435. for r in rows:
  1436. if r.transfer_amount is not None:
  1437. total += Decimal(str(r.transfer_amount))
  1438. return {
  1439. "presignOrderId": pid,
  1440. "confirmedDepositAmount": _money_value(total, money_as_string),
  1441. "confirmedTransferCount": len(rows),
  1442. }
  1443. def get_sample_payments_bundle(
  1444. db: Session,
  1445. collection_protocol_id: str,
  1446. tenant_no: str,
  1447. money_as_string: bool = False,
  1448. ) -> List[Dict[str, Any]]:
  1449. payments = (
  1450. db.query(BizProtocolSamplePayment)
  1451. .filter(
  1452. BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
  1453. BizProtocolSamplePayment.tenant_no == tenant_no,
  1454. BizProtocolSamplePayment.del_flag == 0,
  1455. )
  1456. .order_by(
  1457. BizProtocolSamplePayment.order_num.asc(),
  1458. BizProtocolSamplePayment.create_time.asc(),
  1459. )
  1460. .all()
  1461. )
  1462. if not payments:
  1463. return []
  1464. pids = [p.id for p in payments]
  1465. schedules = (
  1466. db.query(BizProtocolPaymentSchedule)
  1467. .filter(
  1468. BizProtocolPaymentSchedule.sample_payment_id.in_(pids),
  1469. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  1470. BizProtocolPaymentSchedule.del_flag == 0,
  1471. )
  1472. .order_by(
  1473. BizProtocolPaymentSchedule.order_num.asc(),
  1474. BizProtocolPaymentSchedule.schedule_no.asc(),
  1475. )
  1476. .all()
  1477. )
  1478. sids = [s.id for s in schedules]
  1479. transfers: List[BizProtocolPaymentTransfer] = []
  1480. if sids:
  1481. transfers = (
  1482. db.query(BizProtocolPaymentTransfer)
  1483. .filter(
  1484. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  1485. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  1486. BizProtocolPaymentTransfer.del_flag == 0,
  1487. )
  1488. .order_by(BizProtocolPaymentTransfer.create_time.asc())
  1489. .all()
  1490. )
  1491. by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  1492. for t in transfers:
  1493. by_schedule.setdefault(t.schedule_id, []).append(t)
  1494. schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
  1495. for s in schedules:
  1496. schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
  1497. return [
  1498. _sample_payment_to_dict(
  1499. db,
  1500. p,
  1501. schedules_by_payment.get(p.id, []),
  1502. by_schedule,
  1503. tenant_no,
  1504. money_as_string,
  1505. )
  1506. for p in payments
  1507. ]
  1508. def list_transfers_for_finance(
  1509. db: Session,
  1510. *,
  1511. pay_status: str,
  1512. tenant_no: str,
  1513. keyword: Optional[str] = None,
  1514. page: int = 1,
  1515. page_size: int = 10,
  1516. user_info=None,
  1517. due_date_start: Optional[str] = None,
  1518. due_date_end: Optional[str] = None,
  1519. ) -> Tuple[List[Dict[str, Any]], int]:
  1520. if not tenant_no:
  1521. return [], 0
  1522. st = (pay_status or "").strip()
  1523. q = (
  1524. db.query(
  1525. BizProtocolPaymentTransfer,
  1526. BizCollectionProtocol,
  1527. BizProtocolPartyCustomer,
  1528. BizProtocolPaymentSchedule,
  1529. BizProtocolSamplePayment,
  1530. )
  1531. .join(
  1532. BizCollectionProtocol,
  1533. and_(
  1534. BizCollectionProtocol.id
  1535. == BizProtocolPaymentTransfer.collection_protocol_id,
  1536. BizCollectionProtocol.del_flag == 0,
  1537. ),
  1538. )
  1539. .outerjoin(
  1540. BizProtocolPartyCustomer,
  1541. and_(
  1542. BizProtocolPartyCustomer.collection_protocol_id
  1543. == BizProtocolPaymentTransfer.collection_protocol_id,
  1544. BizProtocolPartyCustomer.tenant_no == tenant_no,
  1545. BizProtocolPartyCustomer.del_flag == 0,
  1546. ),
  1547. )
  1548. .outerjoin(
  1549. BizProtocolPaymentSchedule,
  1550. and_(
  1551. BizProtocolPaymentSchedule.id == BizProtocolPaymentTransfer.schedule_id,
  1552. BizProtocolPaymentSchedule.del_flag == 0,
  1553. ),
  1554. )
  1555. .outerjoin(
  1556. BizProtocolSamplePayment,
  1557. and_(
  1558. BizProtocolSamplePayment.id
  1559. == BizProtocolPaymentTransfer.sample_payment_id,
  1560. BizProtocolSamplePayment.del_flag == 0,
  1561. ),
  1562. )
  1563. .filter(
  1564. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  1565. BizProtocolPaymentTransfer.del_flag == 0,
  1566. BizProtocolPaymentTransfer.pay_status == st,
  1567. )
  1568. )
  1569. kw = (keyword or "").strip()
  1570. if kw:
  1571. like = f"%{kw}%"
  1572. q = q.filter(
  1573. or_(
  1574. BizCollectionProtocol.protocol_no.like(like),
  1575. BizProtocolPaymentTransfer.sample_code.like(like),
  1576. BizProtocolPartyCustomer.mother_name.like(like),
  1577. BizProtocolPartyCustomer.mother_id_card.like(like),
  1578. BizProtocolPartyCustomer.newborn_name.like(like),
  1579. BizProtocolPartyCustomer.newborn_id_card.like(like),
  1580. )
  1581. )
  1582. ds = (due_date_start or "").strip().replace("-", "")[:8]
  1583. de = (due_date_end or "").strip().replace("-", "")[:8]
  1584. if ds:
  1585. q = q.filter(BizProtocolPaymentSchedule.due_date >= ds)
  1586. if de:
  1587. q = q.filter(BizProtocolPaymentSchedule.due_date <= de)
  1588. if user_info:
  1589. from app.core.data_scope import apply_data_scope
  1590. q = apply_data_scope(
  1591. q, BizProtocolPaymentTransfer, user_info, db, tenant_no
  1592. )
  1593. total = q.order_by(None).with_entities(func.count(BizProtocolPaymentTransfer.id)).scalar() or 0
  1594. page = max(1, page)
  1595. page_size = min(max(1, page_size), 100)
  1596. rows = (
  1597. q.order_by(BizProtocolPaymentTransfer.create_time.desc())
  1598. .offset((page - 1) * page_size)
  1599. .limit(page_size)
  1600. .all()
  1601. )
  1602. out: List[Dict[str, Any]] = []
  1603. for t, proto, party, sch, sp in rows:
  1604. item = _transfer_to_dict(db, t, tenant_no)
  1605. item["protocolNo"] = (proto.protocol_no if proto else "") or ""
  1606. item["protocolStatus"] = (proto.protocol_status if proto else "") or ""
  1607. item["voidType"] = (proto.void_type if proto else "") or ""
  1608. item["motherName"] = (party.mother_name if party else "") or ""
  1609. item["motherIdCard"] = (party.mother_id_card if party else "") or ""
  1610. item["newbornName"] = (party.newborn_name if party else "") or ""
  1611. item["newbornIdCard"] = (party.newborn_id_card if party else "") or ""
  1612. item["installmentNo"] = sch.schedule_no if sch else None
  1613. item["installmentDueDate"] = (sch.due_date if sch else "") or ""
  1614. item["dueAmount"] = (
  1615. float(sch.due_amount) if sch and sch.due_amount is not None else None
  1616. )
  1617. item["payMode"] = (sp.pay_mode if sp else "") or ""
  1618. out.append(item)
  1619. return out, int(total)
  1620. def _filter_and_page_progress(
  1621. rows: List[Dict[str, Any]],
  1622. progress_status: Optional[str],
  1623. page: int,
  1624. page_size: int,
  1625. ) -> Tuple[List[Dict[str, Any]], int]:
  1626. """先按 progressStatus 过滤,再分页切片(禁止先 LIMIT 再筛进度)。"""
  1627. filtered = rows
  1628. ps = (progress_status or "").strip()
  1629. if ps:
  1630. filtered = [r for r in rows if (r.get("progressStatus") or "") == ps]
  1631. total = len(filtered)
  1632. page = max(1, page)
  1633. page_size = min(max(1, page_size), 100)
  1634. start = (page - 1) * page_size
  1635. return filtered[start : start + page_size], total
  1636. def _progress_to_json_fields(progress: Dict[str, Any]) -> Dict[str, Any]:
  1637. paid = progress.get("paidAmount")
  1638. due_remain = progress.get("dueRemainAmount")
  1639. return {
  1640. "progressStatus": progress.get("progressStatus") or "",
  1641. "paidAmount": float(paid) if paid is not None else 0.0,
  1642. "dueRemainAmount": float(due_remain) if due_remain is not None else 0.0,
  1643. "clearedScheduleCount": progress.get("clearedScheduleCount") or 0,
  1644. "activeScheduleCount": progress.get("activeScheduleCount") or 0,
  1645. "canConfirm": bool(progress.get("canConfirm")),
  1646. }
  1647. def _load_payment_children_batch(
  1648. db: Session,
  1649. sample_payment_ids: List[str],
  1650. tenant_no: str,
  1651. ) -> Tuple[
  1652. Dict[str, List[BizProtocolPaymentSchedule]],
  1653. Dict[str, List[BizProtocolPaymentTransfer]],
  1654. ]:
  1655. schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
  1656. transfers_by_payment: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  1657. if not sample_payment_ids:
  1658. return schedules_by_payment, transfers_by_payment
  1659. schedules = (
  1660. db.query(BizProtocolPaymentSchedule)
  1661. .filter(
  1662. BizProtocolPaymentSchedule.sample_payment_id.in_(sample_payment_ids),
  1663. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  1664. BizProtocolPaymentSchedule.del_flag == 0,
  1665. )
  1666. .order_by(
  1667. BizProtocolPaymentSchedule.order_num.asc(),
  1668. BizProtocolPaymentSchedule.schedule_no.asc(),
  1669. )
  1670. .all()
  1671. )
  1672. sids = [s.id for s in schedules]
  1673. transfers: List[BizProtocolPaymentTransfer] = []
  1674. if sids:
  1675. transfers = (
  1676. db.query(BizProtocolPaymentTransfer)
  1677. .filter(
  1678. BizProtocolPaymentTransfer.schedule_id.in_(sids),
  1679. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  1680. BizProtocolPaymentTransfer.del_flag == 0,
  1681. )
  1682. .order_by(BizProtocolPaymentTransfer.create_time.asc())
  1683. .all()
  1684. )
  1685. for s in schedules:
  1686. schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
  1687. schedule_to_payment = {s.id: s.sample_payment_id for s in schedules}
  1688. for t in transfers:
  1689. pid = schedule_to_payment.get(t.schedule_id) or t.sample_payment_id
  1690. if pid:
  1691. transfers_by_payment.setdefault(pid, []).append(t)
  1692. return schedules_by_payment, transfers_by_payment
  1693. def _compute_progress_for_payment(
  1694. schedules: List[BizProtocolPaymentSchedule],
  1695. transfers: List[BizProtocolPaymentTransfer],
  1696. ) -> Dict[str, Any]:
  1697. sch_dicts = [
  1698. {
  1699. "id": s.id,
  1700. "scheduleStatus": s.schedule_status or "",
  1701. "dueAmount": s.due_amount,
  1702. }
  1703. for s in schedules
  1704. ]
  1705. tr_dicts = [
  1706. {
  1707. "scheduleId": t.schedule_id,
  1708. "payStatus": t.pay_status or "",
  1709. "transferAmount": t.transfer_amount,
  1710. }
  1711. for t in transfers
  1712. ]
  1713. return compute_sample_payment_progress(schedules=sch_dicts, transfers=tr_dicts)
  1714. def _build_list_summary_row(
  1715. sp: BizProtocolSamplePayment,
  1716. proto: BizCollectionProtocol,
  1717. party: Optional[BizProtocolPartyCustomer],
  1718. progress: Dict[str, Any],
  1719. *,
  1720. schedules: Optional[List[BizProtocolPaymentSchedule]] = None,
  1721. transfers: Optional[List[BizProtocolPaymentTransfer]] = None,
  1722. sample_meta: Optional[Dict[str, Any]] = None,
  1723. ) -> Dict[str, Any]:
  1724. sch_list = schedules or []
  1725. tr_list = transfers or []
  1726. meta = sample_meta or {}
  1727. active_dues = [
  1728. s.due_amount
  1729. for s in sch_list
  1730. if (s.schedule_status or "") != "void" and s.due_amount is not None
  1731. ]
  1732. receivable = float(sum(active_dues, Decimal("0"))) if active_dues else 0.0
  1733. discount = meta.get("discountAmount")
  1734. row: Dict[str, Any] = {
  1735. "id": sp.id,
  1736. "collectionProtocolId": sp.collection_protocol_id,
  1737. "protocolNo": (proto.protocol_no if proto else "") or "",
  1738. "protocolStatus": (proto.protocol_status if proto else "") or "",
  1739. "voidType": (proto.void_type if proto else "") or "",
  1740. "motherName": (party.mother_name if party else "") or "",
  1741. "motherPhone": (party.mother_phone if party else "") or "",
  1742. "sampleCode": sp.sample_code or "",
  1743. "payMode": sp.pay_mode or "",
  1744. "contractAmount": float(sp.contract_amount)
  1745. if sp.contract_amount is not None
  1746. else None,
  1747. "signDate": _fmt_ymd8(getattr(proto, "register_date", None) or ""),
  1748. "storageYears": meta.get("storageYears"),
  1749. "sampleTypeName": meta.get("sampleTypeName") or "",
  1750. "discountAmount": discount,
  1751. "receivableAmount": receivable,
  1752. "remark": sp.remark or "",
  1753. "periods": _build_periods_array(sch_list, tr_list),
  1754. }
  1755. row.update(_progress_to_json_fields(progress))
  1756. return row
  1757. def list_sample_payments_page(
  1758. db: Session,
  1759. *,
  1760. tenant_no: str,
  1761. keyword: Optional[str] = None,
  1762. pay_mode: Optional[str] = None,
  1763. progress_status: Optional[str] = None,
  1764. due_date_start: Optional[str] = None,
  1765. due_date_end: Optional[str] = None,
  1766. page: int = 1,
  1767. page_size: int = 10,
  1768. user_info=None,
  1769. ) -> Tuple[List[Dict[str, Any]], int]:
  1770. if not tenant_no:
  1771. return [], 0
  1772. q = (
  1773. db.query(
  1774. BizProtocolSamplePayment,
  1775. BizCollectionProtocol,
  1776. BizProtocolPartyCustomer,
  1777. )
  1778. .join(
  1779. BizCollectionProtocol,
  1780. and_(
  1781. BizCollectionProtocol.id
  1782. == BizProtocolSamplePayment.collection_protocol_id,
  1783. BizCollectionProtocol.del_flag == 0,
  1784. ),
  1785. )
  1786. .outerjoin(
  1787. BizProtocolPartyCustomer,
  1788. and_(
  1789. BizProtocolPartyCustomer.collection_protocol_id
  1790. == BizProtocolSamplePayment.collection_protocol_id,
  1791. BizProtocolPartyCustomer.tenant_no == tenant_no,
  1792. BizProtocolPartyCustomer.del_flag == 0,
  1793. ),
  1794. )
  1795. .filter(
  1796. BizProtocolSamplePayment.tenant_no == tenant_no,
  1797. BizProtocolSamplePayment.del_flag == 0,
  1798. )
  1799. )
  1800. kw = (keyword or "").strip()
  1801. if kw:
  1802. like = f"%{kw}%"
  1803. q = q.filter(
  1804. or_(
  1805. BizCollectionProtocol.protocol_no.like(like),
  1806. BizProtocolSamplePayment.sample_code.like(like),
  1807. BizProtocolPartyCustomer.mother_name.like(like),
  1808. BizProtocolPartyCustomer.mother_phone.like(like),
  1809. )
  1810. )
  1811. mode = (pay_mode or "").strip()
  1812. if mode:
  1813. q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
  1814. ds = (due_date_start or "").strip().replace("-", "")[:8]
  1815. de = (due_date_end or "").strip().replace("-", "")[:8]
  1816. if ds or de:
  1817. due_conditions = [
  1818. BizProtocolPaymentSchedule.sample_payment_id
  1819. == BizProtocolSamplePayment.id,
  1820. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  1821. BizProtocolPaymentSchedule.del_flag == 0,
  1822. BizProtocolPaymentSchedule.schedule_status != "void",
  1823. ]
  1824. if ds:
  1825. due_conditions.append(BizProtocolPaymentSchedule.due_date >= ds)
  1826. if de:
  1827. due_conditions.append(BizProtocolPaymentSchedule.due_date <= de)
  1828. q = q.filter(exists().where(and_(*due_conditions)))
  1829. if user_info:
  1830. from app.core.data_scope import apply_data_scope
  1831. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  1832. candidates = q.order_by(BizProtocolSamplePayment.create_time.desc()).all()
  1833. if not candidates:
  1834. return [], 0
  1835. pids = [sp.id for sp, _, _ in candidates]
  1836. schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
  1837. db, pids, tenant_no
  1838. )
  1839. sample_codes = list({(sp.sample_code or "").strip() for sp, _, _ in candidates if sp.sample_code})
  1840. protocol_ids = list({sp.collection_protocol_id for sp, _, _ in candidates})
  1841. sample_meta_by_key: Dict[str, Dict[str, Any]] = {}
  1842. if sample_codes and protocol_ids:
  1843. s_rows = (
  1844. db.query(BizProtocolSample)
  1845. .filter(
  1846. BizProtocolSample.collection_protocol_id.in_(protocol_ids),
  1847. BizProtocolSample.tenant_no == tenant_no,
  1848. BizProtocolSample.del_flag == 0,
  1849. BizProtocolSample.sample_code.in_(sample_codes),
  1850. )
  1851. .all()
  1852. )
  1853. type_ids = list({s.sample_type_id for s in s_rows if s.sample_type_id})
  1854. type_name_map: Dict[str, str] = {}
  1855. if type_ids:
  1856. for t in (
  1857. db.query(BizCellSampleType)
  1858. .filter(BizCellSampleType.id.in_(type_ids), BizCellSampleType.del_flag == 0)
  1859. .all()
  1860. ):
  1861. type_name_map[t.id] = t.type_name or ""
  1862. for s in s_rows:
  1863. key = f"{s.collection_protocol_id}|{(s.sample_code or '').strip()}"
  1864. orig = float(s.original_price) if s.original_price is not None else None
  1865. price = float(s.price) if s.price is not None else None
  1866. discount = None
  1867. if orig is not None and price is not None and orig > price:
  1868. discount = round(orig - price, 2)
  1869. sample_meta_by_key[key] = {
  1870. "storageYears": s.storage_years,
  1871. "sampleTypeName": type_name_map.get(s.sample_type_id or "", ""),
  1872. "discountAmount": discount,
  1873. }
  1874. rows: List[Dict[str, Any]] = []
  1875. for sp, proto, party in candidates:
  1876. schedules = schedules_by_payment.get(sp.id, [])
  1877. transfers = transfers_by_payment.get(sp.id, [])
  1878. progress = _compute_progress_for_payment(schedules, transfers)
  1879. meta_key = f"{sp.collection_protocol_id}|{(sp.sample_code or '').strip()}"
  1880. rows.append(
  1881. _build_list_summary_row(
  1882. sp,
  1883. proto,
  1884. party,
  1885. progress,
  1886. schedules=schedules,
  1887. transfers=transfers,
  1888. sample_meta=sample_meta_by_key.get(meta_key),
  1889. )
  1890. )
  1891. return _filter_and_page_progress(rows, progress_status, page, page_size)
  1892. def get_sample_payment_detail(
  1893. db: Session,
  1894. *,
  1895. sample_payment_id: str,
  1896. tenant_no: str,
  1897. user_info=None,
  1898. ) -> Optional[Dict[str, Any]]:
  1899. q = (
  1900. db.query(
  1901. BizProtocolSamplePayment,
  1902. BizCollectionProtocol,
  1903. BizProtocolPartyCustomer,
  1904. )
  1905. .join(
  1906. BizCollectionProtocol,
  1907. and_(
  1908. BizCollectionProtocol.id
  1909. == BizProtocolSamplePayment.collection_protocol_id,
  1910. BizCollectionProtocol.del_flag == 0,
  1911. ),
  1912. )
  1913. .outerjoin(
  1914. BizProtocolPartyCustomer,
  1915. and_(
  1916. BizProtocolPartyCustomer.collection_protocol_id
  1917. == BizProtocolSamplePayment.collection_protocol_id,
  1918. BizProtocolPartyCustomer.tenant_no == tenant_no,
  1919. BizProtocolPartyCustomer.del_flag == 0,
  1920. ),
  1921. )
  1922. .filter(
  1923. BizProtocolSamplePayment.id == sample_payment_id,
  1924. BizProtocolSamplePayment.tenant_no == tenant_no,
  1925. BizProtocolSamplePayment.del_flag == 0,
  1926. )
  1927. )
  1928. if user_info:
  1929. from app.core.data_scope import apply_data_scope
  1930. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  1931. row = q.first()
  1932. if not row:
  1933. return None
  1934. sp, proto, party = row
  1935. schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
  1936. db, [sp.id], tenant_no
  1937. )
  1938. schedules = schedules_by_payment.get(sp.id, [])
  1939. transfers = transfers_by_payment.get(sp.id, [])
  1940. by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  1941. for t in transfers:
  1942. by_schedule.setdefault(t.schedule_id, []).append(t)
  1943. detail = _sample_payment_to_dict(db, sp, schedules, by_schedule, tenant_no)
  1944. detail["protocolNo"] = (proto.protocol_no if proto else "") or ""
  1945. detail["protocolStatus"] = (proto.protocol_status if proto else "") or ""
  1946. detail["voidType"] = (proto.void_type if proto else "") or ""
  1947. detail["motherName"] = (party.mother_name if party else "") or ""
  1948. detail["motherPhone"] = (party.mother_phone if party else "") or ""
  1949. progress = _compute_progress_for_payment(schedules, transfers)
  1950. detail.update(_progress_to_json_fields(progress))
  1951. return detail
  1952. def list_payment_chase_page(
  1953. db: Session,
  1954. *,
  1955. tenant_no: str,
  1956. keyword: Optional[str] = None,
  1957. chase_type: Optional[str] = None,
  1958. pay_mode: Optional[str] = None,
  1959. sales_user_id: Optional[str] = None,
  1960. page: int = 1,
  1961. page_size: int = 10,
  1962. user_info=None,
  1963. today: Optional[Any] = None,
  1964. ) -> Tuple[List[Dict[str, Any]], int]:
  1965. """催缴列表:一行 = 一个欠缴>0 且(逾期或未来30天内)的应缴期。"""
  1966. from datetime import date as date_cls
  1967. if not tenant_no:
  1968. return [], 0
  1969. today_d = today if isinstance(today, date_cls) else date_cls.today()
  1970. q = (
  1971. db.query(
  1972. BizProtocolPaymentSchedule,
  1973. BizProtocolSamplePayment,
  1974. BizCollectionProtocol,
  1975. BizProtocolPartyCustomer,
  1976. BizProtocolCollectionInfo,
  1977. )
  1978. .join(
  1979. BizProtocolSamplePayment,
  1980. and_(
  1981. BizProtocolSamplePayment.id
  1982. == BizProtocolPaymentSchedule.sample_payment_id,
  1983. BizProtocolSamplePayment.del_flag == 0,
  1984. ),
  1985. )
  1986. .join(
  1987. BizCollectionProtocol,
  1988. and_(
  1989. BizCollectionProtocol.id
  1990. == BizProtocolPaymentSchedule.collection_protocol_id,
  1991. BizCollectionProtocol.del_flag == 0,
  1992. ),
  1993. )
  1994. .outerjoin(
  1995. BizProtocolPartyCustomer,
  1996. and_(
  1997. BizProtocolPartyCustomer.collection_protocol_id
  1998. == BizProtocolPaymentSchedule.collection_protocol_id,
  1999. BizProtocolPartyCustomer.tenant_no == tenant_no,
  2000. BizProtocolPartyCustomer.del_flag == 0,
  2001. ),
  2002. )
  2003. .outerjoin(
  2004. BizProtocolCollectionInfo,
  2005. and_(
  2006. BizProtocolCollectionInfo.collection_protocol_id
  2007. == BizProtocolPaymentSchedule.collection_protocol_id,
  2008. BizProtocolCollectionInfo.tenant_no == tenant_no,
  2009. BizProtocolCollectionInfo.del_flag == 0,
  2010. ),
  2011. )
  2012. .filter(
  2013. BizProtocolPaymentSchedule.tenant_no == tenant_no,
  2014. BizProtocolPaymentSchedule.del_flag == 0,
  2015. BizProtocolPaymentSchedule.schedule_status != "void",
  2016. )
  2017. )
  2018. kw = (keyword or "").strip()
  2019. if kw:
  2020. like = f"%{kw}%"
  2021. q = q.filter(
  2022. or_(
  2023. BizCollectionProtocol.protocol_no.like(like),
  2024. BizProtocolSamplePayment.sample_code.like(like),
  2025. BizProtocolPartyCustomer.mother_name.like(like),
  2026. BizProtocolPartyCustomer.mother_phone.like(like),
  2027. )
  2028. )
  2029. mode = (pay_mode or "").strip()
  2030. if mode:
  2031. q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
  2032. sid = (sales_user_id or "").strip()
  2033. if sid:
  2034. q = q.filter(BizProtocolCollectionInfo.sales_user_id == sid)
  2035. if user_info:
  2036. from app.core.data_scope import apply_data_scope
  2037. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  2038. candidates = q.all()
  2039. if not candidates:
  2040. return [], 0
  2041. sch_ids = [sch.id for sch, _, _, _, _ in candidates]
  2042. transfers = (
  2043. db.query(BizProtocolPaymentTransfer)
  2044. .filter(
  2045. BizProtocolPaymentTransfer.schedule_id.in_(sch_ids),
  2046. BizProtocolPaymentTransfer.tenant_no == tenant_no,
  2047. BizProtocolPaymentTransfer.del_flag == 0,
  2048. )
  2049. .all()
  2050. )
  2051. by_sch: Dict[str, List[BizProtocolPaymentTransfer]] = {}
  2052. for t in transfers:
  2053. by_sch.setdefault(t.schedule_id, []).append(t)
  2054. sales_ids = list(
  2055. {
  2056. (ci.sales_user_id or "").strip()
  2057. for _, _, _, _, ci in candidates
  2058. if ci and ci.sales_user_id
  2059. }
  2060. )
  2061. sales_name_map: Dict[str, str] = {}
  2062. if sales_ids:
  2063. for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(sales_ids)).all():
  2064. sales_name_map[u.id] = u.nick_name or ""
  2065. ct = (chase_type or "").strip()
  2066. rows: List[Dict[str, Any]] = []
  2067. for sch, sp, proto, party, ci in candidates:
  2068. due_d = parse_due_date_ymd(sch.due_date)
  2069. if due_d is None:
  2070. continue
  2071. tr_maps = [
  2072. {
  2073. "transferAmount": t.transfer_amount,
  2074. "transferType": t.transfer_type or "",
  2075. "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
  2076. if t.transfer_time
  2077. else "",
  2078. "payStatus": t.pay_status or "",
  2079. }
  2080. for t in by_sch.get(sch.id, [])
  2081. ]
  2082. paid, arr = chase_period_money(
  2083. due_amount=sch.due_amount, transfers=tr_maps
  2084. )
  2085. if not is_chase_candidate(due_date=due_d, today=today_d, arrears=arr):
  2086. continue
  2087. overdue = due_d < today_d
  2088. if ct == "overdue" and not overdue:
  2089. continue
  2090. if ct == "upcoming" and overdue:
  2091. continue
  2092. days_overdue = (today_d - due_d).days if overdue else 0
  2093. days_until = (due_d - today_d).days if not overdue else 0
  2094. rows.append(
  2095. {
  2096. "scheduleId": sch.id,
  2097. "samplePaymentId": sp.id,
  2098. "protocolId": proto.id if proto else sch.collection_protocol_id,
  2099. "protocolNo": (proto.protocol_no if proto else "") or "",
  2100. "motherName": (party.mother_name if party else "") or "",
  2101. "motherPhone": (party.mother_phone if party else "") or "",
  2102. "sampleCode": sp.sample_code or sch.sample_code or "",
  2103. "salesUserId": (ci.sales_user_id if ci else "") or "",
  2104. "salesUserName": sales_name_map.get(
  2105. (ci.sales_user_id if ci else "") or "", ""
  2106. ),
  2107. "payMode": sp.pay_mode or "",
  2108. "scheduleNo": sch.schedule_no,
  2109. "dueDate": _fmt_ymd8(sch.due_date),
  2110. "dueAmount": float(sch.due_amount) if sch.due_amount is not None else None,
  2111. "paidAmount": float(paid),
  2112. "arrearsAmount": float(arr),
  2113. "chaseStatus": "overdue" if overdue else "upcoming",
  2114. "daysOverdue": days_overdue,
  2115. "daysUntilDue": days_until,
  2116. "protocolStatus": (proto.protocol_status if proto else "") or "",
  2117. "voidType": (proto.void_type if proto else "") or "",
  2118. }
  2119. )
  2120. rows.sort(
  2121. key=lambda r: (
  2122. 0 if r["chaseStatus"] == "overdue" else 1,
  2123. -int(r.get("daysOverdue") or 0),
  2124. str(r.get("dueDate") or ""),
  2125. str(r.get("protocolNo") or ""),
  2126. )
  2127. )
  2128. total = len(rows)
  2129. page = max(1, page)
  2130. page_size = min(max(1, page_size), 100)
  2131. start = (page - 1) * page_size
  2132. return rows[start : start + page_size], total