test_protocol_payment_detail_status_guard.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855
  1. from datetime import datetime
  2. from decimal import Decimal
  3. import pytest
  4. from sqlalchemy import create_engine, update
  5. from sqlalchemy.orm import Query, sessionmaker
  6. from app.api.collection_protocol import _persist_protocol_sub_resources
  7. from app.crud.collection_protocol import save_payment_details, save_payment_with_details
  8. from app.models.base import Base
  9. from app.models.models import BizProtocolPayment, BizProtocolPaymentDetail
  10. def _session(*, allow_null_pay_status=False):
  11. engine = create_engine("sqlite:///:memory:")
  12. pay_status_column = BizProtocolPaymentDetail.__table__.c.pay_status
  13. original_nullable = pay_status_column.nullable
  14. try:
  15. if allow_null_pay_status:
  16. pay_status_column.nullable = True
  17. Base.metadata.create_all(
  18. engine,
  19. tables=[BizProtocolPayment.__table__, BizProtocolPaymentDetail.__table__],
  20. )
  21. finally:
  22. pay_status_column.nullable = original_nullable
  23. return sessionmaker(bind=engine)()
  24. def _payment(
  25. db,
  26. *,
  27. payment_id="payment-a",
  28. tenant_no="tenant-a",
  29. pay_mode="dj",
  30. contract_amount="1000",
  31. installment_count=None,
  32. ):
  33. row = BizProtocolPayment(
  34. id=payment_id,
  35. collection_protocol_id="protocol-a",
  36. pay_mode=pay_mode,
  37. contract_amount=Decimal(contract_amount),
  38. installment_count=installment_count,
  39. installment_ratios="[1000, 1000]" if installment_count == 2 else None,
  40. tenant_no=tenant_no,
  41. create_dept_id="",
  42. create_time=datetime(2026, 10, 14),
  43. update_time=datetime(2026, 10, 14),
  44. del_flag=0,
  45. )
  46. db.add(row)
  47. db.commit()
  48. return row
  49. def _detail(
  50. db,
  51. *,
  52. detail_id="detail-a",
  53. payment_id="payment-a",
  54. tenant_no="tenant-a",
  55. pay_status="ytf",
  56. due_amount="1000",
  57. installment_no=None,
  58. installment_due_date=None,
  59. ):
  60. row = BizProtocolPaymentDetail(
  61. id=detail_id,
  62. payment_id=payment_id,
  63. transfer_type="bank",
  64. due_amount=Decimal(due_amount),
  65. transfer_amount=Decimal(due_amount),
  66. transfer_time=datetime(2026, 10, 1, 10, 0, 0),
  67. transfer_attachments="receipt.pdf",
  68. finance_confirm_attachments="finance.pdf",
  69. finance_remark="confirmed",
  70. refund_finance_confirm_attachments="refund-confirm.pdf",
  71. refund_finance_remark="refund confirmed",
  72. pay_status=pay_status,
  73. installment_no=installment_no,
  74. installment_due_date=installment_due_date,
  75. refund_amount=Decimal("100"),
  76. refund_remark="refund requested",
  77. refund_attachments="refund.pdf",
  78. tenant_no=tenant_no,
  79. create_dept_id="",
  80. create_time=datetime(2026, 10, 14),
  81. update_time=datetime(2026, 10, 14),
  82. del_flag=0,
  83. )
  84. db.add(row)
  85. db.commit()
  86. if pay_status is None:
  87. db.execute(
  88. update(BizProtocolPaymentDetail)
  89. .where(BizProtocolPaymentDetail.id == detail_id)
  90. .values(pay_status=None)
  91. )
  92. db.commit()
  93. db.refresh(row)
  94. return row
  95. @pytest.mark.parametrize(
  96. "pay_status", ["jfqrz", "yjf", "tfqrz", "ytf", "future_locked", None, "", " ", " dfj "]
  97. )
  98. def test_locked_detail_is_preserved_when_omitted(pay_status):
  99. db = _session(allow_null_pay_status=pay_status is None)
  100. _payment(db)
  101. _detail(db, pay_status=pay_status)
  102. rows = save_payment_details(
  103. db,
  104. payment_id="payment-a",
  105. tenant_no="tenant-a",
  106. user_id="market-user",
  107. details=[],
  108. )
  109. assert len(rows) == 1
  110. row = rows[0]
  111. assert row.id == "detail-a"
  112. assert row.del_flag == 0
  113. assert row.pay_status == pay_status
  114. @pytest.mark.parametrize(
  115. "pay_status", ["jfqrz", "yjf", "tfqrz", "ytf", "future_locked", None, "", " ", " dfj "]
  116. )
  117. def test_locked_detail_is_immutable_when_submitted_by_id(pay_status):
  118. db = _session(allow_null_pay_status=pay_status is None)
  119. _payment(db)
  120. _detail(db, pay_status=pay_status)
  121. rows = save_payment_details(
  122. db,
  123. payment_id="payment-a",
  124. tenant_no="tenant-a",
  125. user_id="market-user",
  126. details=[
  127. {
  128. "id": "detail-a",
  129. "transferType": "cash",
  130. "dueAmount": 1,
  131. "transferAmount": 2,
  132. "transferTime": "2026-12-31 23:59:59",
  133. "installmentNo": 9,
  134. "installmentDueDate": "20261231",
  135. "remark": "tampered",
  136. "attachments": [{"fileUrl": "tampered.pdf"}],
  137. }
  138. ],
  139. )
  140. assert len(rows) == 1
  141. row = rows[0]
  142. assert row.id == "detail-a"
  143. assert row.pay_status == pay_status
  144. assert row.transfer_type == "bank"
  145. assert row.due_amount == Decimal("1000")
  146. assert row.transfer_amount == Decimal("1000")
  147. assert row.transfer_time == datetime(2026, 10, 1, 10, 0, 0)
  148. assert row.installment_no is None
  149. assert row.installment_due_date is None
  150. assert row.remark is None
  151. assert row.transfer_attachments == "receipt.pdf"
  152. assert row.finance_confirm_attachments == "finance.pdf"
  153. assert row.finance_remark == "confirmed"
  154. assert row.refund_amount == Decimal("100")
  155. assert row.refund_remark == "refund requested"
  156. assert row.refund_attachments == "refund.pdf"
  157. assert row.refund_finance_confirm_attachments == "refund-confirm.pdf"
  158. assert row.refund_finance_remark == "refund confirmed"
  159. def test_mixed_save_updates_and_deletes_only_drafts_and_creates_safe_row():
  160. db = _session()
  161. _payment(db)
  162. _detail(db, detail_id="locked", pay_status="yjf")
  163. _detail(db, detail_id="draft-update", pay_status="dfj", due_amount="200")
  164. _detail(db, detail_id="draft-delete", pay_status="dfj", due_amount="300")
  165. rows = save_payment_details(
  166. db,
  167. payment_id="payment-a",
  168. tenant_no="tenant-a",
  169. user_id="market-user",
  170. details=[
  171. {
  172. "id": "locked",
  173. "dueAmount": 1,
  174. "transferAmount": 1,
  175. "remark": "tampered",
  176. },
  177. {
  178. "id": "draft-update",
  179. "transferType": "cash",
  180. "dueAmount": 250,
  181. "transferAmount": 150,
  182. "transferTime": "2026-10-20 12:00:00",
  183. "remark": "updated draft",
  184. "attachments": [{"fileUrl": "draft-new.pdf"}],
  185. },
  186. {
  187. "dueAmount": 500,
  188. "transferAmount": 0,
  189. "remark": "new draft",
  190. },
  191. ],
  192. )
  193. by_id = {row.id: row for row in rows}
  194. assert by_id["locked"].due_amount == Decimal("1000")
  195. assert by_id["locked"].remark is None
  196. assert by_id["draft-update"].due_amount == Decimal("250")
  197. assert by_id["draft-update"].transfer_amount == Decimal("150")
  198. assert by_id["draft-update"].remark == "updated draft"
  199. new_rows = [row for row in rows if row.id not in {"locked", "draft-update"}]
  200. assert len(new_rows) == 1
  201. assert new_rows[0].pay_status == "dfj"
  202. deleted = db.query(BizProtocolPaymentDetail).filter_by(id="draft-delete").one()
  203. assert deleted.del_flag == 1
  204. def test_foreign_or_unknown_detail_id_is_rejected_without_mutation():
  205. db = _session()
  206. _payment(db)
  207. _detail(db)
  208. _payment(db, payment_id="payment-b", tenant_no="tenant-b")
  209. _detail(db, detail_id="detail-b", payment_id="payment-b", tenant_no="tenant-b")
  210. with pytest.raises(ValueError, match="缴费明细不存在或不属于当前缴费方案"):
  211. save_payment_details(
  212. db,
  213. payment_id="payment-a",
  214. tenant_no="tenant-a",
  215. user_id="market-user",
  216. details=[
  217. {
  218. "id": "detail-b",
  219. "dueAmount": 1000,
  220. "transferAmount": 1000,
  221. }
  222. ],
  223. )
  224. db.expire_all()
  225. own = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
  226. foreign = db.query(BizProtocolPaymentDetail).filter_by(id="detail-b").one()
  227. assert own.del_flag == 0
  228. assert own.pay_status == "ytf"
  229. assert foreign.del_flag == 0
  230. assert foreign.pay_status == "ytf"
  231. def test_final_effective_rows_reject_omitted_locked_row_plus_full_new_plan():
  232. db = _session()
  233. _payment(db, pay_mode="fq", contract_amount="2000", installment_count=2)
  234. _detail(db, pay_status="yjf", installment_no=1)
  235. with pytest.raises(ValueError, match="明细条数|无法切换分期类型"):
  236. save_payment_details(
  237. db,
  238. payment_id="payment-a",
  239. tenant_no="tenant-a",
  240. user_id="market-user",
  241. details=[
  242. {"installmentNo": 2, "dueAmount": 1000},
  243. {"installmentNo": 3, "dueAmount": 1000},
  244. ],
  245. )
  246. db.expire_all()
  247. locked = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
  248. assert locked.del_flag == 0
  249. assert locked.installment_no == 1
  250. assert locked.pay_status == "yjf"
  251. def test_final_effective_rows_use_locked_period_and_reject_duplicate_number():
  252. db = _session()
  253. _payment(db, pay_mode="fq", contract_amount="2000", installment_count=2)
  254. _detail(db, pay_status="yjf", installment_no=1)
  255. with pytest.raises(ValueError, match="期号.*重复"):
  256. save_payment_details(
  257. db,
  258. payment_id="payment-a",
  259. tenant_no="tenant-a",
  260. user_id="market-user",
  261. details=[
  262. {
  263. "id": "detail-a",
  264. "installmentNo": 2,
  265. "dueAmount": 1,
  266. },
  267. {"installmentNo": 1, "dueAmount": 1000},
  268. ],
  269. )
  270. db.expire_all()
  271. locked = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
  272. assert locked.installment_no == 1
  273. assert locked.due_amount == Decimal("1000")
  274. def test_save_payment_details_locks_payment_and_detail_rows(monkeypatch):
  275. db = _session()
  276. _payment(db)
  277. _detail(db, pay_status="dfj")
  278. calls = []
  279. original = Query.with_for_update
  280. def tracking_with_for_update(query, *args, **kwargs):
  281. calls.append(query)
  282. return original(query, *args, **kwargs)
  283. monkeypatch.setattr(Query, "with_for_update", tracking_with_for_update)
  284. save_payment_details(
  285. db,
  286. payment_id="payment-a",
  287. tenant_no="tenant-a",
  288. user_id="market-user",
  289. details=[{"id": "detail-a", "dueAmount": 1000}],
  290. )
  291. assert len(calls) >= 2
  292. def test_v1_aggregate_save_rolls_back_parent_when_locked_detail_validation_fails():
  293. db = _session()
  294. payment = _payment(
  295. db,
  296. pay_mode="fq",
  297. contract_amount="2000",
  298. installment_count=2,
  299. )
  300. payment.installment_interval_months = Decimal("12")
  301. payment.installment_start_date = "20261001"
  302. payment.installment_advance_days = 30
  303. payment.installment_ratios = "[500, 500]"
  304. payment.remark = "original plan"
  305. db.commit()
  306. _detail(
  307. db,
  308. detail_id="locked-period-2",
  309. pay_status="yjf",
  310. due_amount="1000",
  311. installment_no=2,
  312. )
  313. with pytest.raises(ValueError, match="明细条数|无法切换分期类型"):
  314. _persist_protocol_sub_resources(
  315. db,
  316. "protocol-a",
  317. "tenant-a",
  318. "market-user",
  319. party_customer=None,
  320. collection_info=None,
  321. samples=None,
  322. payment={
  323. "payMode": "djfq",
  324. "contractAmount": 700,
  325. "installmentCount": 1,
  326. "installmentIntervalMonths": 9,
  327. "installmentStartDate": "20270101",
  328. "installmentAdvanceDays": 15,
  329. "installmentRatios": [1000],
  330. "remark": "tampered plan",
  331. },
  332. payment_details=[],
  333. storage_cert=None,
  334. payment_model_version=1,
  335. )
  336. db.rollback()
  337. db.expire_all()
  338. saved_payment = db.get(BizProtocolPayment, "payment-a")
  339. assert saved_payment.contract_amount == Decimal("2000")
  340. assert saved_payment.pay_mode == "fq"
  341. assert saved_payment.installment_count == 2
  342. assert saved_payment.installment_interval_months == Decimal("12")
  343. assert saved_payment.installment_start_date == "20261001"
  344. assert saved_payment.installment_advance_days == 30
  345. assert saved_payment.installment_ratios == "[500, 500]"
  346. assert saved_payment.remark == "original plan"
  347. locked = db.get(BizProtocolPaymentDetail, "locked-period-2")
  348. assert locked.del_flag == 0
  349. assert locked.pay_status == "yjf"
  350. assert locked.installment_no == 2
  351. assert locked.due_amount == Decimal("1000")
  352. def test_v1_aggregate_passes_exact_max_amount_installments_to_persistence(monkeypatch):
  353. from app.crud import collection_protocol as collection_crud
  354. db = _session()
  355. payment = _payment(
  356. db,
  357. pay_mode="fq",
  358. contract_amount="100.00",
  359. installment_count=2,
  360. )
  361. payment.installment_ratios = None
  362. payment.installment_interval_months = Decimal("12")
  363. payment.installment_start_date = "20261001"
  364. db.commit()
  365. captured = {}
  366. def capture_details(
  367. _db,
  368. _payment_id,
  369. _tenant_no,
  370. _user_id,
  371. details,
  372. **_kwargs,
  373. ):
  374. captured["details"] = details
  375. return []
  376. monkeypatch.setattr(collection_crud, "save_payment_details", capture_details)
  377. save_payment_with_details(
  378. db,
  379. "protocol-a",
  380. "tenant-a",
  381. "market-user",
  382. payment={
  383. "pay_mode": "fq",
  384. "contract_amount": "9999999999999999.99",
  385. "installment_count": 2,
  386. "installment_interval_months": "12",
  387. "installment_start_date": "20261001",
  388. "installment_ratios": None,
  389. },
  390. payment_details=[],
  391. )
  392. assert [row["due_amount"] for row in captured["details"]] == [
  393. "4999999999999999.99",
  394. "5000000000000000",
  395. ]
  396. assert sum(
  397. Decimal(row["due_amount"]) for row in captured["details"]
  398. ) == Decimal("9999999999999999.99")
  399. def test_v1_aggregate_validates_drafts_against_remaining_contract_amount():
  400. db = _session()
  401. payment = _payment(
  402. db, pay_mode="fq", contract_amount="3000", installment_count=3
  403. )
  404. payment.installment_interval_months = Decimal("12")
  405. payment.installment_start_date = "20261001"
  406. payment.installment_advance_days = 30
  407. payment.installment_ratios = None
  408. db.commit()
  409. _detail(
  410. db, detail_id="paid-1", pay_status="yjf", due_amount="500",
  411. installment_no=1, installment_due_date="20261001",
  412. )
  413. _detail(
  414. db, detail_id="draft-2", pay_status="dfj", due_amount="1000",
  415. installment_no=2, installment_due_date="20270901",
  416. )
  417. _detail(
  418. db, detail_id="draft-3", pay_status="dfj", due_amount="1500",
  419. installment_no=3, installment_due_date="20280901",
  420. )
  421. saved = save_payment_with_details(
  422. db,
  423. "protocol-a",
  424. "tenant-a",
  425. "market-user",
  426. payment={
  427. "pay_mode": "fq",
  428. "contract_amount": "3000.00",
  429. "installment_count": 3,
  430. "installment_interval_months": "24",
  431. "installment_start_date": "20261001",
  432. "installment_advance_days": 30,
  433. "installment_ratios": None,
  434. },
  435. payment_details=[
  436. {
  437. "id": "paid-1", "installment_no": 1,
  438. "installment_due_date": "20261001", "due_amount": "500.00",
  439. },
  440. {
  441. "id": "draft-2", "installment_no": 2,
  442. "installment_due_date": "20280901", "due_amount": "1250.00",
  443. },
  444. {
  445. "id": "draft-3", "installment_no": 3,
  446. "installment_due_date": "20300901", "due_amount": "1250.00",
  447. },
  448. ],
  449. )
  450. assert saved.installment_interval_months == Decimal("24")
  451. rows = {
  452. row.id: row
  453. for row in db.query(BizProtocolPaymentDetail)
  454. .filter(BizProtocolPaymentDetail.del_flag == 0)
  455. .all()
  456. }
  457. assert rows["paid-1"].due_amount == Decimal("500")
  458. assert rows["draft-2"].due_amount == Decimal("1250")
  459. assert rows["draft-3"].due_amount == Decimal("1250")
  460. def test_v1_aggregate_uses_remaining_period_ratio_weights():
  461. db = _session()
  462. payment = _payment(
  463. db, pay_mode="fq", contract_amount="3000", installment_count=3
  464. )
  465. payment.installment_interval_months = Decimal("12")
  466. payment.installment_start_date = "20261001"
  467. payment.installment_advance_days = 30
  468. payment.installment_ratios = "[10, 45, 45]"
  469. db.commit()
  470. _detail(
  471. db, detail_id="ratio-paid-1", pay_status="yjf", due_amount="500",
  472. installment_no=1, installment_due_date="20261001",
  473. )
  474. _detail(
  475. db, detail_id="ratio-draft-2", pay_status="dfj", due_amount="1250",
  476. installment_no=2, installment_due_date="20270901",
  477. )
  478. _detail(
  479. db, detail_id="ratio-draft-3", pay_status="dfj", due_amount="1250",
  480. installment_no=3, installment_due_date="20280901",
  481. )
  482. save_payment_with_details(
  483. db,
  484. "protocol-a",
  485. "tenant-a",
  486. "market-user",
  487. payment={
  488. "pay_mode": "fq",
  489. "contract_amount": "3000.00",
  490. "installment_count": 3,
  491. "installment_interval_months": "12",
  492. "installment_start_date": "20261001",
  493. "installment_advance_days": 30,
  494. "installment_ratios": [10, 30, 60],
  495. },
  496. payment_details=[
  497. {
  498. "id": "ratio-paid-1", "installment_no": 1,
  499. "installment_due_date": "20261001", "due_amount": "500.00",
  500. },
  501. {
  502. "id": "ratio-draft-2", "installment_no": 2,
  503. "installment_due_date": "20270901", "due_amount": "833.33",
  504. },
  505. {
  506. "id": "ratio-draft-3", "installment_no": 3,
  507. "installment_due_date": "20280901", "due_amount": "1666.67",
  508. },
  509. ],
  510. )
  511. db.expire_all()
  512. assert db.get(BizProtocolPaymentDetail, "ratio-draft-2").due_amount == Decimal("833.33")
  513. assert db.get(BizProtocolPaymentDetail, "ratio-draft-3").due_amount == Decimal("1666.67")
  514. def test_locked_remaining_distribution_keeps_max_amount_cent_tail():
  515. from app.services.protocol_payment_installment import (
  516. resolve_locked_remaining_due_amounts,
  517. )
  518. amounts = resolve_locked_remaining_due_amounts(
  519. Decimal("9999999999999999.99"),
  520. Decimal("0.02"),
  521. [],
  522. [1, 2],
  523. )
  524. assert amounts == [
  525. Decimal("4999999999999999.98"),
  526. Decimal("4999999999999999.99"),
  527. ]
  528. assert sum(amounts, Decimal("0.02")) == Decimal("9999999999999999.99")
  529. def test_v1_aggregate_rejects_contract_below_locked_due_and_rolls_back():
  530. db = _session()
  531. payment = _payment(
  532. db, pay_mode="fq", contract_amount="3000", installment_count=2
  533. )
  534. payment.installment_interval_months = Decimal("12")
  535. payment.installment_start_date = "20261001"
  536. payment.installment_advance_days = 30
  537. db.commit()
  538. _detail(
  539. db, detail_id="locked-over-contract", pay_status="yjf",
  540. due_amount="500", installment_no=1,
  541. installment_due_date="20261001",
  542. )
  543. with pytest.raises(ValueError, match="合同金额不能小于"):
  544. save_payment_with_details(
  545. db,
  546. "protocol-a",
  547. "tenant-a",
  548. "market-user",
  549. payment={
  550. "pay_mode": "fq", "contract_amount": "400.00",
  551. "installment_count": 2, "installment_interval_months": "24",
  552. "installment_start_date": "20261001",
  553. "installment_advance_days": 30,
  554. "installment_ratios": None,
  555. },
  556. payment_details=[
  557. {
  558. "id": "locked-over-contract", "installment_no": 1,
  559. "installment_due_date": "20261001", "due_amount": "500.00",
  560. },
  561. {
  562. "installment_no": 2, "installment_due_date": "20280901",
  563. "due_amount": "0.00",
  564. },
  565. ],
  566. )
  567. db.expire_all()
  568. assert db.get(BizProtocolPayment, "payment-a").contract_amount == Decimal("3000")
  569. @pytest.mark.parametrize(
  570. ("draft_no", "draft_date", "message"),
  571. [
  572. (4, "20280901", "期号"),
  573. (2, "20270901", "应缴时间"),
  574. ],
  575. )
  576. def test_v1_aggregate_rejects_remaining_period_or_date_mismatch(
  577. draft_no, draft_date, message
  578. ):
  579. db = _session()
  580. payment = _payment(
  581. db, pay_mode="fq", contract_amount="3000", installment_count=3
  582. )
  583. payment.installment_interval_months = Decimal("12")
  584. payment.installment_start_date = "20261001"
  585. payment.installment_advance_days = 30
  586. payment.installment_ratios = None
  587. db.commit()
  588. _detail(
  589. db, detail_id="guard-paid-1", pay_status="yjf", due_amount="500",
  590. installment_no=1, installment_due_date="20261001",
  591. )
  592. _detail(
  593. db, detail_id="guard-draft-2", pay_status="dfj", due_amount="1250",
  594. installment_no=2, installment_due_date="20270901",
  595. )
  596. _detail(
  597. db, detail_id="guard-draft-3", pay_status="dfj", due_amount="1250",
  598. installment_no=3, installment_due_date="20280901",
  599. )
  600. with pytest.raises(ValueError, match=message):
  601. save_payment_with_details(
  602. db,
  603. "protocol-a",
  604. "tenant-a",
  605. "market-user",
  606. payment={
  607. "pay_mode": "fq", "contract_amount": "3000.00",
  608. "installment_count": 3, "installment_interval_months": "24",
  609. "installment_start_date": "20261001",
  610. "installment_advance_days": 30,
  611. "installment_ratios": None,
  612. },
  613. payment_details=[
  614. {
  615. "id": "guard-paid-1", "installment_no": 1,
  616. "installment_due_date": "20261001", "due_amount": "500.00",
  617. },
  618. {
  619. "id": "guard-draft-2", "installment_no": draft_no,
  620. "installment_due_date": draft_date, "due_amount": "1250.00",
  621. },
  622. {
  623. "id": "guard-draft-3", "installment_no": 3,
  624. "installment_due_date": "20300901", "due_amount": "1250.00",
  625. },
  626. ],
  627. )
  628. db.expire_all()
  629. assert db.get(BizProtocolPayment, "payment-a").installment_interval_months == Decimal("12")
  630. def test_v1_aggregate_rejects_invalid_all_draft_plan_and_rolls_back():
  631. db = _session()
  632. payment = _payment(
  633. db, pay_mode="fq", contract_amount="100", installment_count=2
  634. )
  635. payment.installment_interval_months = Decimal("1")
  636. payment.installment_start_date = "20260131"
  637. payment.installment_advance_days = 0
  638. payment.installment_ratios = None
  639. payment.remark = "original"
  640. db.commit()
  641. with pytest.raises(ValueError, match="期号|应缴金额|应缴时间"):
  642. save_payment_with_details(
  643. db,
  644. "protocol-a",
  645. "tenant-a",
  646. "market-user",
  647. payment={
  648. "pay_mode": "fq", "contract_amount": "100.00",
  649. "installment_count": 2, "installment_interval_months": "2",
  650. "installment_start_date": "20260131",
  651. "installment_advance_days": 0,
  652. "installment_ratios": None, "remark": "tampered",
  653. },
  654. payment_details=[
  655. {
  656. "installment_no": 99, "installment_due_date": "20000101",
  657. "due_amount": "1.00",
  658. },
  659. {
  660. "installment_no": 100, "installment_due_date": "19990101",
  661. "due_amount": "2.00",
  662. },
  663. ],
  664. )
  665. db.expire_all()
  666. saved = db.get(BizProtocolPayment, "payment-a")
  667. assert saved.installment_interval_months == Decimal("1")
  668. assert saved.remark == "original"
  669. assert db.query(BizProtocolPaymentDetail).count() == 0
  670. def test_v1_aggregate_accepts_complete_all_draft_plan_with_clamped_dates():
  671. db = _session()
  672. payment = _payment(
  673. db, pay_mode="fq", contract_amount="100", installment_count=2
  674. )
  675. payment.installment_interval_months = Decimal("1")
  676. payment.installment_start_date = "20260131"
  677. payment.installment_advance_days = 0
  678. payment.installment_ratios = None
  679. db.commit()
  680. save_payment_with_details(
  681. db,
  682. "protocol-a",
  683. "tenant-a",
  684. "market-user",
  685. payment={
  686. "pay_mode": "fq", "contract_amount": "100.00",
  687. "installment_count": 2, "installment_interval_months": "1",
  688. "installment_start_date": "20260131",
  689. "installment_advance_days": 0,
  690. "installment_ratios": None,
  691. },
  692. payment_details=[
  693. {
  694. "installment_no": 1, "installment_due_date": "20260131",
  695. "due_amount": "50.00",
  696. },
  697. {
  698. "installment_no": 2, "installment_due_date": "20260228",
  699. "due_amount": "50.00",
  700. },
  701. ],
  702. )
  703. rows = db.query(BizProtocolPaymentDetail).order_by(
  704. BizProtocolPaymentDetail.installment_no
  705. ).all()
  706. assert [row.installment_no for row in rows] == [1, 2]
  707. assert [row.installment_due_date for row in rows] == ["20260131", "20260228"]
  708. assert [row.due_amount for row in rows] == [Decimal("50"), Decimal("50")]
  709. def test_v1_aggregate_rejects_unknown_pay_mode_and_rolls_back():
  710. db = _session()
  711. payment = _payment(db, pay_mode="dj", contract_amount="100")
  712. payment.remark = "original"
  713. db.commit()
  714. with pytest.raises(ValueError, match="缴费模式"):
  715. save_payment_with_details(
  716. db,
  717. "protocol-a",
  718. "tenant-a",
  719. "market-user",
  720. payment={
  721. "pay_mode": "evil",
  722. "contract_amount": "1.00",
  723. "remark": "tampered",
  724. },
  725. payment_details=[],
  726. )
  727. db.expire_all()
  728. saved = db.get(BizProtocolPayment, "payment-a")
  729. assert saved.pay_mode == "dj"
  730. assert saved.contract_amount == Decimal("100")
  731. assert saved.remark == "original"
  732. @pytest.mark.parametrize(
  733. ("payment_patch", "detail_patch"),
  734. [
  735. ({"lump_sum_deadline": "20261399"}, {}),
  736. ({}, {"installment_due_date": "20230229"}),
  737. ],
  738. )
  739. def test_v1_aggregate_rejects_invalid_calendar_dates_and_rolls_back(
  740. payment_patch, detail_patch
  741. ):
  742. db = _session()
  743. payment = _payment(db, pay_mode="dj", contract_amount="100")
  744. payment.lump_sum_deadline = "20261231"
  745. payment.remark = "original"
  746. db.commit()
  747. with pytest.raises(ValueError, match="日期"):
  748. save_payment_with_details(
  749. db,
  750. "protocol-a",
  751. "tenant-a",
  752. "market-user",
  753. payment={
  754. "pay_mode": "dj",
  755. "contract_amount": "100.00",
  756. "lump_sum_deadline": "20261231",
  757. "remark": "tampered",
  758. **payment_patch,
  759. },
  760. payment_details=[{
  761. "installment_due_date": "20261231",
  762. "due_amount": "100.00",
  763. **detail_patch,
  764. }],
  765. )
  766. db.expire_all()
  767. saved = db.get(BizProtocolPayment, "payment-a")
  768. assert saved.lump_sum_deadline == "20261231"
  769. assert saved.remark == "original"
  770. assert db.query(BizProtocolPaymentDetail).count() == 0