锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
10 KiB
Python
368 lines
10 KiB
Python
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Query, sessionmaker
|
|
|
|
from app.crud.protocol_sample_payment import (
|
|
replace_schedules,
|
|
save_sample_payments_for_protocol,
|
|
sync_schedules_and_transfers,
|
|
)
|
|
from app.models.base import Base
|
|
from app.models.models import (
|
|
BizProtocolPaymentSchedule,
|
|
BizProtocolPaymentTransfer,
|
|
BizProtocolSamplePayment,
|
|
)
|
|
|
|
|
|
def _session():
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[
|
|
BizProtocolSamplePayment.__table__,
|
|
BizProtocolPaymentSchedule.__table__,
|
|
BizProtocolPaymentTransfer.__table__,
|
|
],
|
|
)
|
|
return sessionmaker(bind=engine)()
|
|
|
|
|
|
def _payment(db):
|
|
row = BizProtocolSamplePayment(
|
|
id="pay-1",
|
|
collection_protocol_id="protocol-1",
|
|
sample_code="S1",
|
|
pay_mode="fq",
|
|
contract_amount=Decimal("100"),
|
|
tenant_no="tenant-a",
|
|
create_dept_id="",
|
|
create_time=datetime(2026, 10, 14),
|
|
update_time=datetime(2026, 10, 14),
|
|
del_flag=0,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _schedule(db, *, schedule_no=1, status="cleared"):
|
|
row = BizProtocolPaymentSchedule(
|
|
id=f"schedule-{schedule_no}",
|
|
sample_payment_id="pay-1",
|
|
collection_protocol_id="protocol-1",
|
|
sample_code="S1",
|
|
schedule_no=schedule_no,
|
|
due_amount=Decimal("100"),
|
|
due_date="20261031",
|
|
schedule_status=status,
|
|
tenant_no="tenant-a",
|
|
create_dept_id="",
|
|
create_time=datetime(2026, 10, 14),
|
|
update_time=datetime(2026, 10, 14),
|
|
del_flag=0,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _transfer(db, *, schedule_no=1, status="yjf"):
|
|
row = BizProtocolPaymentTransfer(
|
|
id=f"transfer-{schedule_no}",
|
|
schedule_id=f"schedule-{schedule_no}",
|
|
sample_payment_id="pay-1",
|
|
collection_protocol_id="protocol-1",
|
|
sample_code="S1",
|
|
transfer_amount=Decimal("100"),
|
|
transfer_type="bank",
|
|
pay_status=status,
|
|
tenant_no="tenant-a",
|
|
create_dept_id="",
|
|
create_time=datetime(2026, 10, 14),
|
|
update_time=datetime(2026, 10, 14),
|
|
del_flag=0,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def test_existing_schedule_status_is_not_overwritten_by_payload():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="cleared")
|
|
|
|
rows = sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[
|
|
{
|
|
"scheduleNo": 1,
|
|
"dueAmount": 100,
|
|
"dueDate": "20261130",
|
|
"scheduleStatus": "void",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert rows[0].schedule_status == "cleared"
|
|
|
|
|
|
@pytest.mark.parametrize("status", [None, "", " "])
|
|
def test_non_open_blank_schedule_status_is_preserved(status):
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status=status)
|
|
|
|
rows = sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[{"scheduleNo": 1, "dueAmount": 100}],
|
|
)
|
|
|
|
assert rows[0].schedule_status == status
|
|
|
|
|
|
def test_new_schedule_ignores_payload_status_and_defaults_open():
|
|
db = _session()
|
|
_payment(db)
|
|
|
|
rows = sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[
|
|
{
|
|
"scheduleNo": 1,
|
|
"dueAmount": 100,
|
|
"dueDate": "20261031",
|
|
"scheduleStatus": "cleared",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert rows[0].schedule_status == "open"
|
|
|
|
|
|
def test_structure_change_rejects_renumbering_locked_schedule():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, schedule_no=1, status="cleared")
|
|
|
|
with pytest.raises(ValueError, match="锁定批次"):
|
|
sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[
|
|
{
|
|
"scheduleNo": 2,
|
|
"dueAmount": 100,
|
|
"dueDate": "20261130",
|
|
}
|
|
],
|
|
)
|
|
|
|
|
|
def test_locked_schedule_cannot_be_omitted():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="cleared")
|
|
|
|
with pytest.raises(ValueError, match="锁定批次"):
|
|
sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[],
|
|
)
|
|
|
|
db.expire_all()
|
|
assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 0
|
|
|
|
|
|
@pytest.mark.parametrize("pay_status", ["jfqrz", "yjf"])
|
|
@pytest.mark.parametrize("payload", [[], [{"scheduleNo": 2, "dueAmount": 100}]])
|
|
def test_locked_transfer_schedule_cannot_be_omitted_or_renumbered(pay_status, payload):
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="open")
|
|
_transfer(db, status=pay_status)
|
|
|
|
with pytest.raises(ValueError, match="锁定批次"):
|
|
sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=payload,
|
|
)
|
|
|
|
db.expire_all()
|
|
transfer = db.get(BizProtocolPaymentTransfer, "transfer-1")
|
|
assert transfer.schedule_id == "schedule-1"
|
|
assert transfer.pay_status == pay_status
|
|
assert transfer.del_flag == 0
|
|
|
|
|
|
def test_replace_schedules_never_falls_back_locked_transfer_to_first_new_period():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="open")
|
|
_transfer(db, status="yjf")
|
|
|
|
with pytest.raises(ValueError, match="锁定批次"):
|
|
replace_schedules(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[{"scheduleNo": 2, "dueAmount": 100}],
|
|
)
|
|
|
|
db.expire_all()
|
|
assert db.get(BizProtocolPaymentTransfer, "transfer-1").schedule_id == "schedule-1"
|
|
|
|
|
|
def test_locked_transfer_id_cannot_be_submitted_under_another_schedule():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, schedule_no=1, status="open")
|
|
_schedule(db, schedule_no=2, status="open")
|
|
_transfer(db, schedule_no=1, status="yjf")
|
|
|
|
with pytest.raises(ValueError, match="转账笔.*批次"):
|
|
sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[
|
|
{"scheduleNo": 1, "dueAmount": 50, "transfers": []},
|
|
{
|
|
"scheduleNo": 2,
|
|
"dueAmount": 50,
|
|
"transfers": [{"id": "transfer-1", "transferAmount": 999}],
|
|
},
|
|
],
|
|
)
|
|
|
|
db.expire_all()
|
|
transfer = db.get(BizProtocolPaymentTransfer, "transfer-1")
|
|
assert transfer.schedule_id == "schedule-1"
|
|
assert transfer.transfer_amount == Decimal("100")
|
|
|
|
|
|
def test_locked_sample_payment_cannot_be_omitted_from_protocol_save():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="cleared")
|
|
|
|
with pytest.raises(ValueError, match="锁定缴费方案"):
|
|
save_sample_payments_for_protocol(
|
|
db,
|
|
collection_protocol_id="protocol-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
sample_payments=[],
|
|
)
|
|
|
|
db.expire_all()
|
|
assert db.get(BizProtocolSamplePayment, "pay-1").del_flag == 0
|
|
assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 0
|
|
|
|
|
|
def test_save_sample_payments_commit_false_defers_commit(monkeypatch):
|
|
"""commit=False 时成功路径不得自行 db.commit。"""
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="open")
|
|
_transfer(db, status="dfj")
|
|
|
|
commits: list = []
|
|
real_commit = db.commit
|
|
|
|
def tracking_commit(*args, **kwargs):
|
|
commits.append(1)
|
|
return real_commit(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(db, "commit", tracking_commit)
|
|
|
|
save_sample_payments_for_protocol(
|
|
db,
|
|
collection_protocol_id="protocol-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
sample_payments=[
|
|
{
|
|
"id": "pay-1",
|
|
"sampleCode": "S1",
|
|
"payMode": "fq",
|
|
"contractAmount": 100,
|
|
"schedules": [
|
|
{"scheduleNo": 1, "dueAmount": 100, "dueDate": "20261031"}
|
|
],
|
|
}
|
|
],
|
|
commit=False,
|
|
)
|
|
|
|
assert commits == []
|
|
db.expire_all()
|
|
assert db.get(BizProtocolSamplePayment, "pay-1").update_by == "u1"
|
|
|
|
|
|
def test_all_draft_structure_can_still_be_rebuilt():
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="open")
|
|
_transfer(db, status="dfj")
|
|
|
|
rows = sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[{"scheduleNo": 2, "dueAmount": 100}],
|
|
)
|
|
|
|
assert [row.schedule_no for row in rows] == [2]
|
|
db.expire_all()
|
|
assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 1
|
|
assert db.get(BizProtocolPaymentTransfer, "transfer-1").del_flag == 1
|
|
|
|
|
|
def test_schedule_sync_locks_payment_schedule_and_transfer_rows(monkeypatch):
|
|
db = _session()
|
|
_payment(db)
|
|
_schedule(db, status="open")
|
|
_transfer(db, status="dfj")
|
|
calls = []
|
|
original = Query.with_for_update
|
|
|
|
def tracking_with_for_update(query, *args, **kwargs):
|
|
calls.append(query)
|
|
return original(query, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Query, "with_for_update", tracking_with_for_update)
|
|
sync_schedules_and_transfers(
|
|
db,
|
|
sample_payment_id="pay-1",
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
schedules=[{"scheduleNo": 1, "dueAmount": 100}],
|
|
)
|
|
|
|
assert len(calls) >= 3
|