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