Files
zkxw/backend/app/api/research_donation.py
T
2026-10-17 11:46:53 +08:00

516 lines
18 KiB
Python

"""科研捐赠管理 — 捐赠单 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))