Sfoglia il codice sorgente

feat: add presign order CRUD, deposit transfers, and convert API

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 mese fa
parent
commit
fdd2c1fbc3

+ 2 - 0
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",

+ 386 - 0
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))

+ 763 - 0
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,
+    }

+ 2 - 1
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)

+ 30 - 0
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()