"""细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 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 sqlalchemy import and_ as sa_and 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.core.utils import parse_datetime from app.crud import collection_protocol as cp_svc from app.crud import customer as customer_crud from app.models.models import BizCollectionProtocol, BizCustomer, 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) _CAMEL_TO_SNAKE_DATETIME = { "newbornDeliveryTime": "newborn_delivery_time", "sampleCollectTime": "sample_collect_time", "maternalBloodCollectTime": "maternal_blood_collect_time", } def _parse_datetime_fields(d: dict, camel_keys: list) -> None: for camel in camel_keys: val = d.get(camel) if val is not None and isinstance(val, str): snake = _CAMEL_TO_SNAKE_DATETIME.get(camel, camel) parsed = parse_datetime(val) if parsed: d.pop(camel, None) d[snake] = parsed else: d.pop(camel, None) d[snake] = None def _ensure_customer_for_mother(db: Session, party_customer: dict, tenant_no: str, user_id: str) -> dict: """If mother info exists but no customerId, create a customer record and link it.""" if not party_customer: return party_customer customer_id = party_customer.get("customer_id") or party_customer.get("customerId") mother_name = (party_customer.get("mother_name") or party_customer.get("motherName") or "").strip() if customer_id or not mother_name: return party_customer # Build customer fields from mother info mother_phone = (party_customer.get("mother_phone") or party_customer.get("motherPhone") or "").strip() or None mother_id_card = (party_customer.get("mother_id_card") or party_customer.get("motherIdCard") or "").strip() or None # Check if customer with this id_card already exists if mother_id_card and customer_crud.id_card_taken(db, mother_id_card, tenant_no): existing = db.query(BizCustomer).filter( sa_and( BizCustomer.id_card_no == mother_id_card, BizCustomer.tenant_no == tenant_no, BizCustomer.del_flag == 0, ) ).first() if existing: party_customer["customer_id"] = existing.id return party_customer # Create new customer try: obj = customer_crud.create_customer( db, customer_name=mother_name, sex="1", # female (mother) id_card_no=mother_id_card, phone_no=mother_phone, tenant_no=tenant_no, user_id=user_id, ) party_customer["customer_id"] = obj.id except ValueError: # If creation fails (e.g. duplicate), try to find existing pass return party_customer # --------------------------------------------------------------------------- # 采集协议单 # --------------------------------------------------------------------------- @router.get("/collection-protocols") async def list_collection_protocols( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, protocolStatus: Optional[str] = Query(None, alias="protocolStatus"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) current_user_id = _get_current_user_id(user) items, total = cp_svc.list_collection_protocols( db, page=page, page_size=pageSize, keyword=keyword, protocol_status=protocolStatus, tenant_no=tenant_no, scope_collector_user_id=current_user_id, ) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) @router.get("/collection-protocols/{protocol_id}") async def get_collection_protocol_detail( protocol_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) current_user_id = _get_current_user_id(user) bundle = cp_svc.get_protocol_detail_bundle( db, protocol_id, tenant_no, scope_collector_user_id=current_user_id ) if not bundle: return error_response(404, "协议单不存在或无权查看") return success_response(bundle) class ProtocolCreateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) protocol_no: str = Field(..., alias="protocolNo") protocol_no_id: Optional[str] = Field(None, alias="protocolNoId") protocol_status: str = Field("dsx", alias="protocolStatus") protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments") register_date: Optional[str] = Field(None, alias="registerDate") collector_user_id: Optional[str] = Field(None, alias="collectorUserId") remark: Optional[str] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = Field(None, alias="samples") payment: Optional[dict] = Field(None, alias="payment") payment_details: Optional[list] = Field(None, alias="paymentDetails") attachments: Optional[list] = Field(None, alias="attachments") storage_cert: Optional[dict] = Field(None, alias="storageCert") class ProtocolUpdateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) protocol_no: Optional[str] = Field(None, alias="protocolNo") protocol_status: Optional[str] = Field(None, alias="protocolStatus") protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments") register_date: Optional[str] = Field(None, alias="registerDate") effective_time: Optional[str] = Field(None, alias="effectiveTime") effective_desc: Optional[str] = Field(None, alias="effectiveDesc") expire_time: Optional[str] = Field(None, alias="expireTime") expire_desc: Optional[str] = Field(None, alias="expireDesc") void_time: Optional[str] = Field(None, alias="voidTime") void_desc: Optional[str] = Field(None, alias="voidDesc") to_research_time: Optional[str] = Field(None, alias="toResearchTime") to_research_desc: Optional[str] = Field(None, alias="toResearchDesc") collector_user_id: Optional[str] = Field(None, alias="collectorUserId") remark: Optional[str] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = Field(None, alias="samples") payment: Optional[dict] = Field(None, alias="payment") payment_details: Optional[list] = Field(None, alias="paymentDetails") attachments: Optional[list] = Field(None, alias="attachments") storage_cert: Optional[dict] = Field(None, alias="storageCert") def _resolve_protocol_attachments( protocol_attachments: Optional[str], attachments: Optional[list], ) -> Optional[str]: from_urls = cp_svc.attachments_to_urls(attachments) if from_urls: return from_urls return protocol_attachments def _persist_protocol_sub_resources( db: Session, protocol_id: str, tenant_no: str, user_id: str, *, party_customer: Optional[dict], collection_info: Optional[dict], samples: Optional[list], payment: Optional[dict], payment_details: Optional[list], storage_cert: Optional[dict], ) -> None: if party_customer: _parse_datetime_fields(party_customer, ["newbornDeliveryTime"]) party_customer = _ensure_customer_for_mother(db, party_customer, tenant_no, user_id) cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer) if collection_info: _parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"]) cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info) if samples is not None: cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples) if payment: cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment) if payment_details is not None: pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no) if not pay and payment: pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment) if pay: cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, payment_details) if storage_cert: cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert) @router.post("/collection-protocols") async def create_collection_protocol( body: ProtocolCreateBody, 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: protocol_attachments = _resolve_protocol_attachments( body.protocol_attachments, body.attachments, ) obj = cp_svc.create_collection_protocol( db, protocol_no=body.protocol_no, protocol_no_id=body.protocol_no_id, protocol_status=body.protocol_status, protocol_attachments=protocol_attachments, register_date=body.register_date, collector_user_id=user_id, remark=body.remark, tenant_no=tenant_no, user_id=user_id, ) protocol_id = obj.id _persist_protocol_sub_resources( db, protocol_id, tenant_no, user_id, party_customer=body.party_customer, collection_info=body.collection_info, samples=body.samples, payment=body.payment, payment_details=body.payment_details, storage_cert=body.storage_cert, ) return success_response(cp_svc._protocol_to_dict(obj), message="创建成功") except ValueError as e: return error_response(400, str(e)) @router.put("/collection-protocols/{protocol_id}") async def update_collection_protocol( protocol_id: str, body: ProtocolUpdateBody, 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: payload = body.model_dump(exclude_unset=True, by_alias=False) sub_keys = { "party_customer", "collection_info", "samples", "payment", "payment_details", "attachments", "storage_cert", } main_payload = {k: v for k, v in payload.items() if k not in sub_keys} main_payload.pop("collector_user_id", None) if body.attachments is not None: resolved = _resolve_protocol_attachments( main_payload.get("protocol_attachments"), body.attachments, ) if resolved is not None: main_payload["protocol_attachments"] = resolved obj = cp_svc.update_collection_protocol( db, protocol_id, tenant_no=tenant_no, user_id=user_id, scope_collector_user_id=user_id, **{k: v for k, v in main_payload.items() if v is not None}, ) _persist_protocol_sub_resources( db, protocol_id, tenant_no, user_id, party_customer=body.party_customer, collection_info=body.collection_info, samples=body.samples, payment=body.payment, payment_details=body.payment_details, storage_cert=body.storage_cert, ) return success_response(cp_svc._protocol_to_dict(obj), message="更新成功") except ValueError as e: return error_response(400, str(e)) @router.delete("/collection-protocols/{protocol_id}") async def delete_collection_protocol( protocol_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: cp_svc.soft_delete_collection_protocol( db, protocol_id, tenant_no=tenant_no, scope_collector_user_id=user_id, user_id=user_id, ) return success_response(None, message="删除成功") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 审核 # --------------------------------------------------------------------------- @router.get("/collection-protocol-audits") async def list_collection_protocol_audits( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, auditStatus: Optional[str] = Query(None, alias="auditStatus"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_audits( db, page=page, page_size=pageSize, keyword=keyword, audit_status=auditStatus, tenant_no=tenant_no, ) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) class AuditCreateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) audit_status: str = Field(..., alias="auditStatus") audit_remark: Optional[str] = Field(None, alias="auditRemark") @router.post("/collection-protocols/{protocol_id}/audit") async def submit_collection_protocol_audit( protocol_id: str, body: AuditCreateBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: cp_svc.create_audit( db, collection_protocol_id=protocol_id, audit_status=body.audit_status, audit_remark=body.audit_remark, tenant_no=tenant_no, user_id=user.get("user_id"), ) p = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == protocol_id).first() pn = (p.protocol_no if p else "") or "" return success_response({"protocolNo": pn}, message="审核已提交") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 收款确认 # --------------------------------------------------------------------------- @router.get("/protocol-payment-details") async def list_payment_details( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), payStatus: str = Query("dfj", alias="payStatus"), keyword: str = Query("", alias="keyword"), transferType: str = Query("", alias="transferType"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_payment_details_page( db, page=page, page_size=pageSize, pay_status=payStatus, keyword=keyword or None, transfer_type=transferType or None, tenant_no=tenant_no, ) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) class BatchDetailIdsBody(BaseModel): model_config = ConfigDict(populate_by_name=True) detail_ids: List[str] = Field(..., alias="detailIds", min_length=1) class RefundFinanceSubmitBody(BaseModel): model_config = ConfigDict(populate_by_name=True) refund_amount: float = Field(..., alias="refundAmount", gt=0) refund_remark: Optional[str] = Field(None, alias="refundRemark") refund_attachments: Optional[str] = Field(None, alias="refundAttachments") class BatchRefundFinanceSubmitBody(RefundFinanceSubmitBody): model_config = ConfigDict(populate_by_name=True) detail_ids: List[str] = Field(..., alias="detailIds", min_length=1) @router.post("/protocol-payment-details/{detail_id}/submit-receipt-finance") async def post_submit_receipt_finance( detail_id: str, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: cp_svc.submit_receipt_finance( db, detail_id=detail_id, tenant_no=tenant_no, user_id=user.get("user_id"), ) return success_response(None, message="提交成功") except ValueError as e: return error_response(400, str(e)) @router.post("/protocol-payment-details/submit-receipt-finance-batch") async def post_submit_receipt_finance_batch( body: BatchDetailIdsBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: count = cp_svc.batch_submit_receipt_finance( db, detail_ids=body.detail_ids, tenant_no=tenant_no, user_id=user.get("user_id"), ) return success_response({"count": count}, message=f"已成功提交 {count} 条") except ValueError as e: return error_response(400, str(e)) @router.post("/protocol-payment-details/{detail_id}/submit-refund-finance") async def post_submit_refund_finance( detail_id: str, body: RefundFinanceSubmitBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: cp_svc.submit_refund_finance( db, detail_id=detail_id, tenant_no=tenant_no, user_id=user.get("user_id"), refund_amount=body.refund_amount, refund_remark=body.refund_remark, refund_attachments=body.refund_attachments, ) return success_response(None, message="提交成功") except ValueError as e: return error_response(400, str(e)) @router.post("/protocol-payment-details/submit-refund-finance-batch") async def post_submit_refund_finance_batch( body: BatchRefundFinanceSubmitBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: count = cp_svc.batch_submit_refund_finance( db, detail_ids=body.detail_ids, tenant_no=tenant_no, user_id=user.get("user_id"), refund_amount=body.refund_amount, refund_remark=body.refund_remark, refund_attachments=body.refund_attachments, ) return success_response({"count": count}, message=f"已成功提交 {count} 条") except ValueError as e: return error_response(400, str(e)) @router.get("/protocol-payment-details/receipt-confirm") async def list_receipt_confirm( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_receipt_confirm_details(db, page=page, page_size=pageSize, tenant_no=tenant_no) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) @router.get("/protocol-payment-details/receipt-confirmed") async def list_receipt_confirmed( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_receipt_confirmed_details( db, page=page, page_size=pageSize, tenant_no=tenant_no ) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) class FinanceConfirmBody(BaseModel): model_config = ConfigDict(populate_by_name=True) finance_confirm_attachments: Optional[str] = Field(None, alias="financeConfirmAttachments") finance_remark: Optional[str] = Field(None, alias="financeRemark") class RefundFinanceConfirmBody(BaseModel): model_config = ConfigDict(populate_by_name=True) refund_finance_confirm_attachments: Optional[str] = Field( None, alias="refundFinanceConfirmAttachments" ) refund_finance_remark: Optional[str] = Field(None, alias="refundFinanceRemark") @router.post("/protocol-payment-details/{detail_id}/receipt-confirm") async def post_receipt_confirm( detail_id: str, body: FinanceConfirmBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: cp_svc.confirm_receipt( db, detail_id=detail_id, tenant_no=tenant_no, user_id=user.get("user_id"), finance_confirm_attachments=body.finance_confirm_attachments, finance_remark=body.finance_remark, ) return success_response(None, message="确认成功") except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- # 退费确认 # --------------------------------------------------------------------------- @router.get("/protocol-payment-details/refund-confirm") async def list_refund_confirm( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_refund_confirm_details(db, page=page, page_size=pageSize, tenant_no=tenant_no) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) @router.get("/protocol-payment-details/refund-confirmed") async def list_refund_confirmed( page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_refund_confirmed_details( db, page=page, page_size=pageSize, tenant_no=tenant_no ) tp = cp_svc.total_pages(total, pageSize) return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) @router.post("/protocol-payment-details/{detail_id}/refund-confirm") async def post_refund_confirm( detail_id: str, body: RefundFinanceConfirmBody, user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) try: cp_svc.confirm_refund( db, detail_id=detail_id, tenant_no=tenant_no, user_id=user.get("user_id"), refund_finance_confirm_attachments=body.refund_finance_confirm_attachments, refund_finance_remark=body.refund_finance_remark, ) return success_response(None, message="确认成功") except ValueError as e: return error_response(400, str(e))