Преглед изворни кода

feat: expose per-sample payment and transfer finance APIs

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu пре 1 месец
родитељ
комит
aed7a29c1f

+ 2 - 0
backend/app/api/__init__.py

@@ -5,6 +5,7 @@ from app.api.admin import router as admin_router
 from app.api.workflow import router as workflow_router
 from app.api.basic_info import router as basic_info_router
 from app.api.collection_protocol import router as collection_protocol_router
+from app.api.protocol_sample_payment import router as protocol_sample_payment_router
 from app.api.leave import router as leave_router
 from app.api.operations import router as operations_router
 from app.api.ethnic import router as ethnic_router
@@ -44,6 +45,7 @@ __all__ = [
     "workflow_router",
     "basic_info_router",
     "collection_protocol_router",
+    "protocol_sample_payment_router",
     "leave_router",
     "operations_router",
     "ethnic_router",

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

@@ -0,0 +1,431 @@
+"""按样本缴费方案 / 转账笔财务确认 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:
+        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,
+        )
+        if item.schedules is not None:
+            schedule_payloads = [s.model_dump(by_alias=False) for s in item.schedules]
+            created = psp_svc.replace_schedules(
+                db,
+                sample_payment_id=sp.id,
+                tenant_no=tenant_no,
+                user_id=user_id,
+                schedules=schedule_payloads,
+            )
+            # 可选:随 PUT 带入的草稿转账(仅新建 dfj)
+            for sch_body, sch_row in zip(item.schedules, created):
+                for tr in sch_body.transfers or []:
+                    psp_svc.add_transfer(
+                        db,
+                        schedule_id=sch_row.id,
+                        tenant_no=tenant_no,
+                        user_id=user_id,
+                        transfer_amount=tr.get("transferAmount") or tr.get("transfer_amount"),
+                        transfer_time=tr.get("transferTime") or tr.get("transfer_time"),
+                        transfer_type=tr.get("transferType") or tr.get("transfer_type"),
+                        transfer_attachments=tr.get("transferAttachments")
+                        or tr.get("transfer_attachments"),
+                        remark=tr.get("remark"),
+                        commit=False,
+                    )
+    db.commit()
+    return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
+
+
+@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)
+        obj = psp_svc.update_transfer_draft(
+            db,
+            transfer_id=transfer_id,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            fields=fields,
+        )
+        if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
+            return error_response(403, "无权操作该协议")
+        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
+        obj = psp_svc.submit_receipt_finance(
+            db,
+            transfer_id=transfer_id,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            draft_fields=draft,
+        )
+        if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
+            return error_response(403, "无权操作该协议")
+        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.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),
+    keyword: Optional[str] = None,
+    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,
+        )
+        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),
+    keyword: Optional[str] = None,
+    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,
+        )
+        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)
+        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,
+        )
+        if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
+            return error_response(403, "无权操作该协议")
+        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)
+        obj = psp_svc.reject_receipt(
+            db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
+        )
+        if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
+            return error_response(403, "无权操作该协议")
+        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))

+ 6 - 2
backend/app/crud/protocol_sample_payment.py

@@ -300,6 +300,7 @@ def add_transfer(
     transfer_type: Optional[str] = None,
     transfer_attachments: Optional[str] = None,
     remark: Optional[str] = None,
+    commit: bool = True,
 ) -> BizProtocolPaymentTransfer:
     sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
     now = _now()
@@ -332,8 +333,11 @@ def add_transfer(
         del_flag=0,
     )
     db.add(obj)
-    db.commit()
-    db.refresh(obj)
+    if commit:
+        db.commit()
+        db.refresh(obj)
+    else:
+        db.flush()
     return obj
 
 

+ 2 - 1
backend/app/main.py

@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
 from app.core.api_request_log_middleware import ApiRequestLogMiddleware
 from app.core.api_crypto_middleware import ApiCryptoMiddleware
 from app.models import models  # noqa: F401
-from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router
+from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router
 
 
 @asynccontextmanager
@@ -117,6 +117,7 @@ app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
 app.include_router(workflow_router, prefix=settings.API_V1_PREFIX)
 app.include_router(basic_info_router, prefix=settings.API_V1_PREFIX)
 app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX)
+app.include_router(protocol_sample_payment_router, prefix=settings.API_V1_PREFIX)
 app.include_router(leave_router, prefix=settings.API_V1_PREFIX)
 app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
 app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX)