From 2ff5bc09f919f94dd49422daec01058429bd8941 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 3 Oct 2026 11:05:29 +0800 Subject: [PATCH] feat: add protocol-level refund registration API Co-authored-by: Cursor --- backend/app/api/__init__.py | 2 + backend/app/api/protocol_refund_record.py | 205 ++++++++++++++++++ backend/app/crud/protocol_refund_record.py | 182 ++++++++++++++++ backend/app/main.py | 3 +- .../app/services/protocol_refund_record.py | 18 ++ backend/tests/test_protocol_refund_record.py | 20 ++ 6 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/protocol_refund_record.py create mode 100644 backend/app/crud/protocol_refund_record.py create mode 100644 backend/app/services/protocol_refund_record.py create mode 100644 backend/tests/test_protocol_refund_record.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 977042dd..6099f02b 100644 --- a/backend/app/api/__init__.py +++ b/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", diff --git a/backend/app/api/protocol_refund_record.py b/backend/app/api/protocol_refund_record.py new file mode 100644 index 00000000..f1aefb77 --- /dev/null +++ b/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)) diff --git a/backend/app/crud/protocol_refund_record.py b/backend/app/crud/protocol_refund_record.py new file mode 100644 index 00000000..93d4c8d5 --- /dev/null +++ b/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() diff --git a/backend/app/main.py b/backend/app/main.py index 9300668f..75917796 100644 --- a/backend/app/main.py +++ b/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) diff --git a/backend/app/services/protocol_refund_record.py b/backend/app/services/protocol_refund_record.py new file mode 100644 index 00000000..439a3317 --- /dev/null +++ b/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 diff --git a/backend/tests/test_protocol_refund_record.py b/backend/tests/test_protocol_refund_record.py new file mode 100644 index 00000000..ab48fde6 --- /dev/null +++ b/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()