Files
zkxw/backend/tests/test_protocol_payment_detail_status_guard.py
T
2026-10-14 12:32:03 +08:00

856 lines
27 KiB
Python

from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, update
from sqlalchemy.orm import Query, sessionmaker
from app.api.collection_protocol import _persist_protocol_sub_resources
from app.crud.collection_protocol import save_payment_details, save_payment_with_details
from app.models.base import Base
from app.models.models import BizProtocolPayment, BizProtocolPaymentDetail
def _session(*, allow_null_pay_status=False):
engine = create_engine("sqlite:///:memory:")
pay_status_column = BizProtocolPaymentDetail.__table__.c.pay_status
original_nullable = pay_status_column.nullable
try:
if allow_null_pay_status:
pay_status_column.nullable = True
Base.metadata.create_all(
engine,
tables=[BizProtocolPayment.__table__, BizProtocolPaymentDetail.__table__],
)
finally:
pay_status_column.nullable = original_nullable
return sessionmaker(bind=engine)()
def _payment(
db,
*,
payment_id="payment-a",
tenant_no="tenant-a",
pay_mode="dj",
contract_amount="1000",
installment_count=None,
):
row = BizProtocolPayment(
id=payment_id,
collection_protocol_id="protocol-a",
pay_mode=pay_mode,
contract_amount=Decimal(contract_amount),
installment_count=installment_count,
installment_ratios="[1000, 1000]" if installment_count == 2 else None,
tenant_no=tenant_no,
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 _detail(
db,
*,
detail_id="detail-a",
payment_id="payment-a",
tenant_no="tenant-a",
pay_status="ytf",
due_amount="1000",
installment_no=None,
installment_due_date=None,
):
row = BizProtocolPaymentDetail(
id=detail_id,
payment_id=payment_id,
transfer_type="bank",
due_amount=Decimal(due_amount),
transfer_amount=Decimal(due_amount),
transfer_time=datetime(2026, 10, 1, 10, 0, 0),
transfer_attachments="receipt.pdf",
finance_confirm_attachments="finance.pdf",
finance_remark="confirmed",
refund_finance_confirm_attachments="refund-confirm.pdf",
refund_finance_remark="refund confirmed",
pay_status=pay_status,
installment_no=installment_no,
installment_due_date=installment_due_date,
refund_amount=Decimal("100"),
refund_remark="refund requested",
refund_attachments="refund.pdf",
tenant_no=tenant_no,
create_dept_id="",
create_time=datetime(2026, 10, 14),
update_time=datetime(2026, 10, 14),
del_flag=0,
)
db.add(row)
db.commit()
if pay_status is None:
db.execute(
update(BizProtocolPaymentDetail)
.where(BizProtocolPaymentDetail.id == detail_id)
.values(pay_status=None)
)
db.commit()
db.refresh(row)
return row
@pytest.mark.parametrize(
"pay_status", ["jfqrz", "yjf", "tfqrz", "ytf", "future_locked", None, "", " ", " dfj "]
)
def test_locked_detail_is_preserved_when_omitted(pay_status):
db = _session(allow_null_pay_status=pay_status is None)
_payment(db)
_detail(db, pay_status=pay_status)
rows = save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[],
)
assert len(rows) == 1
row = rows[0]
assert row.id == "detail-a"
assert row.del_flag == 0
assert row.pay_status == pay_status
@pytest.mark.parametrize(
"pay_status", ["jfqrz", "yjf", "tfqrz", "ytf", "future_locked", None, "", " ", " dfj "]
)
def test_locked_detail_is_immutable_when_submitted_by_id(pay_status):
db = _session(allow_null_pay_status=pay_status is None)
_payment(db)
_detail(db, pay_status=pay_status)
rows = save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[
{
"id": "detail-a",
"transferType": "cash",
"dueAmount": 1,
"transferAmount": 2,
"transferTime": "2026-12-31 23:59:59",
"installmentNo": 9,
"installmentDueDate": "20261231",
"remark": "tampered",
"attachments": [{"fileUrl": "tampered.pdf"}],
}
],
)
assert len(rows) == 1
row = rows[0]
assert row.id == "detail-a"
assert row.pay_status == pay_status
assert row.transfer_type == "bank"
assert row.due_amount == Decimal("1000")
assert row.transfer_amount == Decimal("1000")
assert row.transfer_time == datetime(2026, 10, 1, 10, 0, 0)
assert row.installment_no is None
assert row.installment_due_date is None
assert row.remark is None
assert row.transfer_attachments == "receipt.pdf"
assert row.finance_confirm_attachments == "finance.pdf"
assert row.finance_remark == "confirmed"
assert row.refund_amount == Decimal("100")
assert row.refund_remark == "refund requested"
assert row.refund_attachments == "refund.pdf"
assert row.refund_finance_confirm_attachments == "refund-confirm.pdf"
assert row.refund_finance_remark == "refund confirmed"
def test_mixed_save_updates_and_deletes_only_drafts_and_creates_safe_row():
db = _session()
_payment(db)
_detail(db, detail_id="locked", pay_status="yjf")
_detail(db, detail_id="draft-update", pay_status="dfj", due_amount="200")
_detail(db, detail_id="draft-delete", pay_status="dfj", due_amount="300")
rows = save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[
{
"id": "locked",
"dueAmount": 1,
"transferAmount": 1,
"remark": "tampered",
},
{
"id": "draft-update",
"transferType": "cash",
"dueAmount": 250,
"transferAmount": 150,
"transferTime": "2026-10-20 12:00:00",
"remark": "updated draft",
"attachments": [{"fileUrl": "draft-new.pdf"}],
},
{
"dueAmount": 500,
"transferAmount": 0,
"remark": "new draft",
},
],
)
by_id = {row.id: row for row in rows}
assert by_id["locked"].due_amount == Decimal("1000")
assert by_id["locked"].remark is None
assert by_id["draft-update"].due_amount == Decimal("250")
assert by_id["draft-update"].transfer_amount == Decimal("150")
assert by_id["draft-update"].remark == "updated draft"
new_rows = [row for row in rows if row.id not in {"locked", "draft-update"}]
assert len(new_rows) == 1
assert new_rows[0].pay_status == "dfj"
deleted = db.query(BizProtocolPaymentDetail).filter_by(id="draft-delete").one()
assert deleted.del_flag == 1
def test_foreign_or_unknown_detail_id_is_rejected_without_mutation():
db = _session()
_payment(db)
_detail(db)
_payment(db, payment_id="payment-b", tenant_no="tenant-b")
_detail(db, detail_id="detail-b", payment_id="payment-b", tenant_no="tenant-b")
with pytest.raises(ValueError, match="缴费明细不存在或不属于当前缴费方案"):
save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[
{
"id": "detail-b",
"dueAmount": 1000,
"transferAmount": 1000,
}
],
)
db.expire_all()
own = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
foreign = db.query(BizProtocolPaymentDetail).filter_by(id="detail-b").one()
assert own.del_flag == 0
assert own.pay_status == "ytf"
assert foreign.del_flag == 0
assert foreign.pay_status == "ytf"
def test_final_effective_rows_reject_omitted_locked_row_plus_full_new_plan():
db = _session()
_payment(db, pay_mode="fq", contract_amount="2000", installment_count=2)
_detail(db, pay_status="yjf", installment_no=1)
with pytest.raises(ValueError, match="明细条数|无法切换分期类型"):
save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[
{"installmentNo": 2, "dueAmount": 1000},
{"installmentNo": 3, "dueAmount": 1000},
],
)
db.expire_all()
locked = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
assert locked.del_flag == 0
assert locked.installment_no == 1
assert locked.pay_status == "yjf"
def test_final_effective_rows_use_locked_period_and_reject_duplicate_number():
db = _session()
_payment(db, pay_mode="fq", contract_amount="2000", installment_count=2)
_detail(db, pay_status="yjf", installment_no=1)
with pytest.raises(ValueError, match="期号.*重复"):
save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[
{
"id": "detail-a",
"installmentNo": 2,
"dueAmount": 1,
},
{"installmentNo": 1, "dueAmount": 1000},
],
)
db.expire_all()
locked = db.query(BizProtocolPaymentDetail).filter_by(id="detail-a").one()
assert locked.installment_no == 1
assert locked.due_amount == Decimal("1000")
def test_save_payment_details_locks_payment_and_detail_rows(monkeypatch):
db = _session()
_payment(db)
_detail(db, pay_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)
save_payment_details(
db,
payment_id="payment-a",
tenant_no="tenant-a",
user_id="market-user",
details=[{"id": "detail-a", "dueAmount": 1000}],
)
assert len(calls) >= 2
def test_v1_aggregate_save_rolls_back_parent_when_locked_detail_validation_fails():
db = _session()
payment = _payment(
db,
pay_mode="fq",
contract_amount="2000",
installment_count=2,
)
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
payment.installment_advance_days = 30
payment.installment_ratios = "[500, 500]"
payment.remark = "original plan"
db.commit()
_detail(
db,
detail_id="locked-period-2",
pay_status="yjf",
due_amount="1000",
installment_no=2,
)
with pytest.raises(ValueError, match="明细条数|无法切换分期类型"):
_persist_protocol_sub_resources(
db,
"protocol-a",
"tenant-a",
"market-user",
party_customer=None,
collection_info=None,
samples=None,
payment={
"payMode": "djfq",
"contractAmount": 700,
"installmentCount": 1,
"installmentIntervalMonths": 9,
"installmentStartDate": "20270101",
"installmentAdvanceDays": 15,
"installmentRatios": [1000],
"remark": "tampered plan",
},
payment_details=[],
storage_cert=None,
payment_model_version=1,
)
db.rollback()
db.expire_all()
saved_payment = db.get(BizProtocolPayment, "payment-a")
assert saved_payment.contract_amount == Decimal("2000")
assert saved_payment.pay_mode == "fq"
assert saved_payment.installment_count == 2
assert saved_payment.installment_interval_months == Decimal("12")
assert saved_payment.installment_start_date == "20261001"
assert saved_payment.installment_advance_days == 30
assert saved_payment.installment_ratios == "[500, 500]"
assert saved_payment.remark == "original plan"
locked = db.get(BizProtocolPaymentDetail, "locked-period-2")
assert locked.del_flag == 0
assert locked.pay_status == "yjf"
assert locked.installment_no == 2
assert locked.due_amount == Decimal("1000")
def test_v1_aggregate_passes_exact_max_amount_installments_to_persistence(monkeypatch):
from app.crud import collection_protocol as collection_crud
db = _session()
payment = _payment(
db,
pay_mode="fq",
contract_amount="100.00",
installment_count=2,
)
payment.installment_ratios = None
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
db.commit()
captured = {}
def capture_details(
_db,
_payment_id,
_tenant_no,
_user_id,
details,
**_kwargs,
):
captured["details"] = details
return []
monkeypatch.setattr(collection_crud, "save_payment_details", capture_details)
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq",
"contract_amount": "9999999999999999.99",
"installment_count": 2,
"installment_interval_months": "12",
"installment_start_date": "20261001",
"installment_ratios": None,
},
payment_details=[],
)
assert [row["due_amount"] for row in captured["details"]] == [
"4999999999999999.99",
"5000000000000000",
]
assert sum(
Decimal(row["due_amount"]) for row in captured["details"]
) == Decimal("9999999999999999.99")
def test_v1_aggregate_validates_drafts_against_remaining_contract_amount():
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="3000", installment_count=3
)
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
payment.installment_advance_days = 30
payment.installment_ratios = None
db.commit()
_detail(
db, detail_id="paid-1", pay_status="yjf", due_amount="500",
installment_no=1, installment_due_date="20261001",
)
_detail(
db, detail_id="draft-2", pay_status="dfj", due_amount="1000",
installment_no=2, installment_due_date="20270901",
)
_detail(
db, detail_id="draft-3", pay_status="dfj", due_amount="1500",
installment_no=3, installment_due_date="20280901",
)
saved = save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq",
"contract_amount": "3000.00",
"installment_count": 3,
"installment_interval_months": "24",
"installment_start_date": "20261001",
"installment_advance_days": 30,
"installment_ratios": None,
},
payment_details=[
{
"id": "paid-1", "installment_no": 1,
"installment_due_date": "20261001", "due_amount": "500.00",
},
{
"id": "draft-2", "installment_no": 2,
"installment_due_date": "20280901", "due_amount": "1250.00",
},
{
"id": "draft-3", "installment_no": 3,
"installment_due_date": "20300901", "due_amount": "1250.00",
},
],
)
assert saved.installment_interval_months == Decimal("24")
rows = {
row.id: row
for row in db.query(BizProtocolPaymentDetail)
.filter(BizProtocolPaymentDetail.del_flag == 0)
.all()
}
assert rows["paid-1"].due_amount == Decimal("500")
assert rows["draft-2"].due_amount == Decimal("1250")
assert rows["draft-3"].due_amount == Decimal("1250")
def test_v1_aggregate_uses_remaining_period_ratio_weights():
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="3000", installment_count=3
)
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
payment.installment_advance_days = 30
payment.installment_ratios = "[10, 45, 45]"
db.commit()
_detail(
db, detail_id="ratio-paid-1", pay_status="yjf", due_amount="500",
installment_no=1, installment_due_date="20261001",
)
_detail(
db, detail_id="ratio-draft-2", pay_status="dfj", due_amount="1250",
installment_no=2, installment_due_date="20270901",
)
_detail(
db, detail_id="ratio-draft-3", pay_status="dfj", due_amount="1250",
installment_no=3, installment_due_date="20280901",
)
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq",
"contract_amount": "3000.00",
"installment_count": 3,
"installment_interval_months": "12",
"installment_start_date": "20261001",
"installment_advance_days": 30,
"installment_ratios": [10, 30, 60],
},
payment_details=[
{
"id": "ratio-paid-1", "installment_no": 1,
"installment_due_date": "20261001", "due_amount": "500.00",
},
{
"id": "ratio-draft-2", "installment_no": 2,
"installment_due_date": "20270901", "due_amount": "833.33",
},
{
"id": "ratio-draft-3", "installment_no": 3,
"installment_due_date": "20280901", "due_amount": "1666.67",
},
],
)
db.expire_all()
assert db.get(BizProtocolPaymentDetail, "ratio-draft-2").due_amount == Decimal("833.33")
assert db.get(BizProtocolPaymentDetail, "ratio-draft-3").due_amount == Decimal("1666.67")
def test_locked_remaining_distribution_keeps_max_amount_cent_tail():
from app.services.protocol_payment_installment import (
resolve_locked_remaining_due_amounts,
)
amounts = resolve_locked_remaining_due_amounts(
Decimal("9999999999999999.99"),
Decimal("0.02"),
[],
[1, 2],
)
assert amounts == [
Decimal("4999999999999999.98"),
Decimal("4999999999999999.99"),
]
assert sum(amounts, Decimal("0.02")) == Decimal("9999999999999999.99")
def test_v1_aggregate_rejects_contract_below_locked_due_and_rolls_back():
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="3000", installment_count=2
)
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
payment.installment_advance_days = 30
db.commit()
_detail(
db, detail_id="locked-over-contract", pay_status="yjf",
due_amount="500", installment_no=1,
installment_due_date="20261001",
)
with pytest.raises(ValueError, match="合同金额不能小于"):
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq", "contract_amount": "400.00",
"installment_count": 2, "installment_interval_months": "24",
"installment_start_date": "20261001",
"installment_advance_days": 30,
"installment_ratios": None,
},
payment_details=[
{
"id": "locked-over-contract", "installment_no": 1,
"installment_due_date": "20261001", "due_amount": "500.00",
},
{
"installment_no": 2, "installment_due_date": "20280901",
"due_amount": "0.00",
},
],
)
db.expire_all()
assert db.get(BizProtocolPayment, "payment-a").contract_amount == Decimal("3000")
@pytest.mark.parametrize(
("draft_no", "draft_date", "message"),
[
(4, "20280901", "期号"),
(2, "20270901", "应缴时间"),
],
)
def test_v1_aggregate_rejects_remaining_period_or_date_mismatch(
draft_no, draft_date, message
):
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="3000", installment_count=3
)
payment.installment_interval_months = Decimal("12")
payment.installment_start_date = "20261001"
payment.installment_advance_days = 30
payment.installment_ratios = None
db.commit()
_detail(
db, detail_id="guard-paid-1", pay_status="yjf", due_amount="500",
installment_no=1, installment_due_date="20261001",
)
_detail(
db, detail_id="guard-draft-2", pay_status="dfj", due_amount="1250",
installment_no=2, installment_due_date="20270901",
)
_detail(
db, detail_id="guard-draft-3", pay_status="dfj", due_amount="1250",
installment_no=3, installment_due_date="20280901",
)
with pytest.raises(ValueError, match=message):
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq", "contract_amount": "3000.00",
"installment_count": 3, "installment_interval_months": "24",
"installment_start_date": "20261001",
"installment_advance_days": 30,
"installment_ratios": None,
},
payment_details=[
{
"id": "guard-paid-1", "installment_no": 1,
"installment_due_date": "20261001", "due_amount": "500.00",
},
{
"id": "guard-draft-2", "installment_no": draft_no,
"installment_due_date": draft_date, "due_amount": "1250.00",
},
{
"id": "guard-draft-3", "installment_no": 3,
"installment_due_date": "20300901", "due_amount": "1250.00",
},
],
)
db.expire_all()
assert db.get(BizProtocolPayment, "payment-a").installment_interval_months == Decimal("12")
def test_v1_aggregate_rejects_invalid_all_draft_plan_and_rolls_back():
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="100", installment_count=2
)
payment.installment_interval_months = Decimal("1")
payment.installment_start_date = "20260131"
payment.installment_advance_days = 0
payment.installment_ratios = None
payment.remark = "original"
db.commit()
with pytest.raises(ValueError, match="期号|应缴金额|应缴时间"):
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq", "contract_amount": "100.00",
"installment_count": 2, "installment_interval_months": "2",
"installment_start_date": "20260131",
"installment_advance_days": 0,
"installment_ratios": None, "remark": "tampered",
},
payment_details=[
{
"installment_no": 99, "installment_due_date": "20000101",
"due_amount": "1.00",
},
{
"installment_no": 100, "installment_due_date": "19990101",
"due_amount": "2.00",
},
],
)
db.expire_all()
saved = db.get(BizProtocolPayment, "payment-a")
assert saved.installment_interval_months == Decimal("1")
assert saved.remark == "original"
assert db.query(BizProtocolPaymentDetail).count() == 0
def test_v1_aggregate_accepts_complete_all_draft_plan_with_clamped_dates():
db = _session()
payment = _payment(
db, pay_mode="fq", contract_amount="100", installment_count=2
)
payment.installment_interval_months = Decimal("1")
payment.installment_start_date = "20260131"
payment.installment_advance_days = 0
payment.installment_ratios = None
db.commit()
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "fq", "contract_amount": "100.00",
"installment_count": 2, "installment_interval_months": "1",
"installment_start_date": "20260131",
"installment_advance_days": 0,
"installment_ratios": None,
},
payment_details=[
{
"installment_no": 1, "installment_due_date": "20260131",
"due_amount": "50.00",
},
{
"installment_no": 2, "installment_due_date": "20260228",
"due_amount": "50.00",
},
],
)
rows = db.query(BizProtocolPaymentDetail).order_by(
BizProtocolPaymentDetail.installment_no
).all()
assert [row.installment_no for row in rows] == [1, 2]
assert [row.installment_due_date for row in rows] == ["20260131", "20260228"]
assert [row.due_amount for row in rows] == [Decimal("50"), Decimal("50")]
def test_v1_aggregate_rejects_unknown_pay_mode_and_rolls_back():
db = _session()
payment = _payment(db, pay_mode="dj", contract_amount="100")
payment.remark = "original"
db.commit()
with pytest.raises(ValueError, match="缴费模式"):
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "evil",
"contract_amount": "1.00",
"remark": "tampered",
},
payment_details=[],
)
db.expire_all()
saved = db.get(BizProtocolPayment, "payment-a")
assert saved.pay_mode == "dj"
assert saved.contract_amount == Decimal("100")
assert saved.remark == "original"
@pytest.mark.parametrize(
("payment_patch", "detail_patch"),
[
({"lump_sum_deadline": "20261399"}, {}),
({}, {"installment_due_date": "20230229"}),
],
)
def test_v1_aggregate_rejects_invalid_calendar_dates_and_rolls_back(
payment_patch, detail_patch
):
db = _session()
payment = _payment(db, pay_mode="dj", contract_amount="100")
payment.lump_sum_deadline = "20261231"
payment.remark = "original"
db.commit()
with pytest.raises(ValueError, match="日期"):
save_payment_with_details(
db,
"protocol-a",
"tenant-a",
"market-user",
payment={
"pay_mode": "dj",
"contract_amount": "100.00",
"lump_sum_deadline": "20261231",
"remark": "tampered",
**payment_patch,
},
payment_details=[{
"installment_due_date": "20261231",
"due_amount": "100.00",
**detail_patch,
}],
)
db.expire_all()
saved = db.get(BizProtocolPayment, "payment-a")
assert saved.lump_sum_deadline == "20261231"
assert saved.remark == "original"
assert db.query(BizProtocolPaymentDetail).count() == 0