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>
This commit is contained in:
zlwuandCursor committed 2026-10-05 12:57:55 +08:00
1 parent 47f873a3e6
commit c35e4ca3d2
3 files changed
+380 -4

No files matched your search

@@ -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
View File
@@ -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
@@ -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()