"""按样本缴费方案 / 转账笔财务确认 API(payment_model_version=2)""" from __future__ import annotations from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from app.core.database import get_db from app.core.menu_guard import require_admin_or_super_admin from app.core.response import error_response, success_response from app.crud import protocol_sample_payment as psp_svc from app.models.models import BizCollectionProtocol, SysUser router = APIRouter(prefix="/web/admin", tags=["细胞存储-按样本缴费"]) def _get_tenant_no(db: Session, user: dict) -> str: user_id = user.get("user_id") if not user_id: raise PermissionError("用户身份信息缺失,无法确定租户") sys_user = db.query(SysUser).filter(SysUser.id == user_id).first() if not sys_user or not sys_user.tenant_no: raise PermissionError("用户租户信息缺失") return sys_user.tenant_no def _get_current_user_id(user: dict) -> str: user_id = user.get("user_id") if not user_id: raise PermissionError("用户身份信息缺失") return str(user_id) def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str, user: dict) -> bool: from app.core.data_scope import apply_data_scope q = db.query(BizCollectionProtocol).filter( BizCollectionProtocol.id == protocol_id, BizCollectionProtocol.del_flag == 0, BizCollectionProtocol.tenant_no == tenant_no, ) q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no) return q.first() is not None class ScheduleBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) id: Optional[str] = None schedule_no: Optional[int] = Field(None, alias="scheduleNo") due_amount: Optional[float] = Field(None, alias="dueAmount") due_date: Optional[str] = Field(None, alias="dueDate") schedule_status: Optional[str] = Field(None, alias="scheduleStatus") remark: Optional[str] = None transfers: Optional[List[Dict[str, Any]]] = None class SamplePaymentBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) id: Optional[str] = None sample_code: str = Field(..., alias="sampleCode") protocol_sample_id: Optional[str] = Field(None, alias="protocolSampleId") pay_mode: str = Field(..., alias="payMode") contract_amount: Optional[float] = Field(None, alias="contractAmount") installment_count: Optional[int] = Field(None, alias="installmentCount") installment_interval_months: Optional[float] = Field( None, alias="installmentIntervalMonths" ) installment_start_date: Optional[str] = Field(None, alias="installmentStartDate") installment_advance_days: Optional[int] = Field(None, alias="installmentAdvanceDays") installment_ratios: Optional[Any] = Field(None, alias="installmentRatios") lump_sum_deadline: Optional[str] = Field(None, alias="lumpSumDeadline") remark: Optional[str] = None schedules: Optional[List[ScheduleBody]] = None class SamplePaymentsPutBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) sample_payments: List[SamplePaymentBody] = Field( default_factory=list, alias="samplePayments" ) class TransferCreateBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) schedule_id: str = Field(..., alias="scheduleId") transfer_amount: Optional[float] = Field(None, alias="transferAmount") transfer_time: Optional[str] = Field(None, alias="transferTime") transfer_type: Optional[str] = Field(None, alias="transferType") transfer_attachments: Optional[str] = Field(None, alias="transferAttachments") remark: Optional[str] = None class TransferDraftBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) transfer_amount: Optional[float] = Field(None, alias="transferAmount") transfer_time: Optional[str] = Field(None, alias="transferTime") transfer_type: Optional[str] = Field(None, alias="transferType") transfer_attachments: Optional[str] = Field(None, alias="transferAttachments") remark: Optional[str] = None class FinanceConfirmBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) finance_confirm_attachments: Optional[str] = Field( None, alias="financeConfirmAttachments" ) finance_remark: Optional[str] = Field(None, alias="financeRemark") def _ratios_to_str(ratios: Any) -> Optional[str]: if ratios is None: return None if isinstance(ratios, str): return ratios.strip() or None import json try: return json.dumps(ratios, ensure_ascii=False) except Exception: return str(ratios) def _save_sample_payments( db: Session, *, protocol_id: str, tenant_no: str, user_id: str, payments: List[SamplePaymentBody], ) -> List[Dict[str, Any]]: for item in payments: prev = psp_svc.find_active_sample_payment( db, tenant_no=tenant_no, sample_payment_id=item.id, collection_protocol_id=protocol_id, sample_code=item.sample_code, ) prev_mode = (prev.pay_mode or "").strip() if prev else "" sp = psp_svc.upsert_sample_payment( db, collection_protocol_id=protocol_id, tenant_no=tenant_no, user_id=user_id, sample_code=item.sample_code, pay_mode=item.pay_mode, contract_amount=item.contract_amount, protocol_sample_id=item.protocol_sample_id, installment_count=item.installment_count, installment_interval_months=item.installment_interval_months, installment_start_date=item.installment_start_date, installment_advance_days=item.installment_advance_days, installment_ratios=_ratios_to_str(item.installment_ratios), lump_sum_deadline=item.lump_sum_deadline, remark=item.remark, sample_payment_id=item.id, ) next_mode = (item.pay_mode or "").strip() if ( prev_mode == "fq" and next_mode in ("fgd", "dj") and psp_svc.sample_payment_has_yjf( db, sample_payment_id=sp.id, tenant_no=tenant_no ) ): psp_svc.apply_fq_to_fgd_merge( db, sample_payment_id=sp.id, tenant_no=tenant_no, user_id=user_id, target_pay_mode=next_mode, ) continue if item.schedules is not None: schedule_payloads = [s.model_dump(by_alias=False) for s in item.schedules] psp_svc.sync_schedules_and_transfers( db, sample_payment_id=sp.id, tenant_no=tenant_no, user_id=user_id, schedules=schedule_payloads, ) db.commit() return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no) @router.get("/payment-chase") async def list_payment_chase( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, chaseType: Optional[str] = Query(None, alias="chaseType"), payMode: Optional[str] = Query(None, alias="payMode"), salesUserId: Optional[str] = Query(None, alias="salesUserId"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): """催缴信息:逾期未清 ∪ 未来 30 天内应缴未清(一行一期)。""" try: tenant_no = _get_tenant_no(db, user) items, total = psp_svc.list_payment_chase_page( db, tenant_no=tenant_no, keyword=keyword, chase_type=chaseType, pay_mode=payMode, sales_user_id=salesUserId, page=page, page_size=page_size, user_info=user, ) return success_response({"list": items, "total": total, "page": page, "pageSize": page_size}) except Exception as e: return error_response(500, str(e)) @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, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) if not _protocol_accessible(db, protocol_id, tenant_no, user): return error_response(404, "协议单不存在或无权查看") data = psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no) 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.put("/collection-protocols/{protocol_id}/sample-payments") async def put_sample_payments( protocol_id: str, body: SamplePaymentsPutBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) if not _protocol_accessible(db, protocol_id, tenant_no, user): return error_response(404, "协议单不存在或无权操作") # pydantic v2: may need model_validate with populate payments = body.sample_payments if hasattr(body, "model_dump"): raw = body.model_dump(by_alias=True) payments = [ SamplePaymentBody.model_validate(x) for x in (raw.get("samplePayments") or raw.get("sample_payments") or []) ] data = _save_sample_payments( db, protocol_id=protocol_id, tenant_no=tenant_no, user_id=user_id, payments=payments or body.sample_payments, ) return success_response(data, message="保存成功") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.post("/payment-transfers") async def create_payment_transfer( body: TransferCreateBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) sch = psp_svc._get_schedule_or_raise( db, schedule_id=body.schedule_id, tenant_no=tenant_no ) if not _protocol_accessible(db, sch.collection_protocol_id, tenant_no, user): return error_response(403, "无权操作该协议") obj = psp_svc.add_transfer( db, schedule_id=body.schedule_id, tenant_no=tenant_no, user_id=user_id, transfer_amount=body.transfer_amount, transfer_time=body.transfer_time, transfer_type=body.transfer_type, transfer_attachments=body.transfer_attachments, remark=body.remark, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response( _transfer_to_dict(db, obj, tenant_no), message="创建成功" ) except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.put("/payment-transfers/{transfer_id}") async def update_payment_transfer( transfer_id: str, body: TransferDraftBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) fields = body.model_dump(by_alias=False, exclude_unset=True) locked_transfer = psp_svc._get_transfer_with_schedule_lock( db, transfer_id=transfer_id, tenant_no=tenant_no, user_info=user, ) obj = psp_svc.update_transfer_draft( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id, fields=fields, locked_transfer=locked_transfer, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response(_transfer_to_dict(db, obj, tenant_no), message="保存成功") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.post("/payment-transfers/{transfer_id}/submit-receipt-finance") async def submit_payment_transfer_finance( transfer_id: str, body: Optional[TransferDraftBody] = None, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) draft = body.model_dump(by_alias=True, exclude_unset=True) if body else None locked_transfer = psp_svc._get_transfer_with_schedule_lock( db, transfer_id=transfer_id, tenant_no=tenant_no, user_info=user, ) obj = psp_svc.submit_receipt_finance( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id, draft_fields=draft, locked_transfer=locked_transfer, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response(_transfer_to_dict(db, obj, tenant_no), message="已提交财务确认") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.post("/payment-transfers/{transfer_id}/withdraw") async def withdraw_payment_transfer( transfer_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) locked_transfer = psp_svc._get_transfer_with_schedule_lock( db, transfer_id=transfer_id, tenant_no=tenant_no, user_info=user, ) obj = psp_svc.withdraw_receipt( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id, locked_transfer=locked_transfer, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response(_transfer_to_dict(db, obj, tenant_no), message="已撤回") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.delete("/payment-transfers/{transfer_id}") async def delete_payment_transfer( transfer_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) obj = psp_svc._get_transfer_or_raise( db, transfer_id=transfer_id, tenant_no=tenant_no ) if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user): return error_response(403, "无权操作该协议") psp_svc.soft_delete_payment_transfer( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id ) return success_response(None, message="删除成功") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.get("/payment-transfers") async def list_payment_transfers( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias="pageSize"), payStatus: str = Query("dfj", alias="payStatus"), keyword: Optional[str] = None, 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_transfers_for_finance( db, pay_status=payStatus, tenant_no=tenant_no, keyword=keyword, page=page, page_size=page_size, user_info=user, due_date_start=installmentDueDateStart, due_date_end=installmentDueDateEnd, ) return success_response( {"list": items, "total": total, "page": page, "pageSize": page_size} ) except PermissionError as e: return error_response(403, str(e)) @router.get("/payment-transfers/receipt-confirm") async def list_receipt_confirm( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, 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_transfers_for_finance( db, pay_status="jfqrz", tenant_no=tenant_no, keyword=keyword, page=page, page_size=page_size, user_info=user, due_date_start=installmentDueDateStart, due_date_end=installmentDueDateEnd, ) return success_response({"list": items, "total": total, "page": page, "pageSize": page_size}) except PermissionError as e: return error_response(403, str(e)) @router.get("/payment-transfers/receipt-confirmed") async def list_receipt_confirmed( page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, 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_transfers_for_finance( db, pay_status="yjf", tenant_no=tenant_no, keyword=keyword, page=page, page_size=page_size, user_info=user, due_date_start=installmentDueDateStart, due_date_end=installmentDueDateEnd, ) return success_response({"list": items, "total": total, "page": page, "pageSize": page_size}) except PermissionError as e: return error_response(403, str(e)) @router.post("/payment-transfers/{transfer_id}/receipt-confirm") async def confirm_payment_transfer( transfer_id: str, body: Optional[FinanceConfirmBody] = None, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) locked_transfer = psp_svc._get_transfer_with_schedule_lock( db, transfer_id=transfer_id, tenant_no=tenant_no, user_info=user, ) obj = psp_svc.confirm_receipt( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id, finance_confirm_attachments=body.finance_confirm_attachments if body else None, finance_remark=body.finance_remark if body else None, locked_transfer=locked_transfer, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response(_transfer_to_dict(db, obj, tenant_no), message="确认收款成功") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e)) @router.post("/payment-transfers/{transfer_id}/receipt-reject") async def reject_payment_transfer( transfer_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): try: tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) locked_transfer = psp_svc._get_transfer_with_schedule_lock( db, transfer_id=transfer_id, tenant_no=tenant_no, user_info=user, ) obj = psp_svc.reject_receipt( db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id, locked_transfer=locked_transfer, ) from app.crud.protocol_sample_payment import _transfer_to_dict return success_response(_transfer_to_dict(db, obj, tenant_no), message="已退回") except PermissionError as e: return error_response(403, str(e)) except ValueError as e: return error_response(400, str(e))