diff --git a/backend/app/api/protocol_sample_payment.py b/backend/app/api/protocol_sample_payment.py index ab1b1356..b850f436 100644 --- a/backend/app/api/protocol_sample_payment.py +++ b/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, diff --git a/backend/app/crud/protocol_sample_payment.py b/backend/app/crud/protocol_sample_payment.py index 522a6871..99f521ba 100644 --- a/backend/app/crud/protocol_sample_payment.py +++ b/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 diff --git a/backend/tests/test_sample_payment_list_helpers.py b/backend/tests/test_sample_payment_list_helpers.py new file mode 100644 index 00000000..3ec2f18f --- /dev/null +++ b/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()