856 lines
27 KiB
Python
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
|