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) 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") @router.get("/collection-protocols/{protocol_id}/sample-payments")
async def get_sample_payments( async def get_sample_payments(
protocol_id: str, protocol_id: str,
+284 -4
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple 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 sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime from app.core.utils import generate_uuid, parse_datetime
@@ -25,6 +25,7 @@ from app.services.protocol_transfer_status import (
can_submit_receipt, can_submit_receipt,
schedule_paid_and_arrears, schedule_paid_and_arrears,
) )
from app.services.sample_payment_progress import compute_sample_payment_progress
def _dec(v: Any) -> Optional[Decimal]: def _dec(v: Any) -> Optional[Decimal]:
@@ -353,7 +354,7 @@ def update_transfer_draft(
) -> BizProtocolPaymentTransfer: ) -> BizProtocolPaymentTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if t.pay_status != "dfj": if t.pay_status != "dfj":
raise ValueError("仅待缴费状态可编辑转账笔") raise ValueError("仅已缴费状态可编辑转账笔")
if "transfer_type" in fields or "transferType" in fields: if "transfer_type" in fields or "transferType" in fields:
v = fields.get("transfer_type", fields.get("transferType")) v = fields.get("transfer_type", fields.get("transferType"))
t.transfer_type = (str(v) if v is not None else "").strip() or None 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) t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if draft_fields: if draft_fields:
if t.pay_status != "dfj": if t.pay_status != "dfj":
raise ValueError("仅待缴费状态可编辑转账笔") raise ValueError("仅已缴费状态可编辑转账笔")
# 就地改草稿字段(不单独 commit) # 就地改草稿字段(不单独 commit)
if "transfer_type" in draft_fields or "transferType" in draft_fields: if "transfer_type" in draft_fields or "transferType" in draft_fields:
v = draft_fields.get("transfer_type", draft_fields.get("transferType")) 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 t.transfer_attachments = (str(v) if v is not None else "").strip() or None
if not can_submit_receipt(t.pay_status): if not can_submit_receipt(t.pay_status):
raise ValueError("仅待缴费状态可提交财务确认") raise ValueError("仅已缴费状态可提交财务确认")
_validate_transfer_submit_ready(t) _validate_transfer_submit_ready(t)
t.pay_status = "jfqrz" t.pay_status = "jfqrz"
t.update_by = user_id t.update_by = user_id
@@ -933,3 +934,282 @@ def list_transfers_for_finance(
item["payMode"] = (sp.pay_mode if sp else "") or "" item["payMode"] = (sp.pay_mode if sp else "") or ""
out.append(item) out.append(item)
return out, int(total) 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()