Files
zkxw/backend/app/api/collection_protocol.py
T
zlwuandCursor 5dad1bb0e3 fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:25 +08:00

1426 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API"""
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from sqlalchemy import and_ as sa_and, func as sa_func
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
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.models.models import (
BizCollectionProtocol,
BizProtocolPartyCustomer,
BizProtocolSample,
BizProtocolStorageCert,
SysUser,
)
from app.services.party_customer_sync import sync_party_customers
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_CELL,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
_PAYMENT_DETAIL_SENSITIVE_FIELDS = {
"pay_status",
"payStatus",
"finance_confirm_attachments",
"financeConfirmAttachments",
"finance_confirm_attachment_files",
"financeConfirmAttachmentFiles",
"finance_remark",
"financeRemark",
"refund_amount",
"refundAmount",
"refund_remark",
"refundRemark",
"refund_attachments",
"refundAttachments",
"refund_attachment_files",
"refundAttachmentFiles",
"refund_finance_confirm_attachments",
"refundFinanceConfirmAttachments",
"refund_finance_confirm_attachment_files",
"refundFinanceConfirmAttachmentFiles",
"refund_finance_remark",
"refundFinanceRemark",
}
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
_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
# ---------------------------------------------------------------------------
# 采集协议单
# ---------------------------------------------------------------------------
@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"),
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)
items, total = cp_svc.list_collection_protocols(
db,
page=page,
page_size=pageSize,
keyword=keyword,
protocol_status=protocolStatus,
create_time_start=createTimeStart,
create_time_end=createTimeEnd,
sample_collect_time_start=sampleCollectTimeStart,
sample_collect_time_end=sampleCollectTimeEnd,
tenant_no=tenant_no,
user_info=user,
)
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权查看")
bundle = cp_svc.get_protocol_detail_bundle(db, protocol_id, tenant_no)
if not bundle:
return error_response(404, "协议单不存在或无权查看")
return success_response(bundle)
class ProtocolCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
protocol_no: Optional[str] = Field(None, 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")
consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
register_date: Optional[str] = Field(None, alias="registerDate")
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
payment_model_version: Optional[int] = Field(2, alias="paymentModelVersion")
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")
sample_payments: Optional[list] = Field(None, alias="samplePayments")
attachments: Optional[list] = Field(None, alias="attachments")
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
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")
consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
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")
void_type: Optional[str] = Field(None, alias="voidType")
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")
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
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")
sample_payments: Optional[list] = Field(None, alias="samplePayments")
attachments: Optional[list] = Field(None, alias="attachments")
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
storage_cert: Optional[dict] = Field(None, alias="storageCert")
def assert_protocol_sensitive_fields(
db: Session,
user: dict,
body: BaseModel,
*,
creating: bool,
) -> None:
"""防止通过普通协议保存接口注入财务确认或退费状态字段(不做菜单权限校验)。"""
for detail in getattr(body, "payment_details", None) or []:
normalized_detail = (
cp_svc._normalize_fields(detail) if isinstance(detail, dict) else {}
)
if _PAYMENT_DETAIL_SENSITIVE_FIELDS.intersection(normalized_detail.keys()):
raise HTTPException(
status_code=400,
detail="普通协议保存不允许提交财务确认或退费状态字段",
)
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 _resolve_consent_form_urls(
consent_form_urls: Optional[str],
consent_attachments: Optional[list],
) -> Optional[str]:
from_urls = cp_svc.attachments_to_urls(consent_attachments)
if from_urls:
return from_urls
return consent_form_urls
def _resolve_health_survey_form_urls(
health_survey_form_urls: Optional[str],
health_survey_attachments: Optional[list],
) -> Optional[str]:
from_urls = cp_svc.attachments_to_urls(health_survey_attachments)
if from_urls:
return from_urls
return health_survey_form_urls
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],
sample_payments: Optional[list] = None,
storage_cert: Optional[dict],
payment_model_version: int = 2,
commit: bool = True,
) -> None:
"""持久化协议子资源。
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
"""
if party_customer:
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
party_customer = sync_party_customers(
db,
party_customer,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
cp_svc.upsert_party_customer(
db, protocol_id, tenant_no, user_id, commit=False, **party_customer
)
if collection_info:
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
cp_svc.upsert_collection_info(
db, protocol_id, tenant_no, user_id, commit=False, **collection_info
)
version = int(payment_model_version or 2)
if version >= 2:
if samples is not None:
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if sample_payments is not None:
from app.crud import protocol_sample_payment as sp_pay_svc
sp_pay_svc.save_sample_payments_for_protocol(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
sample_payments=sample_payments,
commit=False,
)
else:
# 旧模型:缴费先于样本
if payment or payment_details is not None:
cp_svc.save_payment_with_details(
db,
protocol_id,
tenant_no,
user_id,
payment=payment,
payment_details=payment_details,
commit=False,
)
if samples is not None:
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if storage_cert:
cp_svc.upsert_storage_cert(
db, protocol_id, tenant_no, user_id, commit=False, **storage_cert
)
if commit:
db.commit()
@router.post("/collection-protocols")
async def create_collection_protocol(
body: ProtocolCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_protocol_sensitive_fields(db, user, body, creating=True)
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
protocol_id: Optional[str] = None
try:
protocol_attachments = _resolve_protocol_attachments(
body.protocol_attachments,
body.attachments,
)
consent_form_urls = _resolve_consent_form_urls(
body.consent_form_urls,
body.consent_attachments,
)
health_survey_form_urls = _resolve_health_survey_form_urls(
body.health_survey_form_urls,
body.health_survey_attachments,
)
collector = (body.collector_user_id or "").strip()
if not collector:
collector = user_id
obj = cp_svc.create_collection_protocol(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
protocol_no=body.protocol_no or "",
protocol_no_id=body.protocol_no_id,
protocol_status=body.protocol_status,
protocol_attachments=protocol_attachments,
consent_form_urls=consent_form_urls,
health_survey_form_urls=health_survey_form_urls,
register_date=body.register_date,
collector_user_id=collector,
protocol_total_amount=body.protocol_total_amount,
source_presign_order_id=body.source_presign_order_id,
payment_model_version=body.payment_model_version
if body.payment_model_version is not None
else 2,
remark=body.remark,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
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,
sample_payments=body.sample_payments,
storage_cert=body.storage_cert,
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 2
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
except ValueError as e:
db.rollback()
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),
):
assert_protocol_sensitive_fields(db, user, body, creating=False)
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, "协议单不存在或无权操作")
try:
payload = body.model_dump(exclude_unset=True, by_alias=False)
sub_keys = {
"party_customer", "collection_info", "samples",
"payment", "payment_details", "sample_payments", "attachments",
"consent_attachments", "health_survey_attachments", "storage_cert",
}
main_payload = {k: v for k, v in payload.items() if k not in sub_keys}
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
if body.consent_attachments is not None:
resolved = _resolve_consent_form_urls(
main_payload.get("consent_form_urls"),
body.consent_attachments,
)
if resolved is not None:
main_payload["consent_form_urls"] = resolved
if body.health_survey_attachments is not None:
resolved = _resolve_health_survey_form_urls(
main_payload.get("health_survey_form_urls"),
body.health_survey_attachments,
)
if resolved is not None:
main_payload["health_survey_form_urls"] = resolved
before_codes = sample_codes_from_orm_rows(
cp_svc.list_samples(db, protocol_id, tenant_no)
)
obj = cp_svc.update_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
**{
k: v
for k, v in main_payload.items()
if v is not None or k == "protocol_total_amount"
},
)
_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,
sample_payments=body.sample_payments,
storage_cert=body.storage_cert,
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 1
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
except ValueError as e:
db.rollback()
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权操作")
try:
cp_svc.soft_delete_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
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"),
collectionProtocolId: Optional[str] = Query(None, alias="collectionProtocolId"),
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,
collection_protocol_id=collectionProtocolId,
tenant_no=tenant_no,
user_info=user,
)
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(403, "无权访问该协议或协议不存在")
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("/collection-protocols/{protocol_id}/status-changes")
async def list_collection_protocol_status_changes(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(403, "无权访问该协议或协议不存在")
items = cp_svc.list_protocol_status_changes(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
)
return success_response({"list": items})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
@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"),
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
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,
installment_due_date_start=installmentDueDateStart,
installment_due_date_end=installmentDueDateEnd,
tenant_no=tenant_no,
user_info=user,
)
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 PaymentDetailDraftBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
transfer_type: Optional[str] = Field(None, alias="transferType")
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
remark: Optional[str] = None
def _draft_fields_from_body(body: PaymentDetailDraftBody) -> dict:
return body.model_dump(exclude_unset=True)
@router.put("/protocol-payment-details/{detail_id}")
async def put_payment_detail_draft(
detail_id: str,
body: PaymentDetailDraftBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
fields = _draft_fields_from_body(body)
if not fields:
return error_response(400, "请提供需要保存的字段")
try:
cp_svc.update_payment_detail_draft(
db,
detail_id=detail_id,
tenant_no=tenant_no,
user_id=user.get("user_id"),
draft_fields=fields,
)
return success_response(None, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
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,
body: PaymentDetailDraftBody = PaymentDetailDraftBody(),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
fields = _draft_fields_from_body(body)
try:
cp_svc.submit_receipt_finance(
db,
detail_id=detail_id,
tenant_no=tenant_no,
user_id=user.get("user_id"),
draft_fields=fields or None,
)
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"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
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,
keyword=keyword or None,
installment_due_date_start=installmentDueDateStart,
installment_due_date_end=installmentDueDateEnd,
tenant_no=tenant_no,
user_info=user,
)
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"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
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,
keyword=keyword or None,
installment_due_date_start=installmentDueDateStart,
installment_due_date_end=installmentDueDateEnd,
tenant_no=tenant_no,
user_info=user,
)
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.post("/protocol-payment-details/{detail_id}/receipt-reject")
async def post_receipt_reject(
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.reject_receipt(
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.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"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
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,
keyword=keyword or None,
installment_due_date_start=installmentDueDateStart,
installment_due_date_end=installmentDueDateEnd,
tenant_no=tenant_no,
user_info=user,
)
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"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
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,
keyword=keyword or None,
installment_due_date_start=installmentDueDateStart,
installment_due_date_end=installmentDueDateEnd,
tenant_no=tenant_no,
user_info=user,
)
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))
@router.post("/protocol-payment-details/{detail_id}/refund-reject")
async def post_refund_reject(
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.reject_refund(
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.get("/storage-certs")
async def list_storage_certs(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
makeStatus: Optional[str] = Query(None, alias="makeStatus"),
storageInDateStart: Optional[str] = Query(None, alias="storageInDateStart"),
storageInDateEnd: Optional[str] = Query(None, alias="storageInDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
page_items, total = cp_svc.list_storage_certs(
db,
tenant_no=tenant_no,
keyword=keyword,
make_status=makeStatus,
storage_in_date_start=storageInDateStart,
storage_in_date_end=storageInDateEnd,
page=page,
page_size=pageSize,
user_info=user,
)
page_safe = max(1, page)
page_size_safe = min(max(1, pageSize), 100)
tp = cp_svc.total_pages(total, page_size_safe)
return success_response(
{"list": page_items, "total": total, "page": page_safe, "pageSize": page_size_safe, "totalPages": tp}
)
@router.get("/fullmoon-todos")
async def list_fullmoon_todos(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
todoStatus: Optional[str] = Query(None, alias="todoStatus"),
fullmoonDateStart: Optional[str] = Query(None, alias="fullmoonDateStart"),
fullmoonDateEnd: Optional[str] = Query(None, alias="fullmoonDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
party_join = sa_and(
BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
BizProtocolPartyCustomer.del_flag == 0,
BizProtocolPartyCustomer.tenant_no == tenant_no,
)
q = (
db.query(BizCollectionProtocol, BizProtocolPartyCustomer)
.outerjoin(BizProtocolPartyCustomer, party_join)
.filter(
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.protocol_status == "sxz",
)
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(BizProtocolPartyCustomer.mother_name.like(like))
from app.core.data_scope import apply_data_scope
q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no)
rows = q.order_by(BizCollectionProtocol.create_time.desc()).all()
protocol_ids = [r.id for r, _ in rows]
ft_map: Dict[str, Any] = {}
if protocol_ids:
from app.models.models import BizProtocolFullmoonTodo
ft_rows = (
db.query(BizProtocolFullmoonTodo)
.filter(
BizProtocolFullmoonTodo.collection_protocol_id.in_(protocol_ids),
BizProtocolFullmoonTodo.tenant_no == tenant_no,
BizProtocolFullmoonTodo.del_flag == 0,
)
.all()
)
for ft in ft_rows:
ft_map[ft.collection_protocol_id] = ft
now = datetime.now()
_empty_ft: Dict[str, Any] = {
"todoStatus": "0",
"handleDate": "",
"handleRemark": "",
"insuranceAttachments": "",
"insuranceCompanyId": "",
"insuranceCompanyName": "",
"insuranceType": "",
"insurancePolicyNo": "",
"insuranceEffectiveDate": "",
"insuranceExpiryDate": "",
"insuranceSubscribed": "0",
"insuranceFee": None,
}
items: List[Dict[str, Any]] = []
for proto, pc in rows:
dt = pc.newborn_delivery_time if pc else None
fullmoon_date_str = ""
is_fullmoon_passed = False
if dt:
fullmoon_date = dt + timedelta(days=30)
fullmoon_date_str = fullmoon_date.strftime("%Y-%m-%d")
is_fullmoon_passed = fullmoon_date <= now
ft = ft_map.get(proto.id)
ts = (ft.todo_status or "0") if ft else "0"
if todoStatus and todoStatus.strip() and ts != todoStatus.strip():
continue
fm_start = (fullmoonDateStart or "").strip()[:10]
fm_end = (fullmoonDateEnd or "").strip()[:10]
if fm_start or fm_end:
if not fullmoon_date_str:
continue
if fm_start and fullmoon_date_str < fm_start:
continue
if fm_end and fullmoon_date_str > fm_end:
continue
ft_dict = cp_svc._fullmoon_todo_to_dict(ft) if ft else dict(_empty_ft)
items.append(
{
"protocolId": proto.id,
"id": proto.id,
"protocolNo": proto.protocol_no or "",
"protocolStatus": proto.protocol_status or "",
"motherName": (pc.mother_name if pc else "") or "",
"motherPhone": (pc.mother_phone if pc else "") or "",
"newbornName": (pc.newborn_name if pc else "") or "",
"newbornDeliveryTime": cp_svc._fmt_dt(dt) if dt else "",
"fullmoonDate": fullmoon_date_str,
"isFullmoonPassed": is_fullmoon_passed,
**ft_dict,
}
)
total = len(items)
page_safe = max(1, page)
page_size_safe = min(max(1, pageSize), 100)
start = (page_safe - 1) * page_size_safe
page_items = items[start : start + page_size_safe]
tp = cp_svc.total_pages(total, page_size_safe)
return success_response(
{"list": page_items, "total": total, "page": page_safe, "pageSize": page_size_safe, "totalPages": tp}
)
class FullmoonTodoInsuranceBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
insurance_company_id: Optional[str] = Field(None, alias="insuranceCompanyId")
insurance_company_name: Optional[str] = Field(None, alias="insuranceCompanyName")
insurance_type: Optional[str] = Field(None, alias="insuranceType")
insurance_policy_no: Optional[str] = Field(None, alias="insurancePolicyNo")
insurance_effective_date: Optional[str] = Field(None, alias="insuranceEffectiveDate")
insurance_expiry_date: Optional[str] = Field(None, alias="insuranceExpiryDate")
insurance_subscribed: Optional[str] = Field(None, alias="insuranceSubscribed")
insurance_fee: Optional[float] = Field(None, alias="insuranceFee")
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
todo_status: Optional[str] = Field(None, alias="todoStatus")
handle_date: Optional[str] = Field(None, alias="handleDate")
handle_remark: Optional[str] = Field(None, alias="handleRemark")
@router.put("/fullmoon-todos/{protocol_id}/insurance")
async def update_fullmoon_todo_insurance(
protocol_id: str,
body: FullmoonTodoInsuranceBody,
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)
ft = cp_svc.upsert_fullmoon_todo(
db,
protocol_id,
tenant_no,
user_id,
**payload,
)
return success_response(cp_svc._fullmoon_todo_to_dict(ft), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
class StorageCertHandleBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
remark: Optional[str] = None
class FullmoonHandleBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
handle_remark: Optional[str] = Field(None, alias="handleRemark")
def _attach_handler_name(db: Session, payload: Dict[str, Any]) -> Dict[str, Any]:
uid = (payload.get("handlerUserId") or "").strip()
name = ""
if uid:
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
name = (u.nick_name or "") if u else ""
payload["handlerUserName"] = name
return payload
@router.put("/storage-certs/{protocol_id}")
async def update_storage_cert_handle(
protocol_id: str,
body: StorageCertHandleBody,
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在")
try:
ht = parse_datetime(body.handle_time) if body.handle_time else None
sc = cp_svc.upsert_storage_cert_handle(
db,
protocol_id,
tenant_no,
user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
cert_image_urls=body.cert_image_urls,
remark=body.remark,
)
return success_response(
_attach_handler_name(db, cp_svc._storage_cert_to_dict(sc)), message="更新成功"
)
except ValueError as e:
return error_response(400, str(e))
@router.put("/fullmoon-todos/{protocol_id}/handle")
async def update_fullmoon_todo_handle(
protocol_id: str,
body: FullmoonHandleBody,
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在")
try:
ht = parse_datetime(body.handle_time) if body.handle_time else None
ft = cp_svc.upsert_fullmoon_todo_handle(
db,
protocol_id,
tenant_no,
user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
insurance_attachments=body.insurance_attachments,
handle_remark=body.handle_remark,
)
return success_response(
_attach_handler_name(db, cp_svc._fullmoon_todo_to_dict(ft)), message="更新成功"
)
except ValueError as e:
return error_response(400, str(e))
class InvoiceRequestCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
payment_detail_ids: List[str] = Field(..., alias="paymentDetailIds", min_length=1)
invoice_title: Optional[str] = Field(None, alias="invoiceTitle")
tax_no: Optional[str] = Field(None, alias="taxNo")
company_address: Optional[str] = Field(None, alias="companyAddress")
company_phone: Optional[str] = Field(None, alias="companyPhone")
bank_name: Optional[str] = Field(None, alias="bankName")
bank_account: Optional[str] = Field(None, alias="bankAccount")
apply_remark: Optional[str] = Field(None, alias="applyRemark")
class InvoiceRequestCompleteBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
invoice_attachments: str = Field(..., alias="invoiceAttachments")
finance_remark: Optional[str] = Field(None, alias="financeRemark")
@router.get("/collection-protocols/{protocol_id}/invoice-eligible-payments")
async def get_invoice_eligible_payments(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items = cp_svc.list_invoice_eligible_payment_details(
db, collection_protocol_id=protocol_id, tenant_no=tenant_no
)
return success_response({"list": items, "total": len(items)})
@router.get("/collection-protocols/{protocol_id}/invoice-requests")
async def get_protocol_invoice_requests(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items = cp_svc.list_invoice_requests_by_protocol(
db, collection_protocol_id=protocol_id, tenant_no=tenant_no
)
return success_response({"list": items, "total": len(items)})
@router.post("/collection-protocols/{protocol_id}/invoice-requests")
async def post_protocol_invoice_request(
protocol_id: str,
body: InvoiceRequestCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
try:
row = cp_svc.create_invoice_request(
db,
collection_protocol_id=protocol_id,
payment_detail_ids=body.payment_detail_ids,
tenant_no=tenant_no,
user_info=user,
invoice_title=body.invoice_title,
tax_no=body.tax_no,
company_address=body.company_address,
company_phone=body.company_phone,
bank_name=body.bank_name,
bank_account=body.bank_account,
apply_remark=body.apply_remark,
)
except ValueError as e:
return error_response(400, str(e))
return success_response(row, message="提交成功")
@router.get("/protocol-invoice-requests")
async def list_protocol_invoice_requests_api(
status: str = Query(..., description="dkp|ykp"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = Query(None),
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_protocol_invoice_requests(
db,
status=status,
page=page,
page_size=pageSize,
keyword=keyword,
tenant_no=tenant_no,
user_info=user,
)
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize})
@router.get("/protocol-invoice-requests/pending-count")
async def protocol_invoice_pending_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
count = cp_svc.count_pending_protocol_invoice_requests(
db, tenant_no=tenant_no, user_info=user
)
return success_response({"count": count})
@router.put("/protocol-invoice-requests/{request_id}/complete")
async def complete_protocol_invoice_request(
request_id: str,
body: InvoiceRequestCompleteBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
try:
row = cp_svc.complete_invoice_request(
db,
request_id=request_id,
tenant_no=tenant_no,
invoice_attachments=body.invoice_attachments,
finance_remark=body.finance_remark,
user_info=user,
)
except ValueError as e:
return error_response(400, str(e))
return success_response(row, message="已完成开票")