Explorar o código

feat: add protocol-level refund registration API

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 1 mes
pai
achega
2ff5bc09f9

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

@@ -6,6 +6,7 @@ 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.protocol_refund_record import router as protocol_refund_record_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
@@ -46,6 +47,7 @@ __all__ = [
     "basic_info_router",
     "collection_protocol_router",
     "protocol_sample_payment_router",
+    "protocol_refund_record_router",
     "leave_router",
     "operations_router",
     "ethnic_router",

+ 205 - 0
backend/app/api/protocol_refund_record.py

@@ -0,0 +1,205 @@
+"""协议级退费登记 API"""
+
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends
+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_refund_record as rr_svc
+from app.models.models import (
+    BizCollectionProtocol,
+    BizProtocolRefundRecord,
+    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 RefundRecordBody(BaseModel):
+    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+    refund_amount: float = Field(..., alias="refundAmount")
+    refund_time: Optional[str] = Field(None, alias="refundTime")
+    refund_remark: Optional[str] = Field(None, alias="refundRemark")
+    refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
+    workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
+    remark: Optional[str] = None
+
+
+class RefundRecordUpdateBody(BaseModel):
+    model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+    refund_amount: Optional[float] = Field(None, alias="refundAmount")
+    refund_time: Optional[str] = Field(None, alias="refundTime")
+    refund_remark: Optional[str] = Field(None, alias="refundRemark")
+    refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
+    workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
+    remark: Optional[str] = None
+
+
+@router.get("/collection-protocols/{protocol_id}/refund-records")
+async def list_protocol_refund_records(
+    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 = rr_svc.list_refund_records(
+            db, collection_protocol_id=protocol_id, tenant_no=tenant_no
+        )
+        return success_response(data)
+    except PermissionError as e:
+        return error_response(403, str(e))
+
+
+@router.post("/collection-protocols/{protocol_id}/refund-records")
+async def create_protocol_refund_record(
+    protocol_id: str,
+    body: RefundRecordBody,
+    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, "协议单不存在或无权操作")
+        obj = rr_svc.create_refund_record(
+            db,
+            collection_protocol_id=protocol_id,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            refund_amount=body.refund_amount,
+            refund_time=body.refund_time,
+            refund_remark=body.refund_remark,
+            refund_attachments=body.refund_attachments,
+            workflow_instance_id=body.workflow_instance_id,
+            remark=body.remark,
+        )
+        return success_response(
+            rr_svc._record_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("/protocol-refund-records/{record_id}")
+async def update_protocol_refund_record(
+    record_id: str,
+    body: RefundRecordUpdateBody,
+    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)
+        existing = (
+            db.query(BizProtocolRefundRecord)
+            .filter(
+                BizProtocolRefundRecord.id == record_id,
+                BizProtocolRefundRecord.tenant_no == tenant_no,
+                BizProtocolRefundRecord.del_flag == 0,
+            )
+            .first()
+        )
+        if not existing:
+            return error_response(404, "退费登记不存在")
+        if not _protocol_accessible(
+            db, existing.collection_protocol_id, tenant_no, user
+        ):
+            return error_response(403, "无权操作该协议")
+        dumped = body.model_dump(by_alias=False, exclude_unset=True)
+        obj = rr_svc.update_refund_record(
+            db,
+            record_id=record_id,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            refund_amount=dumped.get("refund_amount"),
+            refund_time=dumped.get("refund_time"),
+            refund_remark=dumped.get("refund_remark"),
+            refund_attachments=dumped.get("refund_attachments"),
+            workflow_instance_id=dumped.get("workflow_instance_id"),
+            remark=dumped.get("remark"),
+            fields_set=set(dumped.keys()),
+        )
+        return success_response(
+            rr_svc._record_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("/protocol-refund-records/{record_id}")
+async def delete_protocol_refund_record(
+    record_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)
+        existing = (
+            db.query(BizProtocolRefundRecord)
+            .filter(
+                BizProtocolRefundRecord.id == record_id,
+                BizProtocolRefundRecord.tenant_no == tenant_no,
+                BizProtocolRefundRecord.del_flag == 0,
+            )
+            .first()
+        )
+        if not existing:
+            return error_response(404, "退费登记不存在")
+        if not _protocol_accessible(
+            db, existing.collection_protocol_id, tenant_no, user
+        ):
+            return error_response(403, "无权操作该协议")
+        rr_svc.soft_delete_refund_record(
+            db, record_id=record_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))

+ 182 - 0
backend/app/crud/protocol_refund_record.py

@@ -0,0 +1,182 @@
+"""采集协议单 — 协议级退费登记 CRUD(不改转账笔 pay_status)"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid, parse_datetime
+from app.crud.collection_protocol import urls_to_attachment_list
+from app.models.models import BizProtocolRefundRecord
+from app.services.protocol_refund_record import validate_refund_amount
+
+
+def _now() -> datetime:
+    return datetime.now()
+
+
+def _record_to_dict(
+    db: Session, r: BizProtocolRefundRecord, tenant_no: str
+) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "collectionProtocolId": r.collection_protocol_id,
+        "refundAmount": float(r.refund_amount) if r.refund_amount is not None else None,
+        "refundTime": r.refund_time.strftime("%Y-%m-%d %H:%M:%S") if r.refund_time else "",
+        "refundRemark": r.refund_remark or "",
+        "refundAttachments": r.refund_attachments or "",
+        "refundAttachmentFiles": urls_to_attachment_list(
+            r.refund_attachments, db, tenant_no
+        ),
+        "workflowInstanceId": r.workflow_instance_id or "",
+        "remark": r.remark or "",
+        "createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
+        "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
+    }
+
+
+def list_refund_records(
+    db: Session, *, collection_protocol_id: str, tenant_no: str
+) -> List[Dict[str, Any]]:
+    rows = (
+        db.query(BizProtocolRefundRecord)
+        .filter(
+            BizProtocolRefundRecord.collection_protocol_id == collection_protocol_id,
+            BizProtocolRefundRecord.tenant_no == tenant_no,
+            BizProtocolRefundRecord.del_flag == 0,
+        )
+        .order_by(BizProtocolRefundRecord.create_time.desc())
+        .all()
+    )
+    return [_record_to_dict(db, r, tenant_no) for r in rows]
+
+
+def create_refund_record(
+    db: Session,
+    *,
+    collection_protocol_id: str,
+    tenant_no: str,
+    user_id: Optional[str],
+    refund_amount: Any,
+    refund_time: Any = None,
+    refund_remark: Optional[str] = None,
+    refund_attachments: Optional[str] = None,
+    workflow_instance_id: Optional[str] = None,
+    remark: Optional[str] = None,
+) -> BizProtocolRefundRecord:
+    amt = validate_refund_amount(refund_amount)
+    now = _now()
+    rt = None
+    if refund_time not in (None, ""):
+        rt = (
+            refund_time
+            if isinstance(refund_time, datetime)
+            else parse_datetime(str(refund_time))
+        )
+    obj = BizProtocolRefundRecord(
+        id=generate_uuid(),
+        collection_protocol_id=collection_protocol_id,
+        refund_amount=amt,
+        refund_time=rt or now,
+        refund_remark=(refund_remark or "").strip() or None,
+        refund_attachments=(refund_attachments or "").strip() or None,
+        workflow_instance_id=(workflow_instance_id or "").strip() or None,
+        tenant_no=tenant_no,
+        order_num=0,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id="",
+        create_time=now,
+        update_by=user_id,
+        update_time=now,
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_refund_record(
+    db: Session,
+    *,
+    record_id: str,
+    tenant_no: str,
+    user_id: Optional[str],
+    refund_amount: Any = None,
+    refund_time: Any = None,
+    refund_remark: Optional[str] = None,
+    refund_attachments: Optional[str] = None,
+    workflow_instance_id: Optional[str] = None,
+    remark: Optional[str] = None,
+    fields_set: Optional[set] = None,
+) -> BizProtocolRefundRecord:
+    obj = (
+        db.query(BizProtocolRefundRecord)
+        .filter(
+            BizProtocolRefundRecord.id == record_id,
+            BizProtocolRefundRecord.tenant_no == tenant_no,
+            BizProtocolRefundRecord.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("退费登记不存在")
+    fs = fields_set or {
+        "refund_amount",
+        "refund_time",
+        "refund_remark",
+        "refund_attachments",
+        "workflow_instance_id",
+        "remark",
+    }
+    if "refund_amount" in fs and refund_amount is not None:
+        obj.refund_amount = validate_refund_amount(refund_amount)
+    if "refund_time" in fs:
+        if refund_time in (None, ""):
+            obj.refund_time = None
+        elif isinstance(refund_time, datetime):
+            obj.refund_time = refund_time
+        else:
+            obj.refund_time = parse_datetime(str(refund_time))
+    if "refund_remark" in fs:
+        obj.refund_remark = (refund_remark or "").strip() or None
+    if "refund_attachments" in fs:
+        obj.refund_attachments = (refund_attachments or "").strip() or None
+    if "workflow_instance_id" in fs:
+        obj.workflow_instance_id = (workflow_instance_id or "").strip() or None
+    if "remark" in fs:
+        obj.remark = (remark or "").strip() or None
+    obj.update_by = user_id
+    obj.update_time = _now()
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def soft_delete_refund_record(
+    db: Session,
+    *,
+    record_id: str,
+    tenant_no: str,
+    user_id: Optional[str],
+) -> None:
+    obj = (
+        db.query(BizProtocolRefundRecord)
+        .filter(
+            BizProtocolRefundRecord.id == record_id,
+            BizProtocolRefundRecord.tenant_no == tenant_no,
+            BizProtocolRefundRecord.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("退费登记不存在")
+    obj.del_flag = 1
+    obj.update_by = user_id
+    obj.update_time = _now()
+    db.add(obj)
+    db.commit()

+ 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, 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
+from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_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
@@ -118,6 +118,7 @@ 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(protocol_refund_record_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)

+ 18 - 0
backend/app/services/protocol_refund_record.py

@@ -0,0 +1,18 @@
+"""协议级退费登记金额校验"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any
+
+
+def validate_refund_amount(amount: Any) -> Decimal:
+    if amount is None or amount == "":
+        raise ValueError("请填写退费金额")
+    try:
+        dec = Decimal(str(amount))
+    except Exception as e:
+        raise ValueError("退费金额无效") from e
+    if dec <= 0:
+        raise ValueError("退费金额必须大于 0")
+    return dec

+ 20 - 0
backend/tests/test_protocol_refund_record.py

@@ -0,0 +1,20 @@
+"""协议级退费登记校验单测"""
+
+import unittest
+
+from app.services.protocol_refund_record import validate_refund_amount
+
+
+class TestValidateRefundAmount(unittest.TestCase):
+    def test_refund_amount_must_be_positive(self):
+        with self.assertRaises(ValueError):
+            validate_refund_amount(0)
+        with self.assertRaises(ValueError):
+            validate_refund_amount(-1)
+        with self.assertRaises(ValueError):
+            validate_refund_amount(None)
+        self.assertEqual(validate_refund_amount(0.01), validate_refund_amount("0.01"))
+
+
+if __name__ == "__main__":
+    unittest.main()