From fdd2c1fbc30c6ec81c74430bb447e8816db9ce2d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 3 Oct 2026 11:18:32 +0800 Subject: [PATCH] feat: add presign order CRUD, deposit transfers, and convert API Co-authored-by: Cursor --- backend/app/api/__init__.py | 2 + backend/app/api/presign_order.py | 386 ++++++++++++++ backend/app/crud/presign_order.py | 763 ++++++++++++++++++++++++++++ backend/app/main.py | 3 +- backend/tests/test_presign_order.py | 30 ++ 5 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/presign_order.py create mode 100644 backend/app/crud/presign_order.py create mode 100644 backend/tests/test_presign_order.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6099f02b..3fea9dbd 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -7,6 +7,7 @@ from app.api.basic_info import router as basic_info_router from app.api.collection_protocol import router as collection_protocol_router from app.api.protocol_sample_payment import router as protocol_sample_payment_router from app.api.protocol_refund_record import router as protocol_refund_record_router +from app.api.presign_order import router as presign_order_router from app.api.leave import router as leave_router from app.api.operations import router as operations_router from app.api.ethnic import router as ethnic_router @@ -48,6 +49,7 @@ __all__ = [ "collection_protocol_router", "protocol_sample_payment_router", "protocol_refund_record_router", + "presign_order_router", "leave_router", "operations_router", "ethnic_router", diff --git a/backend/app/api/presign_order.py b/backend/app/api/presign_order.py new file mode 100644 index 00000000..bb2ce1aa --- /dev/null +++ b/backend/app/api/presign_order.py @@ -0,0 +1,386 @@ +"""预签约订单 / 意向金转账 / 转正式 API""" + +from __future__ import annotations + +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 require_admin_or_super_admin +from app.core.response import error_response, success_response +from app.crud import presign_order as po_svc +from app.models.models import 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) + + +class PresignOrderBody(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + order_date: Optional[str] = Field(None, alias="orderDate") + protocol_no: Optional[str] = Field(None, alias="protocolNo") + mother_name: Optional[str] = Field(None, alias="motherName") + expected_delivery_date: Optional[str] = Field(None, alias="expectedDeliveryDate") + region_id: Optional[str] = Field(None, alias="regionId") + hospital_id: Optional[str] = Field(None, alias="hospitalId") + sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime") + pay_mode: Optional[str] = Field(None, alias="payMode") + contract_amount: Optional[float] = Field(None, alias="contractAmount") + sales_user_id: Optional[str] = Field(None, alias="salesUserId") + region_manager_user_id: Optional[str] = Field(None, alias="regionManagerUserId") + order_status: Optional[str] = Field(None, alias="orderStatus") + remark: Optional[str] = None + sample_type_ids: Optional[List[str]] = Field(None, alias="sampleTypeIds") + transfers: Optional[List[Dict[str, Any]]] = None + + +class PresignTransferBody(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + transfer_amount: Optional[float] = Field(None, alias="transferAmount") + transfer_time: Optional[str] = Field(None, alias="transferTime") + transfer_type: Optional[str] = Field(None, alias="transferType") + transfer_attachments: Optional[str] = Field(None, alias="transferAttachments") + remark: Optional[str] = None + + +class FinanceConfirmBody(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + finance_confirm_attachments: Optional[str] = Field( + None, alias="financeConfirmAttachments" + ) + finance_remark: Optional[str] = Field(None, alias="financeRemark") + + +@router.get("/presign-orders") +async def list_presign_orders( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + orderStatus: Optional[str] = Query(None, alias="orderStatus"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items, total = po_svc.list_presign_orders( + db, + tenant_no=tenant_no, + page=page, + page_size=pageSize, + keyword=keyword, + order_status=orderStatus, + user_info=user, + ) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize} + ) + except PermissionError as e: + return error_response(403, str(e)) + + +@router.get("/presign-orders/{order_id}") +async def get_presign_order( + order_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + data = po_svc.get_presign_order(db, order_id=order_id, tenant_no=tenant_no) + if not data: + return error_response(404, "预签约订单不存在") + return success_response(data) + except PermissionError as e: + return error_response(403, str(e)) + + +@router.post("/presign-orders") +async def create_presign_order( + body: PresignOrderBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + dumped = body.model_dump(by_alias=False) + obj = po_svc.create_presign_order( + db, + tenant_no=tenant_no, + user_id=user_id, + fields=dumped, + sample_type_ids=body.sample_type_ids, + transfers=body.transfers, + ) + return success_response( + po_svc.order_to_dict(db, obj, tenant_no, with_children=True), + message="创建成功", + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/presign-orders/{order_id}") +async def update_presign_order( + order_id: str, + body: PresignOrderBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + dumped = body.model_dump(by_alias=False, exclude_unset=True) + sample_type_ids = dumped.pop("sample_type_ids", None) + dumped.pop("transfers", None) + obj = po_svc.update_presign_order( + db, + order_id=order_id, + tenant_no=tenant_no, + user_id=user_id, + fields=dumped, + sample_type_ids=sample_type_ids, + ) + return success_response( + po_svc.order_to_dict(db, obj, tenant_no, with_children=True), + message="保存成功", + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/presign-orders/{order_id}") +async def delete_presign_order( + order_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + po_svc.soft_delete_presign_order( + db, order_id=order_id, tenant_no=tenant_no, user_id=user_id + ) + return success_response(None, message="删除成功") + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/presign-orders/{order_id}/convert") +async def convert_presign_order( + order_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + data = po_svc.convert_to_protocol( + db, order_id=order_id, tenant_no=tenant_no, user_id=user_id + ) + return success_response(data, message="已转正式协议") + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/presign-orders/{order_id}/transfers") +async def create_presign_transfer( + order_id: str, + body: PresignTransferBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.add_transfer( + db, + presign_order_id=order_id, + tenant_no=tenant_no, + user_id=user_id, + fields=body.model_dump(by_alias=False), + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="添加成功" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/presign-transfers/{transfer_id}") +async def update_presign_transfer( + transfer_id: str, + body: PresignTransferBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.update_transfer_draft( + db, + transfer_id=transfer_id, + tenant_no=tenant_no, + user_id=user_id, + fields=body.model_dump(by_alias=False, exclude_unset=True), + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="保存成功" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/presign-transfers/{transfer_id}/submit-receipt-finance") +async def submit_presign_transfer_finance( + transfer_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.submit_receipt_finance( + db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="已提交财务确认" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.get("/presign-transfers/receipt-confirm") +async def list_presign_receipt_confirm( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items, total = po_svc.list_presign_transfers_for_finance( + db, + pay_status="jfqrz", + tenant_no=tenant_no, + keyword=keyword, + page=page, + page_size=pageSize, + user_info=user, + ) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize} + ) + except PermissionError as e: + return error_response(403, str(e)) + + +@router.get("/presign-transfers/receipt-confirmed") +async def list_presign_receipt_confirmed( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items, total = po_svc.list_presign_transfers_for_finance( + db, + pay_status="yjf", + tenant_no=tenant_no, + keyword=keyword, + page=page, + page_size=pageSize, + user_info=user, + ) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize} + ) + except PermissionError as e: + return error_response(403, str(e)) + + +@router.post("/presign-transfers/{transfer_id}/receipt-confirm") +async def confirm_presign_transfer( + transfer_id: str, + body: Optional[FinanceConfirmBody] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.confirm_receipt( + db, + transfer_id=transfer_id, + tenant_no=tenant_no, + user_id=user_id, + finance_confirm_attachments=body.finance_confirm_attachments if body else None, + finance_remark=body.finance_remark if body else None, + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="确认收款成功" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/presign-transfers/{transfer_id}/receipt-reject") +async def reject_presign_transfer( + transfer_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + user_id = _get_current_user_id(user) + obj = po_svc.reject_receipt( + db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id + ) + return success_response( + po_svc._transfer_to_dict(db, obj, tenant_no), message="已退回" + ) + except PermissionError as e: + return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py new file mode 100644 index 00000000..a931f75d --- /dev/null +++ b/backend/app/crud/presign_order.py @@ -0,0 +1,763 @@ +"""预签约订单 / 意向金转账 / 转正式协议 CRUD""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from app.core.utils import generate_uuid, parse_datetime +from app.crud.collection_protocol import urls_to_attachment_list +from app.models.models import ( + BizCollectionProtocol, + BizPresignOrder, + BizPresignOrderSampleType, + BizPresignTransfer, + BizProtocolPartyCustomer, +) +from app.services.protocol_transfer_status import can_confirm_receipt, can_submit_receipt + + +def _now() -> datetime: + return datetime.now() + + +def _dec(v: Any) -> Optional[Decimal]: + if v is None or v == "": + return None + return Decimal(str(v)) + + +def assert_presign_convertible(*, order_status: Optional[str]) -> None: + st = (order_status or "").strip() or "draft" + if st == "converted": + raise ValueError("该预签约订单已转正式协议") + if st == "void": + raise ValueError("已作废的预签约订单不能转正式") + + +def _get_order_or_raise( + db: Session, *, order_id: str, tenant_no: str +) -> BizPresignOrder: + obj = ( + db.query(BizPresignOrder) + .filter( + BizPresignOrder.id == order_id, + BizPresignOrder.tenant_no == tenant_no, + BizPresignOrder.del_flag == 0, + ) + .first() + ) + if not obj: + raise ValueError("预签约订单不存在") + return obj + + +def _get_transfer_or_raise( + db: Session, *, transfer_id: str, tenant_no: str +) -> BizPresignTransfer: + obj = ( + db.query(BizPresignTransfer) + .filter( + BizPresignTransfer.id == transfer_id, + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + ) + .first() + ) + if not obj: + raise ValueError("意向金转账笔不存在") + return obj + + +def _transfer_to_dict( + db: Session, t: BizPresignTransfer, tenant_no: str +) -> Dict[str, Any]: + return { + "id": t.id, + "presignOrderId": t.presign_order_id, + "transferAmount": float(t.transfer_amount) if t.transfer_amount is not None else None, + "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if t.transfer_time else "", + "transferType": t.transfer_type or "", + "transferAttachments": t.transfer_attachments or "", + "attachments": urls_to_attachment_list(t.transfer_attachments, db, tenant_no), + "payStatus": t.pay_status or "", + "financeConfirmAttachments": t.finance_confirm_attachments or "", + "financeConfirmAttachmentFiles": urls_to_attachment_list( + t.finance_confirm_attachments, db, tenant_no + ), + "financeRemark": t.finance_remark or "", + "remark": t.remark or "", + "source": "presign", + "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "", + "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "", + } + + +def confirmed_deposit_amount( + db: Session, *, presign_order_id: str, tenant_no: str +) -> Decimal: + rows = ( + db.query(BizPresignTransfer.transfer_amount) + .filter( + BizPresignTransfer.presign_order_id == presign_order_id, + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + BizPresignTransfer.pay_status == "yjf", + ) + .all() + ) + total = Decimal("0") + for (amt,) in rows: + if amt is not None: + total += Decimal(str(amt)) + return total + + +def list_sample_type_ids( + db: Session, *, presign_order_id: str, tenant_no: str +) -> List[str]: + rows = ( + db.query(BizPresignOrderSampleType) + .filter( + BizPresignOrderSampleType.presign_order_id == presign_order_id, + BizPresignOrderSampleType.tenant_no == tenant_no, + BizPresignOrderSampleType.del_flag == 0, + ) + .order_by( + BizPresignOrderSampleType.order_num.asc(), + BizPresignOrderSampleType.create_time.asc(), + ) + .all() + ) + return [r.sample_type_id for r in rows if r.sample_type_id] + + +def replace_sample_types( + db: Session, + *, + presign_order_id: str, + tenant_no: str, + user_id: Optional[str], + sample_type_ids: List[str], +) -> None: + now = _now() + existing = ( + db.query(BizPresignOrderSampleType) + .filter( + BizPresignOrderSampleType.presign_order_id == presign_order_id, + BizPresignOrderSampleType.tenant_no == tenant_no, + BizPresignOrderSampleType.del_flag == 0, + ) + .all() + ) + for r in existing: + r.del_flag = 1 + r.update_by = user_id + r.update_time = now + db.add(r) + seen = set() + for idx, tid in enumerate(sample_type_ids or []): + sid = (tid or "").strip() + if not sid or sid in seen: + continue + seen.add(sid) + db.add( + BizPresignOrderSampleType( + id=generate_uuid(), + presign_order_id=presign_order_id, + sample_type_id=sid, + tenant_no=tenant_no, + order_num=idx, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + db.flush() + + +def list_transfers( + db: Session, *, presign_order_id: str, tenant_no: str +) -> List[Dict[str, Any]]: + rows = ( + db.query(BizPresignTransfer) + .filter( + BizPresignTransfer.presign_order_id == presign_order_id, + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + ) + .order_by(BizPresignTransfer.create_time.asc()) + .all() + ) + return [_transfer_to_dict(db, t, tenant_no) for t in rows] + + +def order_to_dict( + db: Session, o: BizPresignOrder, tenant_no: str, *, with_children: bool = True +) -> Dict[str, Any]: + deposit = confirmed_deposit_amount( + db, presign_order_id=o.id, tenant_no=tenant_no + ) + data: Dict[str, Any] = { + "id": o.id, + "orderDate": o.order_date or "", + "protocolNo": o.protocol_no or "", + "motherName": o.mother_name or "", + "expectedDeliveryDate": o.expected_delivery_date or "", + "regionId": o.region_id or "", + "hospitalId": o.hospital_id or "", + "sampleCollectTime": o.sample_collect_time.strftime("%Y-%m-%d %H:%M:%S") + if o.sample_collect_time + else "", + "payMode": o.pay_mode or "", + "contractAmount": float(o.contract_amount) if o.contract_amount is not None else None, + "salesUserId": o.sales_user_id or "", + "regionManagerUserId": o.region_manager_user_id or "", + "orderStatus": o.order_status or "", + "convertedProtocolId": o.converted_protocol_id or "", + "confirmedDepositAmount": float(deposit), + "remark": o.remark or "", + "createTime": o.create_time.strftime("%Y-%m-%d %H:%M:%S") if o.create_time else "", + "updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "", + } + if with_children: + data["sampleTypeIds"] = list_sample_type_ids( + db, presign_order_id=o.id, tenant_no=tenant_no + ) + data["transfers"] = list_transfers( + db, presign_order_id=o.id, tenant_no=tenant_no + ) + return data + + +def list_presign_orders( + db: Session, + *, + tenant_no: str, + page: int = 1, + page_size: int = 10, + keyword: Optional[str] = None, + order_status: Optional[str] = None, + user_info=None, +) -> Tuple[List[Dict[str, Any]], int]: + q = db.query(BizPresignOrder).filter( + BizPresignOrder.tenant_no == tenant_no, + BizPresignOrder.del_flag == 0, + ) + st = (order_status or "").strip() + if st: + q = q.filter(BizPresignOrder.order_status == st) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + BizPresignOrder.protocol_no.like(like), + BizPresignOrder.mother_name.like(like), + ) + ) + if user_info: + from app.core.data_scope import apply_data_scope + + q = apply_data_scope(q, BizPresignOrder, user_info, db, tenant_no) + total = q.order_by(None).with_entities(func.count(BizPresignOrder.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q.order_by(BizPresignOrder.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return [ + order_to_dict(db, o, tenant_no, with_children=False) for o in rows + ], int(total) + + +def get_presign_order( + db: Session, *, order_id: str, tenant_no: str +) -> Optional[Dict[str, Any]]: + obj = ( + db.query(BizPresignOrder) + .filter( + BizPresignOrder.id == order_id, + BizPresignOrder.tenant_no == tenant_no, + BizPresignOrder.del_flag == 0, + ) + .first() + ) + if not obj: + return None + return order_to_dict(db, obj, tenant_no, with_children=True) + + +def create_presign_order( + db: Session, + *, + tenant_no: str, + user_id: Optional[str], + fields: Dict[str, Any], + sample_type_ids: Optional[List[str]] = None, + transfers: Optional[List[Dict[str, Any]]] = None, +) -> BizPresignOrder: + now = _now() + sample_collect = fields.get("sample_collect_time") or fields.get("sampleCollectTime") + sct = None + if sample_collect not in (None, ""): + sct = ( + sample_collect + if isinstance(sample_collect, datetime) + else parse_datetime(str(sample_collect)) + ) + pay_mode = (fields.get("pay_mode") or fields.get("payMode") or "").strip() or None + if pay_mode and pay_mode not in ("dj", "fq"): + raise ValueError("缴费方式无效") + obj = BizPresignOrder( + id=generate_uuid(), + order_date=(fields.get("order_date") or fields.get("orderDate") or "").strip() + or None, + protocol_no=(fields.get("protocol_no") or fields.get("protocolNo") or "").strip() + or None, + mother_name=(fields.get("mother_name") or fields.get("motherName") or "").strip() + or None, + expected_delivery_date=( + fields.get("expected_delivery_date") + or fields.get("expectedDeliveryDate") + or "" + ).strip() + or None, + region_id=(fields.get("region_id") or fields.get("regionId") or "").strip() or None, + hospital_id=(fields.get("hospital_id") or fields.get("hospitalId") or "").strip() + or None, + sample_collect_time=sct, + pay_mode=pay_mode, + contract_amount=_dec(fields.get("contract_amount", fields.get("contractAmount"))), + sales_user_id=(fields.get("sales_user_id") or fields.get("salesUserId") or "").strip() + or None, + region_manager_user_id=( + fields.get("region_manager_user_id") + or fields.get("regionManagerUserId") + or "" + ).strip() + or None, + order_status=(fields.get("order_status") or fields.get("orderStatus") or "active") + .strip() + or "active", + tenant_no=tenant_no, + order_num=0, + remark=(fields.get("remark") or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + db.flush() + replace_sample_types( + db, + presign_order_id=obj.id, + tenant_no=tenant_no, + user_id=user_id, + sample_type_ids=sample_type_ids or [], + ) + for raw in transfers or []: + if isinstance(raw, dict): + add_transfer( + db, + presign_order_id=obj.id, + tenant_no=tenant_no, + user_id=user_id, + fields=raw, + commit=False, + ) + db.commit() + db.refresh(obj) + return obj + + +def update_presign_order( + db: Session, + *, + order_id: str, + tenant_no: str, + user_id: Optional[str], + fields: Dict[str, Any], + sample_type_ids: Optional[List[str]] = None, +) -> BizPresignOrder: + obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) + if (obj.order_status or "") == "converted": + raise ValueError("已转正式的预签约订单不可修改") + mapping = { + "order_date": "order_date", + "orderDate": "order_date", + "protocol_no": "protocol_no", + "protocolNo": "protocol_no", + "mother_name": "mother_name", + "motherName": "mother_name", + "expected_delivery_date": "expected_delivery_date", + "expectedDeliveryDate": "expected_delivery_date", + "region_id": "region_id", + "regionId": "region_id", + "hospital_id": "hospital_id", + "hospitalId": "hospital_id", + "pay_mode": "pay_mode", + "payMode": "pay_mode", + "sales_user_id": "sales_user_id", + "salesUserId": "sales_user_id", + "region_manager_user_id": "region_manager_user_id", + "regionManagerUserId": "region_manager_user_id", + "order_status": "order_status", + "orderStatus": "order_status", + "remark": "remark", + } + for k, attr in mapping.items(): + if k in fields: + val = fields[k] + if attr in ( + "order_date", + "protocol_no", + "mother_name", + "expected_delivery_date", + "region_id", + "hospital_id", + "pay_mode", + "sales_user_id", + "region_manager_user_id", + "order_status", + "remark", + ): + setattr(obj, attr, (str(val).strip() if val is not None else "") or None) + if "contract_amount" in fields or "contractAmount" in fields: + obj.contract_amount = _dec( + fields.get("contract_amount", fields.get("contractAmount")) + ) + if "sample_collect_time" in fields or "sampleCollectTime" in fields: + v = fields.get("sample_collect_time", fields.get("sampleCollectTime")) + if v in (None, ""): + obj.sample_collect_time = None + elif isinstance(v, datetime): + obj.sample_collect_time = v + else: + obj.sample_collect_time = parse_datetime(str(v)) + if obj.pay_mode and obj.pay_mode not in ("dj", "fq"): + raise ValueError("缴费方式无效") + obj.update_by = user_id + obj.update_time = _now() + db.add(obj) + if sample_type_ids is not None: + replace_sample_types( + db, + presign_order_id=obj.id, + tenant_no=tenant_no, + user_id=user_id, + sample_type_ids=sample_type_ids, + ) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_presign_order( + db: Session, *, order_id: str, tenant_no: str, user_id: Optional[str] +) -> None: + obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) + if (obj.order_status or "") == "converted": + raise ValueError("已转正式的预签约订单不可删除") + now = _now() + obj.del_flag = 1 + obj.update_by = user_id + obj.update_time = now + db.add(obj) + db.commit() + + +def add_transfer( + db: Session, + *, + presign_order_id: str, + tenant_no: str, + user_id: Optional[str], + fields: Dict[str, Any], + commit: bool = True, +) -> BizPresignTransfer: + _get_order_or_raise(db, order_id=presign_order_id, tenant_no=tenant_no) + now = _now() + tt = fields.get("transfer_time", fields.get("transferTime")) + transfer_time = None + if tt not in (None, ""): + transfer_time = tt if isinstance(tt, datetime) else parse_datetime(str(tt)) + obj = BizPresignTransfer( + id=generate_uuid(), + presign_order_id=presign_order_id, + transfer_amount=_dec(fields.get("transfer_amount", fields.get("transferAmount"))), + transfer_time=transfer_time, + transfer_type=( + fields.get("transfer_type") or fields.get("transferType") or "" + ).strip() + or None, + transfer_attachments=( + fields.get("transfer_attachments") + or fields.get("transferAttachments") + or "" + ).strip() + or None, + pay_status="dfj", + tenant_no=tenant_no, + order_num=0, + remark=(fields.get("remark") or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() + return obj + + +def update_transfer_draft( + db: Session, + *, + transfer_id: str, + tenant_no: str, + user_id: Optional[str], + fields: Dict[str, Any], +) -> BizPresignTransfer: + t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) + if t.pay_status != "dfj": + raise ValueError("仅待缴费状态可编辑意向金转账笔") + if "transfer_amount" in fields or "transferAmount" in fields: + t.transfer_amount = _dec( + fields.get("transfer_amount", fields.get("transferAmount")) + ) + if "transfer_time" in fields or "transferTime" in fields: + v = fields.get("transfer_time", fields.get("transferTime")) + if v in (None, ""): + t.transfer_time = None + elif isinstance(v, datetime): + t.transfer_time = v + else: + t.transfer_time = parse_datetime(str(v)) + if "transfer_type" in fields or "transferType" in fields: + t.transfer_type = ( + str(fields.get("transfer_type", fields.get("transferType")) or "") + ).strip() or None + if "transfer_attachments" in fields or "transferAttachments" in fields: + t.transfer_attachments = ( + str( + fields.get("transfer_attachments", fields.get("transferAttachments")) + or "" + ) + ).strip() or None + if "remark" in fields: + t.remark = (fields.get("remark") or "").strip() or None + t.update_by = user_id + t.update_time = _now() + db.add(t) + db.commit() + db.refresh(t) + return t + + +def submit_receipt_finance( + db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str] +) -> BizPresignTransfer: + t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) + if not can_submit_receipt(t.pay_status): + raise ValueError("仅待缴费状态可提交财务确认") + if not (t.transfer_type or "").strip(): + raise ValueError("请选择转账方式") + if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0: + raise ValueError("请填写转账金额") + t.pay_status = "jfqrz" + t.update_by = user_id + t.update_time = _now() + db.add(t) + db.commit() + db.refresh(t) + return t + + +def confirm_receipt( + db: Session, + *, + transfer_id: str, + tenant_no: str, + user_id: Optional[str], + finance_confirm_attachments: Optional[str] = None, + finance_remark: Optional[str] = None, +) -> BizPresignTransfer: + t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) + if not can_confirm_receipt(t.pay_status): + raise ValueError("仅缴费确认中状态可确认收款") + t.pay_status = "yjf" + if finance_confirm_attachments is not None: + t.finance_confirm_attachments = (finance_confirm_attachments or "").strip() or None + if finance_remark is not None: + t.finance_remark = (finance_remark or "").strip() or None + t.update_by = user_id + t.update_time = _now() + db.add(t) + db.commit() + db.refresh(t) + return t + + +def reject_receipt( + db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str] +) -> BizPresignTransfer: + t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no) + if not can_confirm_receipt(t.pay_status): + raise ValueError("仅缴费确认中状态可退回") + t.pay_status = "dfj" + t.update_by = user_id + t.update_time = _now() + db.add(t) + db.commit() + db.refresh(t) + return t + + +def list_presign_transfers_for_finance( + db: Session, + *, + pay_status: str, + tenant_no: str, + keyword: Optional[str] = None, + page: int = 1, + page_size: int = 10, + user_info=None, +) -> Tuple[List[Dict[str, Any]], int]: + if not tenant_no: + return [], 0 + st = (pay_status or "").strip() + q = ( + db.query(BizPresignTransfer, BizPresignOrder) + .join( + BizPresignOrder, + and_( + BizPresignOrder.id == BizPresignTransfer.presign_order_id, + BizPresignOrder.del_flag == 0, + ), + ) + .filter( + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + BizPresignTransfer.pay_status == st, + ) + ) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + BizPresignOrder.protocol_no.like(like), + BizPresignOrder.mother_name.like(like), + ) + ) + if user_info: + from app.core.data_scope import apply_data_scope + + q = apply_data_scope(q, BizPresignTransfer, user_info, db, tenant_no) + total = q.order_by(None).with_entities(func.count(BizPresignTransfer.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q.order_by(BizPresignTransfer.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + out: List[Dict[str, Any]] = [] + for t, order in rows: + item = _transfer_to_dict(db, t, tenant_no) + item["protocolNo"] = (order.protocol_no if order else "") or "" + item["motherName"] = (order.mother_name if order else "") or "" + item["sampleCode"] = "" + item["presignOrderId"] = order.id if order else "" + out.append(item) + return out, int(total) + + +def convert_to_protocol( + db: Session, + *, + order_id: str, + tenant_no: str, + user_id: Optional[str], +) -> Dict[str, Any]: + """转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;不写入样本缴费金额。""" + from app.crud import collection_protocol as cp_svc + + order = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) + assert_presign_convertible(order_status=order.order_status) + + type_ids = list_sample_type_ids(db, presign_order_id=order.id, tenant_no=tenant_no) + protocol = cp_svc.create_collection_protocol( + db, + protocol_no=order.protocol_no or "", + protocol_status="dsx", + register_date=order.order_date, + collector_user_id=user_id, + source_presign_order_id=order.id, + payment_model_version=2, + protocol_total_amount=None, + remark=order.remark, + tenant_no=tenant_no, + user_id=user_id, + ) + party_fields: Dict[str, Any] = { + "mother_name": order.mother_name, + "expected_delivery_date": order.expected_delivery_date, + } + cp_svc.upsert_party_customer( + db, protocol.id, tenant_no, user_id, **party_fields + ) + collection_fields: Dict[str, Any] = { + "region_id": order.region_id, + "hospital_id": order.hospital_id, + "sales_user_id": order.sales_user_id, + "sample_collect_time": order.sample_collect_time, + } + cp_svc.upsert_collection_info( + db, protocol.id, tenant_no, user_id, **collection_fields + ) + if type_ids: + samples = [ + { + "sampleTypeId": tid, + "sampleStatus": None, + } + for tid in type_ids + ] + cp_svc.save_samples(db, protocol.id, tenant_no, user_id, samples) + + order.order_status = "converted" + order.converted_protocol_id = protocol.id + if not (order.protocol_no or "").strip(): + order.protocol_no = protocol.protocol_no + order.update_by = user_id + order.update_time = _now() + db.add(order) + db.commit() + db.refresh(order) + return { + "presignOrder": order_to_dict(db, order, tenant_no, with_children=True), + "protocolId": protocol.id, + "protocolNo": protocol.protocol_no, + } diff --git a/backend/app/main.py b/backend/app/main.py index 75917796..509079bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.core.database import SessionLocal from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.models import models # noqa: F401 -from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router +from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router @asynccontextmanager @@ -119,6 +119,7 @@ app.include_router(basic_info_router, prefix=settings.API_V1_PREFIX) app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX) app.include_router(protocol_sample_payment_router, prefix=settings.API_V1_PREFIX) app.include_router(protocol_refund_record_router, prefix=settings.API_V1_PREFIX) +app.include_router(presign_order_router, prefix=settings.API_V1_PREFIX) app.include_router(leave_router, prefix=settings.API_V1_PREFIX) app.include_router(operations_router, prefix=settings.API_V1_PREFIX) app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/tests/test_presign_order.py b/backend/tests/test_presign_order.py new file mode 100644 index 00000000..a80d284b --- /dev/null +++ b/backend/tests/test_presign_order.py @@ -0,0 +1,30 @@ +"""预签约订单:意向金状态机与转正约束单测""" + +import unittest + +from app.services.protocol_transfer_status import can_confirm_receipt, can_submit_receipt + + +class TestPresignTransferStatus(unittest.TestCase): + def test_presign_transfer_same_status_machine(self): + self.assertTrue(can_submit_receipt("dfj")) + self.assertFalse(can_submit_receipt("jfqrz")) + self.assertFalse(can_submit_receipt("yjf")) + self.assertTrue(can_confirm_receipt("jfqrz")) + self.assertFalse(can_confirm_receipt("dfj")) + + +class TestConvertRules(unittest.TestCase): + def test_convert_requires_active_status(self): + from app.crud.presign_order import assert_presign_convertible + + with self.assertRaises(ValueError): + assert_presign_convertible(order_status="converted") + with self.assertRaises(ValueError): + assert_presign_convertible(order_status="void") + assert_presign_convertible(order_status="active") + assert_presign_convertible(order_status="draft") + + +if __name__ == "__main__": + unittest.main()