Просмотр исходного кода

feat(backend): add sample-payments list and detail read APIs

Support per-sample payment overview for the standalone payment-info menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 недель назад
Родитель
Сommit
c35e4ca3d2

+ 59 - 0
backend/app/api/protocol_sample_payment.py

@@ -185,6 +185,65 @@ def _save_sample_payments(
     return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
 
 
+@router.get("/sample-payments")
+async def list_sample_payments(
+    page: int = Query(1, ge=1),
+    page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    payMode: Optional[str] = Query(None, alias="payMode"),
+    progressStatus: Optional[str] = Query(None, alias="progressStatus"),
+    installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
+    installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    """按样本缴费方案分页列表(独立缴费信息菜单)。"""
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = psp_svc.list_sample_payments_page(
+            db,
+            tenant_no=tenant_no,
+            keyword=keyword,
+            pay_mode=payMode,
+            progress_status=progressStatus,
+            due_date_start=installmentDueDateStart,
+            due_date_end=installmentDueDateEnd,
+            page=page,
+            page_size=page_size,
+            user_info=user,
+        )
+        return success_response(
+            {"list": items, "total": total, "page": page, "pageSize": page_size}
+        )
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.get("/sample-payments/{sample_payment_id}")
+async def get_sample_payment(
+    sample_payment_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        data = psp_svc.get_sample_payment_detail(
+            db,
+            sample_payment_id=sample_payment_id,
+            tenant_no=tenant_no,
+            user_info=user,
+        )
+        if not data:
+            return error_response(403, "无权查看或记录不存在")
+        return success_response(data)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
 @router.get("/collection-protocols/{protocol_id}/sample-payments")
 async def get_sample_payments(
     protocol_id: str,

+ 284 - 4
backend/app/crud/protocol_sample_payment.py

@@ -7,7 +7,7 @@ from datetime import datetime
 from decimal import Decimal
 from typing import Any, Dict, List, Optional, Tuple
 
-from sqlalchemy import and_, func, or_
+from sqlalchemy import and_, exists, func, or_
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid, parse_datetime
@@ -25,6 +25,7 @@ from app.services.protocol_transfer_status import (
     can_submit_receipt,
     schedule_paid_and_arrears,
 )
+from app.services.sample_payment_progress import compute_sample_payment_progress
 
 
 def _dec(v: Any) -> Optional[Decimal]:
@@ -353,7 +354,7 @@ def update_transfer_draft(
 ) -> BizProtocolPaymentTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if t.pay_status != "dfj":
-        raise ValueError("仅待缴费状态可编辑转账笔")
+        raise ValueError("仅已缴费状态可编辑转账笔")
     if "transfer_type" in fields or "transferType" in fields:
         v = fields.get("transfer_type", fields.get("transferType"))
         t.transfer_type = (str(v) if v is not None else "").strip() or None
@@ -402,7 +403,7 @@ def submit_receipt_finance(
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if draft_fields:
         if t.pay_status != "dfj":
-            raise ValueError("仅待缴费状态可编辑转账笔")
+            raise ValueError("仅已缴费状态可编辑转账笔")
         # 就地改草稿字段(不单独 commit)
         if "transfer_type" in draft_fields or "transferType" in draft_fields:
             v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
@@ -425,7 +426,7 @@ def submit_receipt_finance(
             )
             t.transfer_attachments = (str(v) if v is not None else "").strip() or None
     if not can_submit_receipt(t.pay_status):
-        raise ValueError("仅待缴费状态可提交财务确认")
+        raise ValueError("仅已缴费状态可提交财务确认")
     _validate_transfer_submit_ready(t)
     t.pay_status = "jfqrz"
     t.update_by = user_id
@@ -933,3 +934,282 @@ def list_transfers_for_finance(
         item["payMode"] = (sp.pay_mode if sp else "") or ""
         out.append(item)
     return out, int(total)
+
+
+def _filter_and_page_progress(
+    rows: List[Dict[str, Any]],
+    progress_status: Optional[str],
+    page: int,
+    page_size: int,
+) -> Tuple[List[Dict[str, Any]], int]:
+    """先按 progressStatus 过滤,再分页切片(禁止先 LIMIT 再筛进度)。"""
+    filtered = rows
+    ps = (progress_status or "").strip()
+    if ps:
+        filtered = [r for r in rows if (r.get("progressStatus") or "") == ps]
+    total = len(filtered)
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    start = (page - 1) * page_size
+    return filtered[start : start + page_size], total
+
+
+def _progress_to_json_fields(progress: Dict[str, Any]) -> Dict[str, Any]:
+    paid = progress.get("paidAmount")
+    due_remain = progress.get("dueRemainAmount")
+    return {
+        "progressStatus": progress.get("progressStatus") or "",
+        "paidAmount": float(paid) if paid is not None else 0.0,
+        "dueRemainAmount": float(due_remain) if due_remain is not None else 0.0,
+        "clearedScheduleCount": progress.get("clearedScheduleCount") or 0,
+        "activeScheduleCount": progress.get("activeScheduleCount") or 0,
+        "canConfirm": bool(progress.get("canConfirm")),
+    }
+
+
+def _load_payment_children_batch(
+    db: Session,
+    sample_payment_ids: List[str],
+    tenant_no: str,
+) -> Tuple[
+    Dict[str, List[BizProtocolPaymentSchedule]],
+    Dict[str, List[BizProtocolPaymentTransfer]],
+]:
+    schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
+    transfers_by_payment: Dict[str, List[BizProtocolPaymentTransfer]] = {}
+    if not sample_payment_ids:
+        return schedules_by_payment, transfers_by_payment
+    schedules = (
+        db.query(BizProtocolPaymentSchedule)
+        .filter(
+            BizProtocolPaymentSchedule.sample_payment_id.in_(sample_payment_ids),
+            BizProtocolPaymentSchedule.tenant_no == tenant_no,
+            BizProtocolPaymentSchedule.del_flag == 0,
+        )
+        .order_by(
+            BizProtocolPaymentSchedule.order_num.asc(),
+            BizProtocolPaymentSchedule.schedule_no.asc(),
+        )
+        .all()
+    )
+    sids = [s.id for s in schedules]
+    transfers: List[BizProtocolPaymentTransfer] = []
+    if sids:
+        transfers = (
+            db.query(BizProtocolPaymentTransfer)
+            .filter(
+                BizProtocolPaymentTransfer.schedule_id.in_(sids),
+                BizProtocolPaymentTransfer.tenant_no == tenant_no,
+                BizProtocolPaymentTransfer.del_flag == 0,
+            )
+            .order_by(BizProtocolPaymentTransfer.create_time.asc())
+            .all()
+        )
+    for s in schedules:
+        schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
+    schedule_to_payment = {s.id: s.sample_payment_id for s in schedules}
+    for t in transfers:
+        pid = schedule_to_payment.get(t.schedule_id) or t.sample_payment_id
+        if pid:
+            transfers_by_payment.setdefault(pid, []).append(t)
+    return schedules_by_payment, transfers_by_payment
+
+
+def _compute_progress_for_payment(
+    schedules: List[BizProtocolPaymentSchedule],
+    transfers: List[BizProtocolPaymentTransfer],
+) -> Dict[str, Any]:
+    sch_dicts = [
+        {
+            "scheduleStatus": s.schedule_status or "",
+            "dueAmount": s.due_amount,
+        }
+        for s in schedules
+    ]
+    tr_dicts = [
+        {
+            "payStatus": t.pay_status or "",
+            "transferAmount": t.transfer_amount,
+        }
+        for t in transfers
+    ]
+    return compute_sample_payment_progress(schedules=sch_dicts, transfers=tr_dicts)
+
+
+def _build_list_summary_row(
+    sp: BizProtocolSamplePayment,
+    proto: BizCollectionProtocol,
+    party: Optional[BizProtocolPartyCustomer],
+    progress: Dict[str, Any],
+) -> Dict[str, Any]:
+    row: Dict[str, Any] = {
+        "id": sp.id,
+        "collectionProtocolId": sp.collection_protocol_id,
+        "protocolNo": (proto.protocol_no if proto else "") or "",
+        "protocolStatus": (proto.protocol_status if proto else "") or "",
+        "voidType": (proto.void_type if proto else "") or "",
+        "motherName": (party.mother_name if party else "") or "",
+        "motherPhone": (party.mother_phone if party else "") or "",
+        "sampleCode": sp.sample_code or "",
+        "payMode": sp.pay_mode or "",
+        "contractAmount": float(sp.contract_amount)
+        if sp.contract_amount is not None
+        else None,
+    }
+    row.update(_progress_to_json_fields(progress))
+    return row
+
+
+def list_sample_payments_page(
+    db: Session,
+    *,
+    tenant_no: str,
+    keyword: Optional[str] = None,
+    pay_mode: Optional[str] = None,
+    progress_status: Optional[str] = None,
+    due_date_start: Optional[str] = None,
+    due_date_end: Optional[str] = None,
+    page: int = 1,
+    page_size: int = 10,
+    user_info=None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    if not tenant_no:
+        return [], 0
+    q = (
+        db.query(
+            BizProtocolSamplePayment,
+            BizCollectionProtocol,
+            BizProtocolPartyCustomer,
+        )
+        .join(
+            BizCollectionProtocol,
+            and_(
+                BizCollectionProtocol.id
+                == BizProtocolSamplePayment.collection_protocol_id,
+                BizCollectionProtocol.del_flag == 0,
+            ),
+        )
+        .outerjoin(
+            BizProtocolPartyCustomer,
+            and_(
+                BizProtocolPartyCustomer.collection_protocol_id
+                == BizProtocolSamplePayment.collection_protocol_id,
+                BizProtocolPartyCustomer.tenant_no == tenant_no,
+                BizProtocolPartyCustomer.del_flag == 0,
+            ),
+        )
+        .filter(
+            BizProtocolSamplePayment.tenant_no == tenant_no,
+            BizProtocolSamplePayment.del_flag == 0,
+        )
+    )
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        q = q.filter(
+            or_(
+                BizCollectionProtocol.protocol_no.like(like),
+                BizProtocolSamplePayment.sample_code.like(like),
+                BizProtocolPartyCustomer.mother_name.like(like),
+                BizProtocolPartyCustomer.mother_phone.like(like),
+            )
+        )
+    mode = (pay_mode or "").strip()
+    if mode:
+        q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
+    ds = (due_date_start or "").strip().replace("-", "")[:8]
+    de = (due_date_end or "").strip().replace("-", "")[:8]
+    if ds or de:
+        due_conditions = [
+            BizProtocolPaymentSchedule.sample_payment_id
+            == BizProtocolSamplePayment.id,
+            BizProtocolPaymentSchedule.tenant_no == tenant_no,
+            BizProtocolPaymentSchedule.del_flag == 0,
+            BizProtocolPaymentSchedule.schedule_status != "void",
+        ]
+        if ds:
+            due_conditions.append(BizProtocolPaymentSchedule.due_date >= ds)
+        if de:
+            due_conditions.append(BizProtocolPaymentSchedule.due_date <= de)
+        q = q.filter(exists().where(and_(*due_conditions)))
+    if user_info:
+        from app.core.data_scope import apply_data_scope
+
+        q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
+    candidates = q.order_by(BizProtocolSamplePayment.create_time.desc()).all()
+    if not candidates:
+        return [], 0
+    pids = [sp.id for sp, _, _ in candidates]
+    schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
+        db, pids, tenant_no
+    )
+    rows: List[Dict[str, Any]] = []
+    for sp, proto, party in candidates:
+        schedules = schedules_by_payment.get(sp.id, [])
+        transfers = transfers_by_payment.get(sp.id, [])
+        progress = _compute_progress_for_payment(schedules, transfers)
+        rows.append(_build_list_summary_row(sp, proto, party, progress))
+    return _filter_and_page_progress(rows, progress_status, page, page_size)
+
+
+def get_sample_payment_detail(
+    db: Session,
+    *,
+    sample_payment_id: str,
+    tenant_no: str,
+    user_info=None,
+) -> Optional[Dict[str, Any]]:
+    q = (
+        db.query(
+            BizProtocolSamplePayment,
+            BizCollectionProtocol,
+            BizProtocolPartyCustomer,
+        )
+        .join(
+            BizCollectionProtocol,
+            and_(
+                BizCollectionProtocol.id
+                == BizProtocolSamplePayment.collection_protocol_id,
+                BizCollectionProtocol.del_flag == 0,
+            ),
+        )
+        .outerjoin(
+            BizProtocolPartyCustomer,
+            and_(
+                BizProtocolPartyCustomer.collection_protocol_id
+                == BizProtocolSamplePayment.collection_protocol_id,
+                BizProtocolPartyCustomer.tenant_no == tenant_no,
+                BizProtocolPartyCustomer.del_flag == 0,
+            ),
+        )
+        .filter(
+            BizProtocolSamplePayment.id == sample_payment_id,
+            BizProtocolSamplePayment.tenant_no == tenant_no,
+            BizProtocolSamplePayment.del_flag == 0,
+        )
+    )
+    if user_info:
+        from app.core.data_scope import apply_data_scope
+
+        q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
+    row = q.first()
+    if not row:
+        return None
+    sp, proto, party = row
+    schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
+        db, [sp.id], tenant_no
+    )
+    schedules = schedules_by_payment.get(sp.id, [])
+    transfers = transfers_by_payment.get(sp.id, [])
+    by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
+    for t in transfers:
+        by_schedule.setdefault(t.schedule_id, []).append(t)
+    detail = _sample_payment_to_dict(db, sp, schedules, by_schedule, tenant_no)
+    detail["protocolNo"] = (proto.protocol_no if proto else "") or ""
+    detail["protocolStatus"] = (proto.protocol_status if proto else "") or ""
+    detail["voidType"] = (proto.void_type if proto else "") or ""
+    detail["motherName"] = (party.mother_name if party else "") or ""
+    detail["motherPhone"] = (party.mother_phone if party else "") or ""
+    progress = _compute_progress_for_payment(schedules, transfers)
+    detail.update(_progress_to_json_fields(progress))
+    return detail

+ 37 - 0
backend/tests/test_sample_payment_list_helpers.py

@@ -0,0 +1,37 @@
+"""样本缴费列表:进度过滤与分页顺序"""
+
+import unittest
+
+from app.crud.protocol_sample_payment import _filter_and_page_progress
+
+
+class TestFilterAndPageProgress(unittest.TestCase):
+    def test_filter_bfyj_before_paging(self):
+        rows = [
+            {"id": "1", "progressStatus": "dfj"},
+            {"id": "2", "progressStatus": "bfyj"},
+            {"id": "3", "progressStatus": "bfyj"},
+            {"id": "4", "progressStatus": "yjq"},
+            {"id": "5", "progressStatus": "bfyj"},
+        ]
+        page1, total = _filter_and_page_progress(rows, "bfyj", 1, 2)
+        self.assertEqual(total, 3)
+        self.assertEqual(len(page1), 2)
+        self.assertEqual(page1[0]["id"], "2")
+        self.assertEqual(page1[1]["id"], "3")
+
+        page2, total2 = _filter_and_page_progress(rows, "bfyj", 2, 2)
+        self.assertEqual(total2, 3)
+        self.assertEqual(len(page2), 1)
+        self.assertEqual(page2[0]["id"], "5")
+
+    def test_no_progress_filter_returns_all_paged(self):
+        rows = [{"id": str(i), "progressStatus": "dfj"} for i in range(5)]
+        page, total = _filter_and_page_progress(rows, None, 1, 3)
+        self.assertEqual(total, 5)
+        self.assertEqual(len(page), 3)
+        self.assertEqual([r["id"] for r in page], ["0", "1", "2"])
+
+
+if __name__ == "__main__":
+    unittest.main()