fix(backend): V2 样本缴费补齐协议列表已收金额 (#88) #89

Merged
cykj merged 1 commits from fix/issue-88-v2-payment-summary-export into V2.0 2026-11-11 06:17:00 +00:00
2 changed files with 404 additions and 2 deletions

No files matched your search

@@ -14,6 +14,7 @@ from app.models.models import (
BizProtocolPaymentDetail,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolRefundRecord,
BizProtocolSamplePayment,
)
@@ -229,6 +230,197 @@ def _first_period_first_transfer_times(
return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)}
def _received_amount_from_transfers(transfers: List[BizProtocolPaymentTransfer]) -> float:
total = Decimal("0")
for t in transfers:
st = (t.pay_status or "").strip()
if st in ("yjf", "jfqrz"):
total += t.transfer_amount or Decimal("0")
return float(total)
def _v2_schedule_detail_rows(
schedules: List[BizProtocolPaymentSchedule],
transfers: List[BizProtocolPaymentTransfer],
pay_mode: str,
) -> List[Dict[str, Any]]:
by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
for t in transfers:
sid = (t.schedule_id or "").strip()
if sid:
by_schedule.setdefault(sid, []).append(t)
active = [
s
for s in schedules
if (s.schedule_status or "").strip() != "void"
]
active.sort(
key=lambda s: (
s.schedule_no if s.schedule_no is not None else 10_000,
s.order_num if s.order_num is not None else 0,
s.create_time or datetime.min,
)
)
rows: List[Dict[str, Any]] = []
for idx, sch in enumerate(active):
sch_transfers = by_schedule.get(sch.id, [])
paid = [
t
for t in sch_transfers
if (t.pay_status or "").strip() in ("yjf", "jfqrz")
]
paid_amount = _received_amount_from_transfers(paid) if paid else None
statuses = [(t.pay_status or "").strip() for t in sch_transfers if (t.pay_status or "").strip()]
if "jfqrz" in statuses:
st = "jfqrz"
elif "yjf" in statuses and paid:
st = "yjf"
elif statuses:
st = statuses[0]
else:
st = "dfj"
times = [t.transfer_time for t in paid if t.transfer_time is not None]
transfer_time = _fmt_dt(min(times)) if times else ""
inst_no = sch.schedule_no
if pay_mode in ("fq", "djfq") and inst_no is not None:
label = f"第{inst_no}期"
else:
label = f"缴费{idx + 1}"
rows.append(
{
"installmentNo": inst_no,
"installmentLabel": label,
"installmentDueDate": _fmt_ymd(sch.due_date),
"dueAmount": _dec(sch.due_amount),
"transferAmount": paid_amount,
"refundAmount": None,
"payStatus": st,
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
"transferTime": transfer_time,
}
)
return rows
def _load_v2_protocol_payment_amounts(
db: Session,
protocol_ids: List[str],
tenant_no: str,
) -> Dict[str, Dict[str, Any]]:
"""按协议汇总 V2 样本缴费合同额、已收、明细与退费(仅 list/export 摘要用)。"""
ids = [x.strip() for x in protocol_ids if (x or "").strip()]
if not ids:
return {}
samples = (
db.query(BizProtocolSamplePayment)
.filter(
BizProtocolSamplePayment.collection_protocol_id.in_(ids),
BizProtocolSamplePayment.tenant_no == tenant_no,
BizProtocolSamplePayment.del_flag == 0,
)
.all()
)
if not samples:
return {}
schedules = (
db.query(BizProtocolPaymentSchedule)
.filter(
BizProtocolPaymentSchedule.collection_protocol_id.in_(ids),
BizProtocolPaymentSchedule.tenant_no == tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
)
.all()
)
transfers = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.collection_protocol_id.in_(ids),
BizProtocolPaymentTransfer.tenant_no == tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.all()
)
refunds = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.collection_protocol_id.in_(ids),
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.all()
)
samples_by: Dict[str, List[BizProtocolSamplePayment]] = {}
for sp in samples:
samples_by.setdefault(sp.collection_protocol_id, []).append(sp)
schedules_by: Dict[str, List[BizProtocolPaymentSchedule]] = {}
for s in schedules:
schedules_by.setdefault(s.collection_protocol_id, []).append(s)
transfers_by: Dict[str, List[BizProtocolPaymentTransfer]] = {}
for t in transfers:
transfers_by.setdefault(t.collection_protocol_id, []).append(t)
refund_by: Dict[str, float] = {}
refund_seen: Dict[str, bool] = {}
for r in refunds:
amt = _dec(r.refund_amount)
if amt is None:
continue
refund_seen[r.collection_protocol_id] = True
refund_by[r.collection_protocol_id] = refund_by.get(r.collection_protocol_id, 0.0) + float(amt)
out: Dict[str, Dict[str, Any]] = {}
for pid, sp_list in samples_by.items():
modes = {(sp.pay_mode or "").strip() for sp in sp_list if (sp.pay_mode or "").strip()}
ordered_modes = sorted(
modes,
key=lambda mode: ({"dj": 0, "fq": 1, "djfq": 2, "fgd": 3}.get(mode, 99), mode),
)
if modes == {"dj", "fq"}:
pay_mode = "djfq"
else:
pay_mode = "+".join(ordered_modes) if ordered_modes else ""
contract_total = Decimal("0")
has_contract = False
for sp in sp_list:
if sp.contract_amount is not None:
has_contract = True
contract_total += sp.contract_amount or Decimal("0")
contract = float(contract_total) if has_contract else None
proto_transfers = transfers_by.get(pid, [])
received = _received_amount_from_transfers(proto_transfers)
pending = max(contract - received, 0) if contract is not None else None
detail_rows = _v2_schedule_detail_rows(
schedules_by.get(pid, []),
proto_transfers,
pay_mode,
)
statuses = [(t.pay_status or "").strip() for t in proto_transfers]
overall = _protocol_overall_status(statuses, contract, pending)
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
installment_count = None
if pay_mode in ("fq", "djfq"):
installment_count = len(
{
d.get("installmentNo")
for d in detail_rows
if d.get("installmentNo") is not None
}
) or len(detail_rows)
payload: Dict[str, Any] = {
"payMode": pay_mode,
"contractAmount": contract,
"receivedAmount": received,
"pendingAmount": pending,
"statusHint": hint,
"installmentCount": installment_count,
"paymentDetails": detail_rows,
**overall,
}
if refund_seen.get(pid):
payload["refundAmount"] = refund_by.get(pid, 0.0)
out[pid] = payload
return out
def build_protocol_payment_summaries(
db: Session,
protocol_ids: List[str],
@@ -263,6 +455,7 @@ def build_protocol_payment_summaries(
):
details_by_payment.setdefault(d.payment_id, []).append(d)
sample_modes: Dict[str, set] = {}
v2_amounts: Dict[str, Dict[str, Any]] = {}
if include_sample_payment_modes:
for pid, mode in db.query(
BizProtocolSamplePayment.collection_protocol_id,
@@ -274,6 +467,7 @@ def build_protocol_payment_summaries(
).all():
if mode:
sample_modes.setdefault(pid, set()).add(mode)
v2_amounts = _load_v2_protocol_payment_amounts(db, ids, tenant_no)
first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no)
out: Dict[str, Dict[str, Any]] = {}
for pid in ids:
@@ -285,7 +479,8 @@ def build_protocol_payment_summaries(
)
first_pay = first_pay_map.get(pid) or ""
modes = sample_modes.get(pid, set())
if not summary and not first_pay and not modes:
v2 = v2_amounts.get(pid)
if not summary and not first_pay and not modes and not v2:
continue
if not summary:
summary = {
@@ -294,6 +489,7 @@ def build_protocol_payment_summaries(
"contractAmount": None,
"receivedAmount": None,
"pendingAmount": None,
"refundAmount": None,
"statusHint": "",
"installmentCount": None,
"paymentDetails": [],
@@ -309,6 +505,27 @@ def build_protocol_payment_summaries(
labels = {**_PAY_MODE_LABELS, "fgd": "分改趸"}
summary["payMode"] = "djfq" if modes == {"dj", "fq"} else "+".join(ordered_modes)
summary["payModeLabel"] = "+".join(labels.get(mode, mode) for mode in ordered_modes)
# V1 摘要缺已收金额时,用 V2 样本缴费补齐(自存表导出 / 协议列表依赖)。
if v2 and summary.get("receivedAmount") is None:
if summary.get("contractAmount") is None:
summary["contractAmount"] = v2.get("contractAmount")
summary["receivedAmount"] = v2.get("receivedAmount")
if summary.get("pendingAmount") is None:
summary["pendingAmount"] = v2.get("pendingAmount")
if not summary.get("paymentDetails"):
summary["paymentDetails"] = v2.get("paymentDetails") or []
if summary.get("installmentCount") is None:
summary["installmentCount"] = v2.get("installmentCount")
if summary.get("refundAmount") is None and "refundAmount" in v2:
summary["refundAmount"] = v2.get("refundAmount")
for key in (
"statusHint",
"overallPayStatus",
"overallPayStatusLabel",
"overallPayStatusTagType",
):
if not summary.get(key) and v2.get(key):
summary[key] = v2.get(key)
if first_pay:
summary["firstPayTransferTime"] = first_pay
out[pid] = summary
@@ -1,10 +1,18 @@
"""协议列表可显式读取 V2 样本方案,其他费用摘要消费者保持原行为。"""
from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import BizProtocolSamplePayment
from app.models.models import (
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolRefundRecord,
BizProtocolSamplePayment,
)
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
@@ -31,3 +39,180 @@ def test_protocol_list_uses_active_tenant_v2_payment_modes(modes, expected, labe
assert result['p1']['payModeLabel'] == label
assert build_protocol_payment_summaries(db, ['p1'], 'tenant-a') == {}
engine.dispose()
def test_protocol_list_fills_received_from_v2_sample_transfers():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
with sessionmaker(bind=engine)() as db:
db.add_all(
[
BizProtocolSamplePayment(
id='sp-dj',
collection_protocol_id='p-v2',
sample_code='S1',
pay_mode='dj',
contract_amount=Decimal('12800'),
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolSamplePayment(
id='sp-fq',
collection_protocol_id='p-v2',
sample_code='S2',
pay_mode='fq',
contract_amount=Decimal('22880'),
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentSchedule(
id='sch-dj-1',
sample_payment_id='sp-dj',
collection_protocol_id='p-v2',
sample_code='S1',
schedule_no=1,
due_amount=Decimal('12800'),
due_date='20260915',
schedule_status='cleared',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentSchedule(
id='sch-fq-1',
sample_payment_id='sp-fq',
collection_protocol_id='p-v2',
sample_code='S2',
schedule_no=1,
due_amount=Decimal('6480'),
due_date='20260915',
schedule_status='cleared',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentSchedule(
id='sch-fq-2',
sample_payment_id='sp-fq',
collection_protocol_id='p-v2',
sample_code='S2',
schedule_no=2,
due_amount=Decimal('16400'),
due_date='20261015',
schedule_status='open',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentTransfer(
id='tr-dj',
schedule_id='sch-dj-1',
sample_payment_id='sp-dj',
collection_protocol_id='p-v2',
sample_code='S1',
transfer_amount=Decimal('12800'),
transfer_time=datetime(2026, 9, 15, 12, 0, 0),
pay_status='yjf',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentTransfer(
id='tr-fq',
schedule_id='sch-fq-1',
sample_payment_id='sp-fq',
collection_protocol_id='p-v2',
sample_code='S2',
transfer_amount=Decimal('6480'),
transfer_time=datetime(2026, 9, 15, 11, 13, 1),
pay_status='yjf',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentTransfer(
id='tr-pending',
schedule_id='sch-fq-2',
sample_payment_id='sp-fq',
collection_protocol_id='p-v2',
sample_code='S2',
transfer_amount=None,
transfer_time=None,
pay_status='dfj',
tenant_no='tenant-a',
del_flag=0,
),
]
)
db.commit()
result = build_protocol_payment_summaries(
db, ['p-v2'], 'tenant-a', include_sample_payment_modes=True
)
summary = result['p-v2']
assert summary['payMode'] == 'djfq'
assert summary['contractAmount'] == 35680.0
assert summary['receivedAmount'] == 19280.0
assert summary['pendingAmount'] == 16400.0
details = summary['paymentDetails']
assert len(details) == 3
first = [d for d in details if d['installmentNo'] == 1]
assert sum(float(d['dueAmount'] or 0) for d in first) == 19280.0
assert sum(float(d['transferAmount'] or 0) for d in first) == 19280.0
# 未开启 include 时不补齐 V2 已收(质检费用摘要保持原语义)
plain = build_protocol_payment_summaries(db, ['p-v2'], 'tenant-a')
assert plain['p-v2'].get('receivedAmount') is None
assert plain['p-v2'].get('paymentDetails') == []
engine.dispose()
def test_protocol_list_fills_refund_amount_from_v2_refund_records():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
with sessionmaker(bind=engine)() as db:
db.add_all(
[
BizProtocolSamplePayment(
id='sp-void',
collection_protocol_id='p-void',
sample_code='S9',
pay_mode='dj',
contract_amount=Decimal('5000'),
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentSchedule(
id='sch-void',
sample_payment_id='sp-void',
collection_protocol_id='p-void',
sample_code='S9',
schedule_no=1,
due_amount=Decimal('5000'),
due_date='20260918',
schedule_status='cleared',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolPaymentTransfer(
id='tr-void',
schedule_id='sch-void',
sample_payment_id='sp-void',
collection_protocol_id='p-void',
sample_code='S9',
transfer_amount=Decimal('5000'),
transfer_time=datetime(2026, 9, 18, 14, 55, 4),
pay_status='yjf',
tenant_no='tenant-a',
del_flag=0,
),
BizProtocolRefundRecord(
id='rf-void',
collection_protocol_id='p-void',
refund_amount=Decimal('5000'),
refund_time=datetime(2026, 9, 19, 10, 0, 0),
tenant_no='tenant-a',
del_flag=0,
),
]
)
db.commit()
summary = build_protocol_payment_summaries(
db, ['p-void'], 'tenant-a', include_sample_payment_modes=True
)['p-void']
assert summary['receivedAmount'] == 5000.0
assert summary['refundAmount'] == 5000.0
engine.dispose()