2026-05-27-clinical-application.md 44 KB

临床应用业务 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在现有中试服务基础上,通过 biz_type 区分新增临床应用业务线,包含独立缴费流程(缴费信息/缴费确认/退费确认三个菜单)。

Architecture: 复用中试服务的 biz_pilot_application 表体系,新增 biz_type 字段区分两条线。缴费参照细胞存储 biz_protocol_payment 模式,但简化为单表趸交(一个输注计划一条缴费记录)。前端复用 pilot-service 目录下的页面组件,通过路由 prop 区分行为。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2(后端),Vue 3 + Element Plus + TypeScript(前端),MySQL 5.7

Design Spec: docs/superpowers/specs/2026-05-27-clinical-application-design.md


File Structure

Backend — New Files

  • backend/app/models/clinical_payment.py — BizPilotPayment 模型
  • backend/app/crud/clinical_payment.py — 缴费 CRUD(参照 collection_protocol.py 中缴费函数)
  • backend/app/api/clinical_payment.py — 缴费 API 路由(10 个端点)
  • backend/app/schemas/clinical_payment.py — Pydantic schemas

Backend — Modified Files

  • backend/app/models/models.py — BizPilotApplication 加 biz_type,BizPilotInfusionSchedule 加 amount
  • backend/app/crud/pilot_application.py — create_application / _row_to_dict_application / _generate_application_no / list_applications 加 biz_type 支持
  • backend/app/api/pilot_application.py — 路由参数加 biz_type
  • backend/app/api/__init__.py — 注册 clinical_payment_router
  • backend/app/main.py — 挂载新路由

Frontend — New Files

  • web-admin/src/views/pilot-service/clinical-payment-info/index.vue — 缴费信息页面
  • web-admin/src/views/pilot-service/clinical-payment-confirm/index.vue — 缴费确认页面
  • web-admin/src/views/pilot-service/clinical-refund-confirm/index.vue — 退费确认页面

Frontend — Modified Files

  • web-admin/src/router/modules/sqxp.ts — 新增临床应用管理一级菜单 + 7 个子路由
  • web-admin/src/api/business.ts — 新增 clinical payment API 函数
  • web-admin/src/views/pilot-service/pilot-application/index.vue — 接收 bizType prop,条件渲染
  • web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue — 临床应用模式隐藏渠道订单
  • web-admin/src/views/pilot-service/infusion-schedule-management/index.vue — 临床应用显示金额列

SQL

  • docs/sql/clinical-application-migration.sql — ALTER + CREATE TABLE

Task 1: 数据库迁移 SQL

Files:

  • Create: docs/sql/clinical-application-migration.sql

  • [ ] Step 1: 编写迁移 SQL

-- ============================================================
-- 临床应用业务 - 数据库迁移
-- ============================================================

-- 1. biz_pilot_application 新增 biz_type 字段
ALTER TABLE biz_pilot_application
  ADD COLUMN biz_type VARCHAR(20) NOT NULL DEFAULT 'pilot_service'
    COMMENT '业务类型: pilot_service=中试服务, clinical_application=临床应用'
  AFTER channel_name;

-- 2. biz_pilot_infusion_schedule 新增 amount 字段
ALTER TABLE biz_pilot_infusion_schedule
  ADD COLUMN amount DECIMAL(18,2) DEFAULT NULL
    COMMENT '临床应用-手动填写的输注金额(中试为NULL)'
  AFTER qc_status;

-- 3. 新建 biz_pilot_payment 缴费记录表
CREATE TABLE biz_pilot_payment (
  id VARCHAR(64) NOT NULL COMMENT '主键ID',
  pilot_application_id VARCHAR(64) NOT NULL COMMENT '临床申请单ID',
  infusion_schedule_id VARCHAR(64) NOT NULL COMMENT '输注计划ID',
  biz_type VARCHAR(20) NOT NULL DEFAULT 'clinical_application' COMMENT '业务类型',
  amount DECIMAL(18,2) NOT NULL COMMENT '缴费金额',
  transfer_type VARCHAR(16) DEFAULT NULL COMMENT '转账方式: wxzz/zzfbzz/yhkzz/xj',
  transfer_amount DECIMAL(18,2) DEFAULT NULL COMMENT '实际转账金额',
  transfer_time DATETIME DEFAULT NULL COMMENT '转账时间',
  transfer_attachments TEXT DEFAULT NULL COMMENT '转账凭证附件',
  pay_status VARCHAR(32) NOT NULL DEFAULT 'dfj' COMMENT '缴费状态: dfj/jfqrz/yjf/tfqrz/ytf',
  finance_confirm_attachments TEXT DEFAULT NULL COMMENT '财务确认收款附件',
  finance_remark VARCHAR(512) DEFAULT NULL COMMENT '财务确认收款备注',
  finance_confirm_time DATETIME DEFAULT NULL COMMENT '财务确认收款时间',
  refund_amount DECIMAL(18,2) DEFAULT NULL COMMENT '退费金额',
  refund_remark VARCHAR(512) DEFAULT NULL COMMENT '退费备注',
  refund_attachments TEXT DEFAULT NULL COMMENT '退费附件',
  refund_apply_by VARCHAR(64) DEFAULT NULL COMMENT '退费申请人ID',
  refund_apply_time DATETIME DEFAULT NULL COMMENT '退费申请时间',
  refund_finance_confirm_attachments TEXT DEFAULT NULL COMMENT '退费财务确认附件',
  refund_finance_remark VARCHAR(512) DEFAULT NULL COMMENT '退费财务确认备注',
  refund_finance_confirm_time DATETIME DEFAULT NULL COMMENT '退费财务确认时间',
  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  order_num INT(11) DEFAULT NULL COMMENT '排序号',
  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  del_flag TINYINT(4) NOT NULL DEFAULT '0' COMMENT '删除标记: 0-正常 1-已删除',
  PRIMARY KEY (id),
  UNIQUE KEY uk_infusion_schedule (infusion_schedule_id, del_flag),
  KEY idx_pilot_app (pilot_application_id, del_flag),
  KEY idx_pay_status (pay_status, del_flag),
  KEY idx_tenant (tenant_no, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='临床应用缴费记录';
  • Step 2: 在数据库执行迁移

Run: mysql -u root -p zkxw < docs/sql/clinical-application-migration.sql

  • Step 3: Commit
git add docs/sql/clinical-application-migration.sql
git commit -m "feat: add clinical application DB migration (biz_type + biz_pilot_payment)"

Task 2: 后端模型层

Files:

  • Modify: backend/app/models/models.py — BizPilotApplication 和 BizPilotInfusionSchedule
  • Create: backend/app/models/clinical_payment.py — BizPilotPayment

  • [ ] Step 1: 在 BizPilotApplication 类中添加 biz_type 字段

在 backend/app/models/models.py 中 BizPilotApplication 类的 channel_name 字段后面添加:

biz_type = Column(String(20), nullable=False, default="pilot_service", comment="业务类型: pilot_service=中试服务, clinical_application=临床应用")
  • Step 2: 在 BizPilotInfusionSchedule 类中添加 amount 字段

在 backend/app/models/models.py 中 BizPilotInfusionSchedule 类的 qc_status 字段后面添加:

amount = Column(Numeric(18, 2), nullable=True, comment="临床应用-手动填写的输注金额(中试为NULL)")
  • Step 3: 创建 backend/app/models/clinical_payment.py
"""临床应用缴费记录模型"""
from sqlalchemy import Column, String, Text, SmallInteger, Numeric, DateTime
from sqlalchemy.sql import func
from app.models.base import Base
from decimal import Decimal


class BizPilotPayment(Base):
    """临床应用缴费记录 biz_pilot_payment"""

    __tablename__ = "biz_pilot_payment"

    id = Column(String(64), primary_key=True, comment="主键ID")
    pilot_application_id = Column(String(64), nullable=False, comment="临床申请单ID")
    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
    biz_type = Column(String(20), nullable=False, default="clinical_application", comment="业务类型")
    amount = Column(Numeric(18, 2), nullable=False, comment="缴费金额")
    transfer_type = Column(String(16), nullable=True, comment="转账方式: wxzz/zzfbzz/yhkzz/xj")
    transfer_amount = Column(Numeric(18, 2), nullable=True, comment="实际转账金额")
    transfer_time = Column(DateTime, nullable=True, comment="转账时间")
    transfer_attachments = Column(Text, nullable=True, comment="转账凭证附件")
    pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态: dfj/jfqrz/yjf/tfqrz/ytf")
    finance_confirm_attachments = Column(Text, nullable=True, comment="财务确认收款附件")
    finance_remark = Column(String(512), nullable=True, comment="财务确认收款备注")
    finance_confirm_time = Column(DateTime, nullable=True, comment="财务确认收款时间")
    refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额")
    refund_remark = Column(String(512), nullable=True, comment="退费备注")
    refund_attachments = Column(Text, nullable=True, comment="退费附件")
    refund_apply_by = Column(String(64), nullable=True, comment="退费申请人ID")
    refund_apply_time = Column(DateTime, nullable=True, comment="退费申请时间")
    refund_finance_confirm_attachments = Column(Text, nullable=True, comment="退费财务确认附件")
    refund_finance_remark = Column(String(512), nullable=True, comment="退费财务确认备注")
    refund_finance_confirm_time = Column(DateTime, nullable=True, comment="退费财务确认时间")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(SmallInteger, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
  • Step 4: 在 models.py 末尾导入新模型

在 backend/app/models/models.py 末尾已有的 import 行后面添加:

from app.models.clinical_payment import BizPilotPayment  # noqa: E402,F401
  • Step 5: 验证模型加载

Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizPilotApplication, BizPilotInfusionSchedule; from app.models.clinical_payment import BizPilotPayment; print('OK')" Expected: OK

  • Step 6: Commit
git add backend/app/models/models.py backend/app/models/clinical_payment.py
git commit -m "feat: add biz_type to pilot tables + BizPilotPayment model"

Task 3: 后端 CRUD — clinical_payment.py

Files:

  • Create: backend/app/crud/clinical_payment.py

参照 backend/app/crud/collection_protocol.py 中 confirm_receipt (line 1390)、confirm_refund (line 1522)、submit_receipt_finance (line 1595)、submit_refund_finance (line 1648) 的模式。

  • Step 1: 创建 CRUD 文件

创建 backend/app/crud/clinical_payment.py,包含以下函数:

"""临床应用缴费 CRUD"""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import and_, func
from sqlalchemy.orm import Session

from app.models.clinical_payment import BizPilotPayment
from app.models.models import (
    BizPilotApplication,
    BizPilotPatient,
    BizPilotInfusionPlan,
    BizPilotInfusionSchedule,
)


# ─── helpers ──────────────────────────────────────────────


def _row_to_dict(row: BizPilotPayment, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    d: Dict[str, Any] = {
        "id": row.id,
        "pilotApplicationId": row.pilot_application_id or "",
        "infusionScheduleId": row.infusion_schedule_id or "",
        "bizType": row.biz_type or "clinical_application",
        "amount": float(row.amount) if row.amount else 0,
        "transferType": row.transfer_type or "",
        "transferAmount": float(row.transfer_amount) if row.transfer_amount else None,
        "transferTime": row.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if row.transfer_time else "",
        "transferAttachments": row.transfer_attachments or "",
        "payStatus": row.pay_status or "dfj",
        "financeConfirmAttachments": row.finance_confirm_attachments or "",
        "financeRemark": row.finance_remark or "",
        "financeConfirmTime": row.finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.finance_confirm_time else "",
        "refundAmount": float(row.refund_amount) if row.refund_amount else None,
        "refundRemark": row.refund_remark or "",
        "refundAttachments": row.refund_attachments or "",
        "refundApplyBy": row.refund_apply_by or "",
        "refundApplyTime": row.refund_apply_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_apply_time else "",
        "refundFinanceConfirmAttachments": row.refund_finance_confirm_attachments or "",
        "refundFinanceRemark": row.refund_finance_remark or "",
        "refundFinanceConfirmTime": row.refund_finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_finance_confirm_time else "",
        "tenantNo": row.tenant_no or "",
        "remark": row.remark or "",
        "createBy": row.create_by or "",
        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
    }
    if extra:
        d.update(extra)
    return d


def _get_or_raise(db: Session, *, payment_id: str, tenant_no: str) -> BizPilotPayment:
    row = (
        db.query(BizPilotPayment)
        .filter(
            and_(
                BizPilotPayment.id == payment_id,
                BizPilotPayment.tenant_no == tenant_no,
                BizPilotPayment.del_flag == 0,
            )
        )
        .first()
    )
    if not row:
        raise ValueError("缴费记录不存在")
    return row


def _enrich_payment(db: Session, row: BizPilotPayment) -> Dict[str, Any]:
    """关联查询申请单、患者、输注计划信息,附加到 dict"""
    app_row = (
        db.query(BizPilotApplication)
        .filter(BizPilotApplication.id == row.pilot_application_id)
        .first()
    )
    patient_row = None
    schedule_row = (
        db.query(BizPilotInfusionSchedule)
        .filter(BizPilotInfusionSchedule.id == row.infusion_schedule_id)
        .first()
    )
    if app_row:
        patient_row = (
            db.query(BizPilotPatient)
            .filter(
                BizPilotPatient.pilot_application_id == app_row.id,
                BizPilotPatient.del_flag == 0,
            )
            .first()
        )
    extra = {
        "applicationNo": app_row.application_no if app_row else "",
        "applicationName": app_row.application_name if app_row else "",
        "channelName": app_row.channel_name if app_row else "",
        "patientName": patient_row.patient_name if patient_row else "",
        "cellType": schedule_row.cell_type if schedule_row else "",
        "specText": schedule_row.spec_text if schedule_row else "",
        "infusionTime": schedule_row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if schedule_row and schedule_row.infusion_time else "",
    }
    return _row_to_dict(row, extra=extra)


# ─── create (auto) ────────────────────────────────────────


def auto_create_payment(
    db: Session,
    *,
    application_id: str,
    schedule_id: str,
    amount: Any,
    tenant_no: str,
    user_id: str,
    dept_id: str,
) -> Optional[BizPilotPayment]:
    """输注计划创建时自动调用,生成 dfj 缴费记录"""
    if amount is None or Decimal(str(amount)) <= 0:
        return None
    now = datetime.now()
    row = BizPilotPayment(
        id=str(uuid.uuid4()),
        pilot_application_id=application_id,
        infusion_schedule_id=schedule_id,
        biz_type="clinical_application",
        amount=Decimal(str(amount)),
        pay_status="dfj",
        tenant_no=tenant_no,
        order_num=0,
        create_by=user_id,
        create_dept_id=dept_id or "",
        create_time=now,
        update_by=user_id,
        update_time=now,
        del_flag=0,
    )
    db.add(row)
    return row


# ─── list ─────────────────────────────────────────────────


def list_payments(
    db: Session,
    *,
    page: int = 1,
    page_size: int = 10,
    pay_status: Optional[str] = None,
    keyword: Optional[str] = None,
    tenant_no: str = "",
    user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
    q = (
        db.query(BizPilotPayment)
        .filter(
            BizPilotPayment.del_flag == 0,
            BizPilotPayment.tenant_no == tenant_no,
        )
    )
    if pay_status and pay_status.strip():
        q = q.filter(BizPilotPayment.pay_status == pay_status.strip())
    if keyword and keyword.strip():
        kw = f"%{keyword.strip()}%"
        sub = (
            db.query(BizPilotApplication.id)
            .filter(
                BizPilotApplication.del_flag == 0,
                BizPilotApplication.application_no.like(kw),
            )
            .subquery()
        )
        q = q.filter(BizPilotPayment.pilot_application_id.in_(sub))
    if user_info:
        from app.core.data_scope import apply_data_scope
        q = apply_data_scope(q, BizPilotPayment, user_info, db, tenant_no)
    total = q.order_by(None).with_entities(func.count(BizPilotPayment.id)).scalar() or 0
    page = max(1, page)
    page_size = min(max(1, page_size), 100)
    rows = (
        q.order_by(BizPilotPayment.create_time.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )
    out = [_enrich_payment(db, r) for r in rows]
    return out, int(total)


# ─── submit receipt (dfj → jfqrz) ────────────────────────


def submit_receipt(
    db: Session,
    *,
    payment_id: str,
    tenant_no: str,
    user_id: Optional[str],
) -> BizPilotPayment:
    row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
    if row.pay_status != "dfj":
        raise ValueError("仅待缴费状态可提交缴费确认")
    row.pay_status = "jfqrz"
    row.update_by = user_id
    row.update_time = datetime.now()
    db.add(row)
    db.commit()
    db.refresh(row)
    return row


def batch_submit_receipt(
    db: Session,
    *,
    payment_ids: List[str],
    tenant_no: str,
    user_id: Optional[str],
) -> int:
    ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()]
    if not ids:
        raise ValueError("请选择缴费记录")
    for pid in ids:
        row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no)
        if row.pay_status != "dfj":
            continue
        row.pay_status = "jfqrz"
        row.update_by = user_id
        row.update_time = datetime.now()
        db.add(row)
    db.commit()
    return len(ids)


# ─── confirm receipt (jfqrz → yjf) ──────────────────────


def confirm_receipt(
    db: Session,
    *,
    payment_id: str,
    tenant_no: str,
    user_id: Optional[str],
    finance_confirm_attachments: Optional[str] = None,
    finance_remark: Optional[str] = None,
) -> BizPilotPayment:
    row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
    if row.pay_status != "jfqrz":
        if row.pay_status == "yjf":
            raise ValueError("该记录已确认收款")
        raise ValueError("当前状态不可确认收款")
    row.pay_status = "yjf"
    if finance_confirm_attachments is not None:
        row.finance_confirm_attachments = finance_confirm_attachments.strip() or None
    if finance_remark is not None:
        row.finance_remark = finance_remark.strip() or None
    row.finance_confirm_time = datetime.now()
    row.update_by = user_id
    row.update_time = datetime.now()
    db.add(row)
    db.commit()
    db.refresh(row)
    return row


# ─── submit refund (yjf → tfqrz) ─────────────────────────


def submit_refund(
    db: Session,
    *,
    payment_id: str,
    tenant_no: str,
    user_id: Optional[str],
    refund_amount: Any,
    refund_remark: Optional[str] = None,
    refund_attachments: Optional[str] = None,
) -> BizPilotPayment:
    row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
    if row.pay_status != "yjf":
        raise ValueError("仅已缴费状态可发起退费")
    if refund_amount is None or Decimal(str(refund_amount)) <= 0:
        raise ValueError("请填写有效的退费金额")
    row.pay_status = "tfqrz"
    row.refund_amount = Decimal(str(refund_amount))
    row.refund_remark = (refund_remark or "").strip() or None
    row.refund_attachments = (refund_attachments or "").strip() or None
    row.refund_apply_by = user_id
    row.refund_apply_time = datetime.now()
    row.update_by = user_id
    row.update_time = datetime.now()
    db.add(row)
    db.commit()
    db.refresh(row)
    return row


def batch_submit_refund(
    db: Session,
    *,
    payment_ids: List[str],
    tenant_no: str,
    user_id: Optional[str],
    refund_amount: Any,
    refund_remark: Optional[str] = None,
    refund_attachments: Optional[str] = None,
) -> int:
    ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()]
    if not ids:
        raise ValueError("请选择缴费记录")
    for pid in ids:
        row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no)
        if row.pay_status != "yjf":
            continue
        if refund_amount is None or Decimal(str(refund_amount)) <= 0:
            continue
        row.pay_status = "tfqrz"
        row.refund_amount = Decimal(str(refund_amount))
        row.refund_remark = (refund_remark or "").strip() or None
        row.refund_attachments = (refund_attachments or "").strip() or None
        row.refund_apply_by = user_id
        row.refund_apply_time = datetime.now()
        row.update_by = user_id
        row.update_time = datetime.now()
        db.add(row)
    db.commit()
    return len(ids)


# ─── confirm refund (tfqrz → ytf) ───────────────────────


def confirm_refund(
    db: Session,
    *,
    payment_id: str,
    tenant_no: str,
    user_id: Optional[str],
    refund_finance_confirm_attachments: Optional[str] = None,
    refund_finance_remark: Optional[str] = None,
) -> BizPilotPayment:
    row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
    if row.pay_status != "tfqrz":
        if row.pay_status == "ytf":
            raise ValueError("该记录已确认退费")
        raise ValueError("当前状态不可确认退费")
    row.pay_status = "ytf"
    if refund_finance_confirm_attachments is not None:
        row.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None
    if refund_finance_remark is not None:
        row.refund_finance_remark = refund_finance_remark.strip() or None
    row.refund_finance_confirm_time = datetime.now()
    row.update_by = user_id
    row.update_time = datetime.now()
    db.add(row)
    db.commit()
    db.refresh(row)
    return row


# ─── detail ───────────────────────────────────────────────


def get_payment_detail(db: Session, *, payment_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
    row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
    return _enrich_payment(db, row)


def get_payment_by_schedule(db: Session, *, schedule_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
    row = (
        db.query(BizPilotPayment)
        .filter(
            BizPilotPayment.infusion_schedule_id == schedule_id,
            BizPilotPayment.tenant_no == tenant_no,
            BizPilotPayment.del_flag == 0,
        )
        .first()
    )
    if not row:
        return None
    return _enrich_payment(db, row)
  • Step 2: 验证导入

Run: cd backend && source venv/bin/activate && python -c "from app.crud.clinical_payment import list_payments; print('OK')" Expected: OK

  • Step 3: Commit
git add backend/app/crud/clinical_payment.py
git commit -m "feat: add clinical payment CRUD layer"

Task 4: 后端 API 路由 — clinical_payment.py

Files:

  • Create: backend/app/api/clinical_payment.py
  • Modify: backend/app/api/__init__.py
  • Modify: backend/app/main.py

  • [ ] Step 1: 创建 API 路由文件

创建 backend/app/api/clinical_payment.py:

"""临床应用缴费 API"""
from fastapi import APIRouter, Depends, Query, Request
from typing import Optional

router = APIRouter()


def _common_params(
    request: Request,
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100),
    keyword: Optional[str] = Query(None),
):
    return {
        "page": page,
        "page_size": pageSize,
        "keyword": keyword,
        "tenant_no": request.state.tenant_no,
        "user_info": getattr(request.state, "user_info", None),
    }


# ─── 缴费信息页面 ──────────────────────────────────────


@router.get("/clinical-payments")
def list_clinical_payments(
    payStatus: Optional[str] = Query(None),
    **kwargs,
):
    from app.core.database import get_db
    from app.crud.clinical_payment import list_payments

    params = _common_params(**kwargs) if kwargs else _common_params(
        kwargs.get("__request"), kwargs.get("page", 1), kwargs.get("pageSize", 10), kwargs.get("keyword")
    )
    # 重新从 request 获取参数
    # 注意:FastAPI 会自动注入 Query 参数,这里用更简洁的方式
    pass


# 更简洁的写法 — 每个端点独立定义

由于上面的草稿过于冗长,下面用完整的每个端点函数替代。每个端点参照 backend/app/api/pilot_service.py 中现有端点的模式(获取 db session、tenant_no、user_info)。

实际实现时参照已有模式:

  • request.state.tenant_no 获取租户
  • request.state.user_info 获取用户信息
  • get_db() 获取数据库会话
  • _row_to_dict 系列函数序列化返回

10 个端点:

端点 CRUD 函数
GET /clinical-payments list_payments(pay_status=...)
GET /clinical-payments/{payment_id} get_payment_detail(payment_id=...)
POST /clinical-payments/{payment_id}/submit-receipt submit_receipt(payment_id=...)
POST /clinical-payments/submit-receipt-batch batch_submit_receipt(payment_ids=...)
POST /clinical-payments/{payment_id}/confirm-receipt confirm_receipt(payment_id=..., finance_confirm_attachments=..., finance_remark=...)
POST /clinical-payments/{payment_id}/submit-refund submit_refund(payment_id=..., refund_amount=..., refund_remark=..., refund_attachments=...)
POST /clinical-payments/submit-refund-batch batch_submit_refund(payment_ids=..., refund_amount=..., ...)
POST /clinical-payments/{payment_id}/confirm-refund confirm_refund(payment_id=..., refund_finance_confirm_attachments=..., refund_finance_remark=...)
GET /clinical-payments/receipt-pending list_payments(pay_status='jfqrz')
GET /clinical-payments/receipt-confirmed list_payments(pay_status='yjf')
GET /clinical-payments/refund-pending list_payments(pay_status='tfqrz')
GET /clinical-payments/refund-confirmed list_payments(pay_status='ytf')
  • Step 2: 在 __init__.py 注册路由

在 backend/app/api/__init__.py 添加:

from app.api.clinical_payment import router as clinical_payment_router

在 routers 列表中添加 "clinical_payment_router"。

  • Step 3: 在 main.py 挂载路由

在 backend/app/main.py 中现有路由挂载位置添加:

from app.api import clinical_payment_router
app.include_router(clinical_payment_router, prefix="/web/admin", tags=["clinical-payment"])
  • Step 4: 验证路由注册

Run: cd backend && source venv/bin/activate && python -c "from app.api.clinical_payment import router; print(f'Routes: {len(router.routes)}')" Expected: Routes: 12(或对应数量)

  • Step 5: Commit
git add backend/app/api/clinical_payment.py backend/app/api/__init__.py backend/app/main.py
git commit -m "feat: add clinical payment API routes (12 endpoints)"

Task 5: 后端 CRUD 改造 — pilot_application.py 支持 biz_type

Files:

  • Modify: backend/app/crud/pilot_application.py

关键改动点(参照已读取的代码):

  • Step 1: _generate_application_no 支持 biz_type 参数

在 line 74,将 prefix 根据 biz_type 动态选择:

def _generate_application_no(db: Session, tenant_no: str, biz_type: str = "pilot_service") -> str:
    today_str = datetime.now().strftime("%Y%m%d")
    prefix = f"LCSQ{today_str}" if biz_type == "clinical_application" else f"ZSSQ{today_str}"
    existing = (
        db.query(BizPilotApplication.application_no)
        .filter(
            BizPilotApplication.tenant_no == tenant_no,
            BizPilotApplication.application_no.like(f"{prefix}%"),
            BizPilotApplication.del_flag == 0,
        )
        .order_by(BizPilotApplication.application_no.desc())
        .first()
    )
    seq = 1
    if existing and existing[0]:
        try:
            seq = int(existing[0][-4:]) + 1
        except ValueError:
            seq = 1
    return f"{prefix}{seq:04d}"
  • Step 2: _row_to_dict_application 添加 bizType 字段

在 line 96-110 的 dict 中添加:

"bizType": row.biz_type or "pilot_service",
  • Step 3: list_applications 支持 biz_type 过滤

在 line 284 的 list_applications 函数签名中添加 biz_type 参数,并在查询中添加过滤:

def list_applications(
    db: Session,
    ...
    biz_type: Optional[str] = None,  # 新增
) -> Tuple[List[Dict[str, Any]], int]:
    ...
    if biz_type:
        q = q.filter(BizPilotApplication.biz_type == biz_type)
  • Step 4: create_application 支持 biz_type=clinical_application

在 line 385 的 create_application 函数中:

  • 当 biz_type == 'clinical_application' 时,channel_service_order_id 不必填,直接从 data 中获取 channelId
  • 调用 _generate_application_no 时传入 biz_type
def create_application(db, data, tenant_no, user_id, dept_id):
    biz_type = data.get("bizType") or "pilot_service"
    
    if biz_type == "pilot_service":
        channel_service_order_id = data.get("channelServiceOrderId") or ""
        if not channel_service_order_id:
            raise ValueError("请选择渠道服务订单")
        # ... 现有逻辑不变
    else:
        # clinical_application
        channel_service_order_id = ""
        channel_id = data.get("channelId") or ""
        channel_name = data.get("channelName") or ""
    
    application_no = _generate_application_no(db, tenant_no, biz_type)
    
    row = BizPilotApplication(
        ...
        biz_type=biz_type,
        channel_service_order_id=channel_service_order_id,
        channel_id=channel_id,
        channel_name=channel_name,
    )
  • Step 5: create_schedule 支持临床应用 amount + 自动创建缴费记录

在 line 1106 的 create_schedule 函数中:

  • 保存 amount 字段到 BizPilotInfusionSchedule
  • 当 biz_type == 'clinical_application' 时调用 auto_create_payment
# 在 row = BizPilotInfusionSchedule(...) 中添加:
amount=data.get("amount") if data.get("amount") else None,

# 在创建工单之后添加:
if application.biz_type == "clinical_application" and data.get("amount"):
    from app.crud.clinical_payment import auto_create_payment
    auto_create_payment(
        db,
        application_id=application.id,
        schedule_id=schedule_id,
        amount=data["amount"],
        tenant_no=tenant_no,
        user_id=user_id,
        dept_id=dept_id,
    )
  • Step 6: _row_to_dict_infusion_schedule 添加 amount 字段

在输注计划序列化函数中添加:

"amount": float(row.amount) if row.amount else None,
  • Step 7: 验证导入和基本逻辑

Run: cd backend && source venv/bin/activate && python -c "from app.crud.pilot_application import create_application; print('OK')" Expected: OK

  • Step 8: Commit
git add backend/app/crud/pilot_application.py
git commit -m "feat: pilot_application CRUD supports biz_type for clinical application"

Task 6: 后端 API 改造 — pilot_application.py 路由

Files:

  • Modify: backend/app/api/pilot_application.py

  • [ ] Step 1: list 路由添加 bizType 过滤

在申请单列表路由中,从 Query 参数获取 bizType 并传给 list_applications。

  • Step 2: create 路由传递 bizType

在创建申请单路由中,从前端传入的 data 中透传 bizType 字段。

  • Step 3: 输注计划创建路由传递 amount

在创建输注计划路由中,透传 amount 字段。

  • Step 4: 验证 API 可访问

Run: cd backend && source venv/bin/activate && python -c "from app.api.pilot_application import router; print('OK')" Expected: OK

  • Step 5: Commit
git add backend/app/api/pilot_application.py
git commit -m "feat: pilot_application API supports bizType filter and amount field"

Task 7: 前端路由 + 菜单

Files:

  • Modify: web-admin/src/router/modules/sqxp.ts

  • [ ] Step 1: 在 sqxp.ts 末尾(] 之前)添加临床应用管理一级菜单

  {
    id: '90000000000000000000000000000001',
    name: 'ClinicalApplication',
    path: '/clinical-application',
    component: '/index/index',
    meta: {
      title: '临床应用管理',
      icon: 'ri:heart-pulse-line',
      keepAlive: false,
      roles: ['admin']
    },
    children: [
      {
        id: '90000000000000000000000000000002',
        name: 'ClinicalApplication',
        path: 'clinical-application',
        component: '/pilot-service/pilot-application/index',
        meta: {
          title: '临床申请单',
          keepAlive: true,
          menuCode: 'clinical-application:application:list'
        },
        props: { bizType: 'clinical_application' }
      },
      {
        id: '90000000000000000000000000000003',
        name: 'ClinicalCheckupManagement',
        path: 'clinical-checkup-management',
        component: '/pilot-service/checkup-management/index',
        meta: {
          title: '体检管理',
          keepAlive: true,
          menuCode: 'clinical-application:checkup-management:list'
        },
        props: { bizType: 'clinical_application' }
      },
      {
        id: '90000000000000000000000000000004',
        name: 'ClinicalInfusionScheduleManagement',
        path: 'clinical-infusion-schedule-management',
        component: '/pilot-service/infusion-schedule-management/index',
        meta: {
          title: '输注计划管理',
          keepAlive: true,
          menuCode: 'clinical-application:infusion-schedule-management:list'
        },
        props: { bizType: 'clinical_application' }
      },
      {
        id: '90000000000000000000000000000005',
        name: 'ClinicalPaymentInfo',
        path: 'clinical-payment-info',
        component: '/pilot-service/clinical-payment-info/index',
        meta: {
          title: '缴费信息',
          keepAlive: true,
          menuCode: 'clinical-application:payment-info:list'
        }
      },
      {
        id: '90000000000000000000000000000006',
        name: 'ClinicalPaymentConfirm',
        path: 'clinical-payment-confirm',
        component: '/pilot-service/clinical-payment-confirm/index',
        meta: {
          title: '缴费确认(财务)',
          keepAlive: true,
          menuCode: 'clinical-application:payment-confirm:list'
        }
      },
      {
        id: '90000000000000000000000000000007',
        name: 'ClinicalRefundConfirm',
        path: 'clinical-refund-confirm',
        component: '/pilot-service/clinical-refund-confirm/index',
        meta: {
          title: '退费确认(财务)',
          keepAlive: true,
          menuCode: 'clinical-application:refund-confirm:list'
        }
      },
      {
        id: '90000000000000000000000000000008',
        name: 'ClinicalOutboundReleaseReview',
        path: 'clinical-outbound-release-review',
        component: '/pilot-service/outbound-release-review/index',
        meta: {
          title: '出库放行审核',
          keepAlive: true,
          menuCode: 'clinical-application:outbound-release-review:list'
        },
        props: { bizType: 'clinical_application' }
      }
    ]
  }
  • Step 2: 验证路由文件语法

Run: cd web-admin && npx tsc --noEmit src/router/modules/sqxp.ts 2>&1 | head -5

  • Step 3: Commit
git add web-admin/src/router/modules/sqxp.ts
git commit -m "feat: add clinical application menu and routes"

Task 8: 前端 API — clinical payment

Files:

  • Modify: web-admin/src/api/business.ts

  • [ ] Step 1: 在 business.ts 末尾添加 clinical payment API 函数

// ─── 临床应用缴费 ─────────────────────────────────────

export function fetchClinicalPayments(params: any) {
  return http.get('/admin/clinical-payments', params)
}

export function fetchClinicalPaymentDetail(id: string) {
  return http.get(`/admin/clinical-payments/${id}`)
}

export function submitClinicalReceipt(id: string) {
  return http.post(`/admin/clinical-payments/${id}/submit-receipt`)
}

export function batchSubmitClinicalReceipt(ids: string[]) {
  return http.post('/admin/clinical-payments/submit-receipt-batch', { ids })
}

export function confirmClinicalReceipt(id: string, data: any) {
  return http.post(`/admin/clinical-payments/${id}/confirm-receipt`, data)
}

export function submitClinicalRefund(id: string, data: any) {
  return http.post(`/admin/clinical-payments/${id}/submit-refund`, data)
}

export function batchSubmitClinicalRefund(ids: string[], data: any) {
  return http.post('/admin/clinical-payments/submit-refund-batch', { ...data, ids })
}

export function confirmClinicalRefund(id: string, data: any) {
  return http.post(`/admin/clinical-payments/${id}/confirm-refund`, data)
}

export function fetchClinicalReceiptPending(params: any) {
  return http.get('/admin/clinical-payments/receipt-pending', params)
}

export function fetchClinicalReceiptConfirmed(params: any) {
  return http.get('/admin/clinical-payments/receipt-confirmed', params)
}

export function fetchClinicalRefundPending(params: any) {
  return http.get('/admin/clinical-payments/refund-pending', params)
}

export function fetchClinicalRefundConfirmed(params: any) {
  return http.get('/admin/clinical-payments/refund-confirmed', params)
}
  • Step 2: Commit
git add web-admin/src/api/business.ts
git commit -m "feat: add clinical payment API functions"

Task 9: 前端页面 — 缴费信息

Files:

  • Create: web-admin/src/views/pilot-service/clinical-payment-info/index.vue

参照 web-admin/src/views/cell-storage/payment-info/index.vue 的结构。

  • Step 1: 创建页面

核心结构:

  • Tab 切换:dfj 待缴费 / yjf 已缴费
  • 列表表格列:申请单号、患者姓名、渠道名称、细胞类型/规格、输注时间、缴费金额、缴费状态(payStatusLabel + payStatusTagType)、操作
  • 待缴费 Tab 操作:单条缴费确认、批量缴费确认
  • 已缴费 Tab 操作:单条退费(弹窗填退费金额+备注+附件)、批量退费
  • 复用 payStatusLabel、payStatusTagType、formatMoneyDisplay

  • [ ] Step 2: Commit

git add web-admin/src/views/pilot-service/clinical-payment-info/
git commit -m "feat: add clinical payment info page"

Task 10: 前端页面 — 缴费确认(财务)

Files:

  • Create: web-admin/src/views/pilot-service/clinical-payment-confirm/index.vue

参照 web-admin/src/views/cell-storage/payment-confirm/index.vue。

  • Step 1: 创建页面

核心结构:

  • Tab:待确认(jfqrz)/ 已确认(yjf)
  • 确认收款弹窗:复用 ProtocolFinanceConfirmFields 组件(备注 + 附件上传)
  • 表格列同缴费信息页

  • [ ] Step 2: Commit

git add web-admin/src/views/pilot-service/clinical-payment-confirm/
git commit -m "feat: add clinical payment confirm page (finance)"

Task 11: 前端页面 — 退费确认(财务)

Files:

  • Create: web-admin/src/views/pilot-service/clinical-refund-confirm/index.vue

参照 web-admin/src/views/cell-storage/refund-confirm/index.vue。

  • Step 1: 创建页面

核心结构:

  • Tab:待确认(tfqrz)/ 已确认(ytf)
  • 确认退费弹窗:展示退费申请信息(只读)+ 财务确认表单(复用 ProtocolFinanceConfirmFields)
  • 表格列同缴费信息页

  • [ ] Step 2: Commit

git add web-admin/src/views/pilot-service/clinical-refund-confirm/
git commit -m "feat: add clinical refund confirm page (finance)"

Task 12: 前端改造 — 申请单页面支持 bizType

Files:

  • Modify: web-admin/src/views/pilot-service/pilot-application/index.vue
  • Modify: web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue

  • [ ] Step 1: index.vue 接收 bizType prop

在 <script setup> 中:

const props = defineProps<{ bizType?: string }>()
const bizType = computed(() => props.bizType || 'pilot_service')

列表 API 调用时传入 bizType 参数。

  • [ ] Step 2: ApplicationFormDialog.vue 条件渲染

  • 临床应用模式(bizType === 'clinical_application'):

    • 隐藏"渠道服务订单"选择器
    • 显示"渠道"直接选择器(从渠道列表中选择)
    • 隐藏输注方案中的"协议产品价格"选择
    • 显示输注计划的"金额"手动输入框
  • 中试服务模式:保持现有行为不变

  • [ ] Step 3: Commit

git add web-admin/src/views/pilot-service/pilot-application/
git commit -m "feat: pilot application page supports bizType prop for clinical application"

Task 13: 前端改造 — 输注计划管理支持金额和缴费状态

Files:

  • Modify: web-admin/src/views/pilot-service/infusion-schedule-management/index.vue

  • [ ] Step 1: 接收 bizType prop,条件渲染金额列

const props = defineProps<{ bizType?: string }>()
const isClinical = computed(() => props.bizType === 'clinical_application')

在表格中条件渲染:

  • "缴费金额"列:v-if="isClinical"
  • "缴费状态"列:v-if="isClinical",使用 payStatusLabel + payStatusTagType

  • [ ] Step 2: 查询缴费状态

在列表查询时,如果是临床应用,额外查询每条输注计划的缴费状态(后端返回 paymentStatus 字段)。

  • Step 3: Commit
git add web-admin/src/views/pilot-service/infusion-schedule-management/
git commit -m "feat: infusion schedule page shows payment info for clinical application"

Task 14: 集成验证

  • Step 1: 后端启动验证

Run: cd backend && source venv/bin/activate && python -c "from app.main import app; print('App OK')"

  • Step 2: 前端构建验证

Run: cd web-admin && pnpm build 2>&1 | tail -5 Expected: 构建成功

  • Step 3: LSP 诊断

对以下文件运行 lsp_diagnostics:

  • backend/app/models/models.py
  • backend/app/crud/pilot_application.py
  • backend/app/crud/clinical_payment.py
  • web-admin/src/router/modules/sqxp.ts

Expected: 无新增 error

  • Step 4: Final commit
git add -A
git commit -m "feat: complete clinical application business with payment flow"