From 476f744ee9d2071caa2bab3f86019b6242ff8042 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 11 Nov 2026 14:15:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E5=8D=8F=E8=AE=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=BB=8E=20V2=20=E6=A0=B7=E6=9C=AC=E7=BC=B4=E8=B4=B9?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B7=B2=E6=94=B6=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自存表导出依赖 paymentSummary.receivedAmount,原先仅汇总 V1 biz_protocol_payment;现对 include_sample_payment_modes 路径补齐 合同额、已收、分期明细与退费,避免已缴/退款列为空。 Co-authored-by: Cursor --- .../app/services/qc_work_order_fee_summary.py | 219 +++++++++++++++++- .../test_protocol_v2_payment_mode_summary.py | 187 ++++++++++++++- 2 files changed, 404 insertions(+), 2 deletions(-) diff --git a/backend/app/services/qc_work_order_fee_summary.py b/backend/app/services/qc_work_order_fee_summary.py index 05e73e85..b19155d3 100644 --- a/backend/app/services/qc_work_order_fee_summary.py +++ b/backend/app/services/qc_work_order_fee_summary.py @@ -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 diff --git a/backend/tests/test_protocol_v2_payment_mode_summary.py b/backend/tests/test_protocol_v2_payment_mode_summary.py index 885ff045..858b75aa 100644 --- a/backend/tests/test_protocol_v2_payment_mode_summary.py +++ b/backend/tests/test_protocol_v2_payment_mode_summary.py @@ -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() -- 2.54.0