Files
zkxw/docs/superpowers/plans/2026-05-27-clinical-application.md
T
2026-05-27 16:52:46 +08:00

1253 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 临床应用业务 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**
```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**
```bash
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` 字段后面添加:
```python
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` 字段后面添加:
```python
amount = Column(Numeric(18, 2), nullable=True, comment="临床应用-手动填写的输注金额(中试为NULL)")
```
- [ ] **Step 3: 创建 `backend/app/models/clinical_payment.py`**
```python
"""临床应用缴费记录模型"""
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 行后面添加:
```python
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**
```bash
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`,包含以下函数:
```python
"""临床应用缴费 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**
```bash
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`:
```python
"""临床应用缴费 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` 添加:
```python
from app.api.clinical_payment import router as clinical_payment_router
```
在 `routers` 列表中添加 `"clinical_payment_router"`。
- [ ] **Step 3: 在 `main.py` 挂载路由**
在 `backend/app/main.py` 中现有路由挂载位置添加:
```python
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**
```bash
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` 动态选择:
```python
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 中添加:
```python
"bizType": row.biz_type or "pilot_service",
```
- [ ] **Step 3: `list_applications` 支持 biz_type 过滤**
在 line 284 的 `list_applications` 函数签名中添加 `biz_type` 参数,并在查询中添加过滤:
```python
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`
```python
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`
```python
# 在 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 字段**
在输注计划序列化函数中添加:
```python
"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**
```bash
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**
```bash
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 末尾(`]` 之前)添加临床应用管理一级菜单**
```typescript
{
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**
```bash
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 函数**
```typescript
// ─── 临床应用缴费 ─────────────────────────────────────
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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>` 中:
```typescript
const props = defineProps<{ bizType?: string }>()
const bizType = computed(() => props.bizType || 'pilot_service')
```
列表 API 调用时传入 `bizType` 参数。
- [ ] **Step 2: ApplicationFormDialog.vue 条件渲染**
- 临床应用模式(`bizType === 'clinical_application'`):
- 隐藏"渠道服务订单"选择器
- 显示"渠道"直接选择器(从渠道列表中选择)
- 隐藏输注方案中的"协议产品价格"选择
- 显示输注计划的"金额"手动输入框
- 中试服务模式:保持现有行为不变
- [ ] **Step 3: Commit**
```bash
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,条件渲染金额列**
```typescript
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**
```bash
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**
```bash
git add -A
git commit -m "feat: complete clinical application business with payment flow"
```