"""科研捐赠管理 — 捐赠单 CRUD / 审核 / 状态变更 / 转换 API""" 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 ( assert_strict_menu_code, require_admin_or_super_admin, require_strict_menu_code, ) from app.core.response import error_response, success_response from app.crud import research_donation as rd_svc from app.models.models import SysUser from app.models.research_donation import BizResearchDonation router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"]) PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list" PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list" 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) # --------------------------------------------------------------------------- # Pydantic bodies # --------------------------------------------------------------------------- class DonationCreateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) donation_no: Optional[str] = Field(None, alias="donationNo") donation_status: Optional[str] = Field("dsx", alias="donationStatus") donation_date: Optional[str] = Field(None, alias="donationDate") source_type: Optional[str] = Field("direct", alias="sourceType") source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId") consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls") consent_attachments: Optional[list] = Field(None, alias="consentAttachments") attachment_urls: Optional[str] = Field(None, alias="attachmentUrls") other_attachments: Optional[list] = Field(None, alias="otherAttachments") infectious_disease_report_urls: Optional[str] = Field( None, alias="infectiousDiseaseReportUrls" ) infectious_disease_report_attachments: Optional[list] = Field( None, alias="infectiousDiseaseReportAttachments" ) remark: Optional[str] = None party: Optional[dict] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection: Optional[dict] = None collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = None collector_user_id: Optional[str] = Field(None, alias="collectorUserId") class DonationUpdateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) donation_no: Optional[str] = Field(None, alias="donationNo") donation_status: Optional[str] = Field(None, alias="donationStatus") donation_date: Optional[str] = Field(None, alias="donationDate") source_type: Optional[str] = Field(None, alias="sourceType") source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId") consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls") consent_attachments: Optional[list] = Field(None, alias="consentAttachments") attachment_urls: Optional[str] = Field(None, alias="attachmentUrls") other_attachments: Optional[list] = Field(None, alias="otherAttachments") infectious_disease_report_urls: Optional[str] = Field( None, alias="infectiousDiseaseReportUrls" ) infectious_disease_report_attachments: Optional[list] = Field( None, alias="infectiousDiseaseReportAttachments" ) remark: Optional[str] = None party: Optional[dict] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection: Optional[dict] = None collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = None collector_user_id: Optional[str] = Field(None, alias="collectorUserId") class BatchDeleteBody(BaseModel): model_config = ConfigDict(populate_by_name=True) ids: List[str] = Field(..., alias="ids", min_length=1) class AuditSubmitBody(BaseModel): model_config = ConfigDict(populate_by_name=True) audit_status: str = Field(..., alias="auditStatus") audit_remark: Optional[str] = Field(None, alias="auditRemark") class StatusChangeBody(BaseModel): model_config = ConfigDict(populate_by_name=True) new_status: str = Field(..., alias="newStatus") desc: Optional[str] = Field(None, alias="desc") time_val: Optional[str] = Field(None, alias="timeVal") void_type: Optional[str] = Field(None, alias="voidType") class ConvertFromStorageBody(BaseModel): model_config = ConfigDict(populate_by_name=True) protocol_id: str = Field(..., alias="protocolId") def assert_donation_sensitive_fields( db: Session, user: dict, body: BaseModel, *, creating: bool, ) -> None: """防止通过普通保存接口注入状态或协议互转来源。""" submitted = set(body.model_fields_set) if creating: status_value = str(getattr(body, "donation_status", None) or "dsx").strip() source_value = str(getattr(body, "source_type", None) or "direct").strip() requires_audit = ( status_value != "dsx" or source_value != "direct" or bool(getattr(body, "source_protocol_id", None)) ) else: requires_audit = bool( submitted.intersection( {"donation_status", "source_type", "source_protocol_id"} ) ) if requires_audit: assert_strict_menu_code(db, user, PROTOCOL_AUDIT_MENU) # --------------------------------------------------------------------------- # 1. 列表 # --------------------------------------------------------------------------- @router.get("/research-donations") async def list_research_donations( page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200, alias="pageSize"), keyword: Optional[str] = Query(None, alias="keyword"), donationStatus: Optional[str] = Query(None, alias="donationStatus"), createTimeStart: Optional[str] = Query(None, alias="createTimeStart"), createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"), sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"), sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) result = rd_svc.list_donations( db, tenant_no=tenant_no, page=page, page_size=pageSize, keyword=keyword, donation_status=donationStatus, create_time_start=createTimeStart, create_time_end=createTimeEnd, sample_collect_time_start=sampleCollectTimeStart, sample_collect_time_end=sampleCollectTimeEnd, user_info=user, ) return success_response(result) # --------------------------------------------------------------------------- # 2. 审核列表(须在 {donation_id} 详情路由之前注册,避免 audits 被当作 id) # --------------------------------------------------------------------------- @router.get("/research-donations/audits") async def list_research_donation_audits( page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200, alias="pageSize"), keyword: Optional[str] = Query(None, alias="keyword"), auditStatus: Optional[str] = Query(None, alias="auditStatus"), researchDonationId: Optional[str] = Query(None, alias="researchDonationId"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) result = rd_svc.list_audits( db, tenant_no=tenant_no, page=page, page_size=pageSize, keyword=keyword, audit_status=auditStatus, research_donation_id=researchDonationId, user_info=user, ) return success_response(result) # --------------------------------------------------------------------------- # 3. 下一捐赠样本编码(须在 {donation_id} 详情路由之前注册) # --------------------------------------------------------------------------- @router.get("/research-donations/next-sample-code") async def next_research_donation_sample_code( pendingCodes: Optional[str] = Query(None, alias="pendingCodes"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) extra = [c.strip() for c in (pendingCodes or "").split(",") if c.strip()] code = rd_svc.next_donation_sample_code(db, tenant_no=tenant_no, extra_codes=extra) return success_response({"sampleCode": code}) # --------------------------------------------------------------------------- # 4. 详情 # --------------------------------------------------------------------------- @router.get("/research-donations/{donation_id}") async def get_research_donation_detail( donation_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) result = rd_svc.get_donation_detail(db, donation_id, tenant_no) if not result: return error_response(404, "捐赠单不存在或无权查看") return success_response(result) # --------------------------------------------------------------------------- # 4. 创建 # --------------------------------------------------------------------------- @router.post("/research-donations") async def create_research_donation( body: DonationCreateBody, user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)), db: Session = Depends(get_db), ): assert_donation_sensitive_fields(db, user, body, creating=True) tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: data = body.model_dump(exclude_none=False, by_alias=True) collector = (data.get("collectorUserId") or data.get("collector_user_id") or "").strip() if not collector: data["collectorUserId"] = user_id obj = rd_svc.create_donation( db, data=data, tenant_no=tenant_no, user_id=user_id, ) return success_response( rd_svc._donation_to_dict(obj, db, tenant_no), message="创建成功", ) except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 5. 更新 # --------------------------------------------------------------------------- @router.put("/research-donations/{donation_id}") async def update_research_donation( donation_id: str, body: DonationUpdateBody, user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)), db: Session = Depends(get_db), ): assert_donation_sensitive_fields(db, user, body, creating=False) tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: data = body.model_dump(exclude_unset=True, by_alias=True) obj = rd_svc.update_donation( db, donation_id, data=data, tenant_no=tenant_no, user_id=user_id, ) return success_response( rd_svc._donation_to_dict(obj, db, tenant_no), message="更新成功", ) except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 6. 删除 # --------------------------------------------------------------------------- @router.delete("/research-donations/{donation_id}") async def delete_research_donation( donation_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: rd_svc.delete_donation( db, donation_id, tenant_no=tenant_no, user_id=user_id, ) return success_response(None, message="删除成功") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 7. 批量删除 # --------------------------------------------------------------------------- @router.post("/research-donations/batch-delete") async def batch_delete_research_donations( body: BatchDeleteBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: count = rd_svc.batch_delete_donations( db, ids=body.ids, tenant_no=tenant_no, user_id=user_id, ) return success_response({"count": count}, message=f"已成功删除 {count} 条") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 8. 提交审核 # --------------------------------------------------------------------------- @router.post("/research-donations/{donation_id}/audit") async def submit_donation_audit( donation_id: str, body: AuditSubmitBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: rd_svc.submit_audit( db, donation_id, audit_status=body.audit_status, audit_remark=body.audit_remark, user_id=user.get("user_id"), tenant_no=tenant_no, ) d = ( db.query(BizResearchDonation) .filter(BizResearchDonation.id == donation_id) .first() ) dn = (d.donation_no if d else "") or "" return success_response({"donationNo": dn}, message="审核已提交") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 9. 状态变更 # --------------------------------------------------------------------------- @router.post("/research-donations/{donation_id}/status-change") async def change_donation_status( donation_id: str, body: StatusChangeBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: obj = rd_svc.change_status( db, donation_id, new_status=body.new_status, desc=body.desc, time_val=body.time_val, void_type=body.void_type, user_id=user_id, tenant_no=tenant_no, ) return success_response( rd_svc._donation_to_dict(obj, db, tenant_no), message="状态变更成功", ) except ValueError as e: return error_response(400, str(e)) @router.get("/research-donations/{donation_id}/status-changes") async def list_research_donation_status_changes( donation_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) obj = ( db.query(BizResearchDonation) .filter( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) .first() ) if not obj: return error_response(404, "捐赠单不存在") items = rd_svc.list_donation_status_changes( db, research_donation_id=donation_id, tenant_no=tenant_no, ) return success_response({"list": items}) # --------------------------------------------------------------------------- # 10. 细胞存储 → 科研捐赠 # --------------------------------------------------------------------------- @router.post("/research-donations/convert-from-storage") async def convert_from_storage( body: ConvertFromStorageBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: obj = rd_svc.convert_from_storage( db, body.protocol_id, tenant_no=tenant_no, user_id=user_id, ) return success_response( rd_svc._donation_to_dict(obj, db, tenant_no), message="转换成功", ) except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 11. 科研捐赠 → 细胞存储 # --------------------------------------------------------------------------- @router.post("/research-donations/{donation_id}/convert-to-storage") async def convert_to_storage( donation_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: obj = rd_svc.convert_to_storage( db, donation_id, tenant_no=tenant_no, user_id=user_id, ) return success_response( rd_svc._donation_to_dict(obj, db, tenant_no), message="转换成功", ) except ValueError as e: return error_response(400, str(e))