test_protocol_sample_payment_status_guard.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. from datetime import datetime
  2. from decimal import Decimal
  3. import pytest
  4. from sqlalchemy import create_engine
  5. from sqlalchemy.orm import Query, sessionmaker
  6. from app.crud.protocol_sample_payment import (
  7. replace_schedules,
  8. save_sample_payments_for_protocol,
  9. sync_schedules_and_transfers,
  10. )
  11. from app.models.base import Base
  12. from app.models.models import (
  13. BizProtocolPaymentSchedule,
  14. BizProtocolPaymentTransfer,
  15. BizProtocolSamplePayment,
  16. )
  17. def _session():
  18. engine = create_engine("sqlite:///:memory:")
  19. Base.metadata.create_all(
  20. engine,
  21. tables=[
  22. BizProtocolSamplePayment.__table__,
  23. BizProtocolPaymentSchedule.__table__,
  24. BizProtocolPaymentTransfer.__table__,
  25. ],
  26. )
  27. return sessionmaker(bind=engine)()
  28. def _payment(db):
  29. row = BizProtocolSamplePayment(
  30. id="pay-1",
  31. collection_protocol_id="protocol-1",
  32. sample_code="S1",
  33. pay_mode="fq",
  34. contract_amount=Decimal("100"),
  35. tenant_no="tenant-a",
  36. create_dept_id="",
  37. create_time=datetime(2026, 10, 14),
  38. update_time=datetime(2026, 10, 14),
  39. del_flag=0,
  40. )
  41. db.add(row)
  42. db.commit()
  43. return row
  44. def _schedule(db, *, schedule_no=1, status="cleared"):
  45. row = BizProtocolPaymentSchedule(
  46. id=f"schedule-{schedule_no}",
  47. sample_payment_id="pay-1",
  48. collection_protocol_id="protocol-1",
  49. sample_code="S1",
  50. schedule_no=schedule_no,
  51. due_amount=Decimal("100"),
  52. due_date="20261031",
  53. schedule_status=status,
  54. tenant_no="tenant-a",
  55. create_dept_id="",
  56. create_time=datetime(2026, 10, 14),
  57. update_time=datetime(2026, 10, 14),
  58. del_flag=0,
  59. )
  60. db.add(row)
  61. db.commit()
  62. return row
  63. def _transfer(db, *, schedule_no=1, status="yjf"):
  64. row = BizProtocolPaymentTransfer(
  65. id=f"transfer-{schedule_no}",
  66. schedule_id=f"schedule-{schedule_no}",
  67. sample_payment_id="pay-1",
  68. collection_protocol_id="protocol-1",
  69. sample_code="S1",
  70. transfer_amount=Decimal("100"),
  71. transfer_type="bank",
  72. pay_status=status,
  73. tenant_no="tenant-a",
  74. create_dept_id="",
  75. create_time=datetime(2026, 10, 14),
  76. update_time=datetime(2026, 10, 14),
  77. del_flag=0,
  78. )
  79. db.add(row)
  80. db.commit()
  81. return row
  82. def test_existing_schedule_status_is_not_overwritten_by_payload():
  83. db = _session()
  84. _payment(db)
  85. _schedule(db, status="cleared")
  86. rows = sync_schedules_and_transfers(
  87. db,
  88. sample_payment_id="pay-1",
  89. tenant_no="tenant-a",
  90. user_id="u1",
  91. schedules=[
  92. {
  93. "scheduleNo": 1,
  94. "dueAmount": 100,
  95. "dueDate": "20261130",
  96. "scheduleStatus": "void",
  97. }
  98. ],
  99. )
  100. assert rows[0].schedule_status == "cleared"
  101. @pytest.mark.parametrize("status", [None, "", " "])
  102. def test_non_open_blank_schedule_status_is_preserved(status):
  103. db = _session()
  104. _payment(db)
  105. _schedule(db, status=status)
  106. rows = sync_schedules_and_transfers(
  107. db,
  108. sample_payment_id="pay-1",
  109. tenant_no="tenant-a",
  110. user_id="u1",
  111. schedules=[{"scheduleNo": 1, "dueAmount": 100}],
  112. )
  113. assert rows[0].schedule_status == status
  114. def test_new_schedule_ignores_payload_status_and_defaults_open():
  115. db = _session()
  116. _payment(db)
  117. rows = sync_schedules_and_transfers(
  118. db,
  119. sample_payment_id="pay-1",
  120. tenant_no="tenant-a",
  121. user_id="u1",
  122. schedules=[
  123. {
  124. "scheduleNo": 1,
  125. "dueAmount": 100,
  126. "dueDate": "20261031",
  127. "scheduleStatus": "cleared",
  128. }
  129. ],
  130. )
  131. assert rows[0].schedule_status == "open"
  132. def test_structure_change_rejects_renumbering_locked_schedule():
  133. db = _session()
  134. _payment(db)
  135. _schedule(db, schedule_no=1, status="cleared")
  136. with pytest.raises(ValueError, match="锁定批次"):
  137. sync_schedules_and_transfers(
  138. db,
  139. sample_payment_id="pay-1",
  140. tenant_no="tenant-a",
  141. user_id="u1",
  142. schedules=[
  143. {
  144. "scheduleNo": 2,
  145. "dueAmount": 100,
  146. "dueDate": "20261130",
  147. }
  148. ],
  149. )
  150. def test_locked_schedule_cannot_be_omitted():
  151. db = _session()
  152. _payment(db)
  153. _schedule(db, status="cleared")
  154. with pytest.raises(ValueError, match="锁定批次"):
  155. sync_schedules_and_transfers(
  156. db,
  157. sample_payment_id="pay-1",
  158. tenant_no="tenant-a",
  159. user_id="u1",
  160. schedules=[],
  161. )
  162. db.expire_all()
  163. assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 0
  164. @pytest.mark.parametrize("pay_status", ["jfqrz", "yjf"])
  165. @pytest.mark.parametrize("payload", [[], [{"scheduleNo": 2, "dueAmount": 100}]])
  166. def test_locked_transfer_schedule_cannot_be_omitted_or_renumbered(pay_status, payload):
  167. db = _session()
  168. _payment(db)
  169. _schedule(db, status="open")
  170. _transfer(db, status=pay_status)
  171. with pytest.raises(ValueError, match="锁定批次"):
  172. sync_schedules_and_transfers(
  173. db,
  174. sample_payment_id="pay-1",
  175. tenant_no="tenant-a",
  176. user_id="u1",
  177. schedules=payload,
  178. )
  179. db.expire_all()
  180. transfer = db.get(BizProtocolPaymentTransfer, "transfer-1")
  181. assert transfer.schedule_id == "schedule-1"
  182. assert transfer.pay_status == pay_status
  183. assert transfer.del_flag == 0
  184. def test_replace_schedules_never_falls_back_locked_transfer_to_first_new_period():
  185. db = _session()
  186. _payment(db)
  187. _schedule(db, status="open")
  188. _transfer(db, status="yjf")
  189. with pytest.raises(ValueError, match="锁定批次"):
  190. replace_schedules(
  191. db,
  192. sample_payment_id="pay-1",
  193. tenant_no="tenant-a",
  194. user_id="u1",
  195. schedules=[{"scheduleNo": 2, "dueAmount": 100}],
  196. )
  197. db.expire_all()
  198. assert db.get(BizProtocolPaymentTransfer, "transfer-1").schedule_id == "schedule-1"
  199. def test_locked_transfer_id_cannot_be_submitted_under_another_schedule():
  200. db = _session()
  201. _payment(db)
  202. _schedule(db, schedule_no=1, status="open")
  203. _schedule(db, schedule_no=2, status="open")
  204. _transfer(db, schedule_no=1, status="yjf")
  205. with pytest.raises(ValueError, match="转账笔.*批次"):
  206. sync_schedules_and_transfers(
  207. db,
  208. sample_payment_id="pay-1",
  209. tenant_no="tenant-a",
  210. user_id="u1",
  211. schedules=[
  212. {"scheduleNo": 1, "dueAmount": 50, "transfers": []},
  213. {
  214. "scheduleNo": 2,
  215. "dueAmount": 50,
  216. "transfers": [{"id": "transfer-1", "transferAmount": 999}],
  217. },
  218. ],
  219. )
  220. db.expire_all()
  221. transfer = db.get(BizProtocolPaymentTransfer, "transfer-1")
  222. assert transfer.schedule_id == "schedule-1"
  223. assert transfer.transfer_amount == Decimal("100")
  224. def test_locked_sample_payment_cannot_be_omitted_from_protocol_save():
  225. db = _session()
  226. _payment(db)
  227. _schedule(db, status="cleared")
  228. with pytest.raises(ValueError, match="锁定缴费方案"):
  229. save_sample_payments_for_protocol(
  230. db,
  231. collection_protocol_id="protocol-1",
  232. tenant_no="tenant-a",
  233. user_id="u1",
  234. sample_payments=[],
  235. )
  236. db.expire_all()
  237. assert db.get(BizProtocolSamplePayment, "pay-1").del_flag == 0
  238. assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 0
  239. def test_save_sample_payments_commit_false_defers_commit(monkeypatch):
  240. """commit=False 时成功路径不得自行 db.commit。"""
  241. db = _session()
  242. _payment(db)
  243. _schedule(db, status="open")
  244. _transfer(db, status="dfj")
  245. commits: list = []
  246. real_commit = db.commit
  247. def tracking_commit(*args, **kwargs):
  248. commits.append(1)
  249. return real_commit(*args, **kwargs)
  250. monkeypatch.setattr(db, "commit", tracking_commit)
  251. save_sample_payments_for_protocol(
  252. db,
  253. collection_protocol_id="protocol-1",
  254. tenant_no="tenant-a",
  255. user_id="u1",
  256. sample_payments=[
  257. {
  258. "id": "pay-1",
  259. "sampleCode": "S1",
  260. "payMode": "fq",
  261. "contractAmount": 100,
  262. "schedules": [
  263. {"scheduleNo": 1, "dueAmount": 100, "dueDate": "20261031"}
  264. ],
  265. }
  266. ],
  267. commit=False,
  268. )
  269. assert commits == []
  270. db.expire_all()
  271. assert db.get(BizProtocolSamplePayment, "pay-1").update_by == "u1"
  272. def test_all_draft_structure_can_still_be_rebuilt():
  273. db = _session()
  274. _payment(db)
  275. _schedule(db, status="open")
  276. _transfer(db, status="dfj")
  277. rows = sync_schedules_and_transfers(
  278. db,
  279. sample_payment_id="pay-1",
  280. tenant_no="tenant-a",
  281. user_id="u1",
  282. schedules=[{"scheduleNo": 2, "dueAmount": 100}],
  283. )
  284. assert [row.schedule_no for row in rows] == [2]
  285. db.expire_all()
  286. assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 1
  287. assert db.get(BizProtocolPaymentTransfer, "transfer-1").del_flag == 1
  288. def test_schedule_sync_locks_payment_schedule_and_transfer_rows(monkeypatch):
  289. db = _session()
  290. _payment(db)
  291. _schedule(db, status="open")
  292. _transfer(db, status="dfj")
  293. calls = []
  294. original = Query.with_for_update
  295. def tracking_with_for_update(query, *args, **kwargs):
  296. calls.append(query)
  297. return original(query, *args, **kwargs)
  298. monkeypatch.setattr(Query, "with_for_update", tracking_with_for_update)
  299. sync_schedules_and_transfers(
  300. db,
  301. sample_payment_id="pay-1",
  302. tenant_no="tenant-a",
  303. user_id="u1",
  304. schedules=[{"scheduleNo": 1, "dueAmount": 100}],
  305. )
  306. assert len(calls) >= 3