Files
zkxw/docs/superpowers/plans/2026-05-18-checkup-project-package.md
T
2026-05-18 16:21:38 +08:00

554 lines
21 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.
# 体检机构项目套餐(Phase 1)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:** 在体检机构维度维护「项目套餐」(主子表),套餐总价为明细费用之和,明细价可编辑;机构项目软删除不阻断、不级联套餐行。
**Architecture:** 新增 `biz_checkup_institution_package` + `_package_item` 两张表;CRUD 追加到 `pilot_service.py`;API 在 `pilot_service.py` 路由文件增加 5 个端点,POST/PUT 一次提交 `items[]` 并在事务内全量替换明细;前端在 `checkup-institution/index.vue` 的「项目价格配置」弹窗内用 Tab 分离「项目列表 / 套餐管理」,套餐编辑交互对齐 `project-price-system` 主子表弹窗。
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / Element Plus / TypeScript;MySQL 5.7
**Design Spec:** `docs/superpowers/specs/2026-05-18-checkup-project-package-design.md`
**SQL:** `docs/sql/checkup_institution_package.sql`
**Out of Scope (Phase 2):** 中试申请单体检「从套餐添加」展开预约明细
---
## File Structure
| # | 文件 | 操作 | 职责 |
|---|------|------|------|
| 1 | `docs/sql/checkup_institution_package.sql` | 已有 | 建表 DDL(实施前在目标库执行) |
| 2 | `docs/sql/zkxw.sql` | 追加 | 基线库同步两张新表(与增量 SQL 一致) |
| 3 | `backend/app/models/models.py` | 追加 | `BizCheckupInstitutionPackage` / `Item` |
| 4 | `backend/app/crud/pilot_service.py` | 追加 | 套餐 CRUD + 校验辅助函数 |
| 5 | `backend/app/api/pilot_service.py` | 追加 | 路由 + Pydantic Body |
| 6 | `web-admin/src/api/admin.ts` | 追加 | 5 个 fetch 函数 |
| 7 | `web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue` | 新建 | 套餐 Tab:列表 + 主子表弹窗 + 选项目子弹窗 |
| 8 | `web-admin/src/views/pilot-service/checkup-institution/index.vue` | 修改 | 项目配置弹窗改为 `el-tabs`;引入套餐面板 |
| 9 | `web-admin/src/App.vue` | 按需 | 若套餐弹窗标题需竖条,在 `panelTitleBarTextRules` 登记(可选) |
---
## Task 1: 数据库
**Files:**
- Execute: `docs/sql/checkup_institution_package.sql`
- Modify: `docs/sql/zkxw.sql`(在 `biz_checkup_institution_project` 表定义之后插入两张新表,内容与增量 SQL 一致)
- [ ] **Step 1: 在开发/测试库执行增量 SQL**
```bash
mysql -h127.0.0.1 -uroot -p zkxw < docs/sql/checkup_institution_package.sql
```
Expected: `Query OK`,`SHOW TABLES LIKE 'biz_checkup_institution_package%'` 返回 2 行。
- [ ] **Step 2: 将建表语句合并进 `docs/sql/zkxw.sql`**
在 `biz_checkup_institution_project` 段落后、`biz_clean_room` 之前插入 `checkup_institution_package.sql` 中两张 `CREATE TABLE`(勿重复 DROP 破坏全量脚本顺序时可只保留 CREATE)。
- [ ] **Step 3: Commit**
```bash
git add docs/sql/checkup_institution_package.sql docs/sql/zkxw.sql
git commit -m "feat(db): add checkup institution package tables"
```
---
## Task 2: 后端 Model
**Files:**
- Modify: `backend/app/models/models.py`(在 `BizCheckupInstitutionProject` 类之后、`# 生产管理` 注释之前)
- [ ] **Step 1: 追加两个 Model 类**
```python
class BizCheckupInstitutionPackage(Base):
"""体检机构项目套餐 biz_checkup_institution_package"""
__tablename__ = "biz_checkup_institution_package"
id = Column(String(64), primary_key=True, comment="主键ID")
checkup_institution_id = Column(String(64), nullable=False, comment="体检机构ID")
package_name = Column(String(200), nullable=False, comment="套餐名称")
package_code = Column(String(64), nullable=False, comment="套餐编码")
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, 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, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCheckupInstitutionPackageItem(Base):
"""体检机构项目套餐明细 biz_checkup_institution_package_item"""
__tablename__ = "biz_checkup_institution_package_item"
id = Column(String(64), primary_key=True, comment="主键ID")
package_id = Column(String(64), nullable=False, comment="套餐ID")
checkup_institution_project_id = Column(String(64), nullable=False, comment="机构项目ID")
project_name = Column(String(200), nullable=False, comment="项目名称快照")
project_fee = Column(Numeric(18, 2), nullable=True, comment="套餐内项目费用(元)")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, 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, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
```
- [ ] **Step 2: 更新 `pilot_service.py` CRUD 顶部 import**
在 `backend/app/crud/pilot_service.py` 的 `from app.models.models import (` 中追加:
```python
BizCheckupInstitutionPackage,
BizCheckupInstitutionPackageItem,
```
- [ ] **Step 3: 验证 import**
```bash
cd backend && source venv/bin/activate && python -c "from app.models.models import BizCheckupInstitutionPackage, BizCheckupInstitutionPackageItem; print('OK')"
```
Expected: `OK`
- [ ] **Step 4: Commit**
```bash
git add backend/app/models/models.py backend/app/crud/pilot_service.py
git commit -m "feat: add SQLAlchemy models for checkup institution packages"
```
---
## Task 3: 后端 CRUD
**Files:**
- Modify: `backend/app/crud/pilot_service.py`(在 `soft_delete_checkup_institution_project` 之后、`# Shared utility` 之前新增 Entity 14 段)
- [ ] **Step 1: 实现字典转换与辅助函数**
新增函数(命名固定,便于 API 层调用):
| 函数 | 职责 |
|------|------|
| `_row_to_dict_checkup_institution_package(row, *, total_fee=None, item_count=None)` | 头表 camelCase + `totalFee` / `itemCount` |
| `_row_to_dict_checkup_institution_package_item(row, *, source_project_deleted=False)` | 明细 + `sourceProjectDeleted` |
| `_sum_package_item_fees(items)` | `sum(project_fee or 0)` |
| `checkup_package_code_exists(db, institution_id, code, tenant_no, exclude_id=None)` | 同机构 `del_flag=0` 编码唯一 |
| `_get_checkup_project_for_package(db, project_id, institution_id, tenant_no)` | 查源项目,不存在返回 None |
| `_validate_package_items_for_save(db, institution_id, items, tenant_no, existing_item_project_ids=None)` | 见下方规则 |
**`_validate_package_items_for_save` 规则:**
1. `items` 至少 1 条;同一请求内 `checkup_institution_project_id` 不重复。
2. 对每条明细:
- 若 `project_id` 属于 `existing_item_project_ids`(更新时保留的历史行):允许源项目 `del_flag=1`,但须校验曾属于本机构。
- 否则:源项目须 `del_flag=0`、`status=1`、且 `checkup_institution_id` 匹配。
3. 若任一新添加行源项目 `status=0`:抛出 `ValueError("套餐不能包含已停用的体检项目")`。
- [ ] **Step 2: 实现列表 / 详情 / 创建 / 更新 / 删除**
```python
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None) -> List[Dict[str, Any]]:
# 查头表 del_flag=0,按 create_time desc
# 对每个 package 查明细 del_flag=0,计算 totalFee、itemCount
...
def get_checkup_institution_package(db, package_id, tenant_no=None):
# 头表单条,不存在返回 None
...
def get_checkup_institution_package_detail(db, package_id, tenant_no=None) -> Optional[Dict[str, Any]]:
# 头表 dict + items[](join 源项目 del_flag 填 sourceProjectDeleted)
...
def create_checkup_institution_package(
db, *, checkup_institution_id, package_name, package_code, status="1",
items, tenant_no, order_num=None, remark=None, user_id=None,
):
# 1. checkup_package_code_exists → ValueError
# 2. _validate_package_items_for_save(..., existing_item_project_ids=None)
# 3. 插入头表 + 循环插入明细(project_name 优先 body,否则源项目名)
# 4. commit;返回 get_checkup_institution_package_detail(...)
...
def update_checkup_institution_package(
db, package_id, *, package_name=None, package_code=None, status=None,
items=None, order_num=None, remark=None, user_id=None, tenant_no=None,
):
# 1. get 头表
# 2. 若改 package_code 做唯一校验
# 3. 若 items is not None:
# - 读取旧明细 project_id 集合 → existing_item_project_ids
# - _validate_package_items_for_save(..., existing_item_project_ids=...)
# - 旧明细全部 soft_delete (del_flag=1)
# - 插入新明细行(新 id)
# 4. commit;返回 detail
...
def soft_delete_checkup_institution_package(db, package_id, tenant_no=None):
# 头表 del_flag=1;其下明细 del_flag=1
...
```
**机构项目删除:** 确认 `soft_delete_checkup_institution_project` **不增加**套餐引用检查(保持现状即可,在 commit message 中注明符合设计)。
- [ ] **Step 3: 本地 CRUD 冒烟(可选,Django shell 等价)**
```bash
cd backend && source venv/bin/activate && python - <<'PY'
from app.core.database import SessionLocal
from app.crud import pilot_service as ps
db = SessionLocal()
# 替换为库内真实 institution_id / project_id
inst_id = "REPLACE"
proj_id = "REPLACE"
try:
detail = ps.create_checkup_institution_package(
db,
checkup_institution_id=inst_id,
package_name="基础套餐",
package_code="PKG_BASE_01",
status="1",
items=[{"checkup_institution_project_id": proj_id, "project_name": "血常规", "project_fee": 100.0}],
tenant_no="zkxw",
user_id="admin",
)
print("totalFee", detail.get("totalFee"), "items", len(detail.get("items", [])))
finally:
db.close()
PY
```
Expected: 打印 `totalFee 100.0 items 1`(无异常;测试后可软删该套餐)
- [ ] **Step 4: Commit**
```bash
git add backend/app/crud/pilot_service.py
git commit -m "feat: add CRUD for checkup institution project packages"
```
---
## Task 4: 后端 API 路由
**Files:**
- Modify: `backend/app/api/pilot_service.py`(在体检机构项目路由段之后追加)
- [ ] **Step 1: 定义 Pydantic Body**
```python
class CheckupPackageItemBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
checkup_institution_project_id: str = Field(..., alias="checkupInstitutionProjectId")
project_name: Optional[str] = Field(None, alias="projectName")
project_fee: Optional[float] = Field(None, alias="projectFee")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
class CheckupInstitutionPackageCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_name: str = Field(..., alias="packageName")
package_code: str = Field(..., alias="packageCode")
status: Optional[str] = Field("1", alias="status")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
items: List[CheckupPackageItemBody] = Field(default_factory=list)
class CheckupInstitutionPackageUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_name: Optional[str] = Field(None, alias="packageName")
package_code: Optional[str] = Field(None, alias="packageCode")
status: Optional[str] = Field(None, alias="status")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
items: Optional[List[CheckupPackageItemBody]] = None
```
- [ ] **Step 2: 注册 5 个路由**
| 方法 | 路径 | 处理 |
|------|------|------|
| GET | `/checkup-institutions/{institution_id}/packages` | `list_checkup_institution_packages` → `success_response(list)` |
| POST | `/checkup-institutions/{institution_id}/packages` | `create_checkup_institution_package`,body 转 `items` 为 dict 列表 |
| GET | `/checkup-institution-packages/{package_id}` | `get_checkup_institution_package_detail` |
| PUT | `/checkup-institution-packages/{package_id}` | `update_checkup_institution_package` |
| DELETE | `/checkup-institution-packages/{package_id}` | `soft_delete_checkup_institution_package` |
权限:`require_admin_or_super_admin`;`tenant_no = _get_tenant_no(db, user)`。
- [ ] **Step 3: 启动后端并用 curl 验证(需有效 JWT)**
```bash
# 登录后替换 TOKEN、INSTITUTION_ID
curl -s -H "Authorization: Bearer TOKEN" \
"http://127.0.0.1:8000/api/web/admin/checkup-institutions/INSTITUTION_ID/packages" | head -c 500
```
Expected: `code:200`,`data` 为数组(可为 `[]`)。
- [ ] **Step 4: Commit**
```bash
git add backend/app/api/pilot_service.py
git commit -m "feat: add REST API for checkup institution project packages"
```
---
## Task 5: 前端 API
**Files:**
- Modify: `web-admin/src/api/admin.ts`(在 `fetchDeleteCheckupInstitutionProject` 之后)
- [ ] **Step 1: 追加 5 个函数**
```typescript
// 中试服务管理 — 体检机构项目套餐
export function fetchCheckupInstitutionPackages(institutionId: string) {
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/packages` })
}
export function fetchCheckupInstitutionPackageDetail(packageId: string) {
return request.get({ url: `/web/admin/checkup-institution-packages/${packageId}` })
}
export function fetchCreateCheckupInstitutionPackage(
institutionId: string,
data: Record<string, unknown>
) {
return request.post({
url: `/web/admin/checkup-institutions/${institutionId}/packages`,
params: data
})
}
export function fetchUpdateCheckupInstitutionPackage(
packageId: string,
data: Record<string, unknown>
) {
return request.put({ url: `/web/admin/checkup-institution-packages/${packageId}`, params: data })
}
export function fetchDeleteCheckupInstitutionPackage(packageId: string) {
return request.del({ url: `/web/admin/checkup-institution-packages/${packageId}` })
}
```
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/admin.ts
git commit -m "feat(web-admin): add API helpers for checkup institution packages"
```
---
## Task 6: 前端 — `CheckupPackagePanel.vue`
**Files:**
- Create: `web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue`
**Props:**
```typescript
const props = defineProps<{
institutionId: string
institutionName: string
}>()
```
- [ ] **Step 1: 套餐列表区**
- 工具栏:已选 N 项 +「新增套餐」(唯一 `type="primary"`)
- 表格列:序号、套餐名称、套餐编码、项目数(`itemCount`)、合计费用(`totalFee`)、状态(tag)、备注、操作(编辑/删除)
- `loadPackages()` → `fetchCheckupInstitutionPackages(props.institutionId)`,解析 `Array.isArray(res) ? res : res?.list ?? []`
- [ ] **Step 2: 套餐新增/编辑弹窗(主子表)**
对齐 `project-price-system/index.vue`:
**头部表单**(`form-grid-2`,`label-position="top"`):
| 字段 | 校验 |
|------|------|
| packageName | required |
| packageCode | required |
| status | `1` / `0` |
| remark | 可选 |
**明细区**:
- 按钮「添加项目」→ 打开子弹窗
- 表格列:序号、项目名称(+ `sourceProjectDeleted` 时 `<span class="form-label-tip">源项目已删除</span>`)、项目费用(可编辑 `el-input-number`)、操作(删除行)
- 底部:`套餐合计:¥ {{ packageTotalFee }}`(computed,`detailItems` 的 `projectFee` 求和)
**提交 payload:**
```typescript
{
packageName: string,
packageCode: string,
status: string,
remark?: string,
items: detailItems.value.map((row, idx) => ({
checkupInstitutionProjectId: row.checkupInstitutionProjectId,
projectName: row.projectName,
projectFee: row.projectFee,
orderNum: idx + 1
}))
}
```
- 新增:`fetchCreateCheckupInstitutionPackage(institutionId, payload)`
- 编辑:`fetchUpdateCheckupInstitutionPackage(id, payload)`;打开时 `fetchCheckupInstitutionPackageDetail(id)` 填充 `detailItems`
- [ ] **Step 3: 「添加项目」子弹窗**
- 调用已有 `fetchCheckupInstitutionProjects(props.institutionId)`
- 仅展示 `status == '1'` 且未在 `detailItems` 中的项目
- 多选确认后 push:
```typescript
{
checkupInstitutionProjectId: row.id,
projectName: row.projectName,
projectFee: row.projectFee ?? 0,
sourceProjectDeleted: false
}
```
- [ ] **Step 4: 删除套餐**
`ElMessageBox.confirm` → `fetchDeleteCheckupInstitutionPackage` → `loadPackages()`
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue
git commit -m "feat(web-admin): add checkup package panel component"
```
---
## Task 7: 前端 — 接入 `index.vue` Tab
**Files:**
- Modify: `web-admin/src/views/pilot-service/checkup-institution/index.vue`
- [ ] **Step 1: 将「项目价格配置」弹窗正文改为 `el-tabs`**
```vue
<el-tabs v-model="itemConfigTab" class="item-config-tabs">
<el-tab-pane label="项目列表" name="projects">
<!-- 现有 list-toolbar + el-table + 项目嵌套弹窗 移入此 pane -->
</el-tab-pane>
<el-tab-pane label="套餐管理" name="packages">
<CheckupPackagePanel
v-if="itemConfigTab === 'packages' && currentInstitutionId"
:institution-id="currentInstitutionId"
:institution-name="currentInstitutionName"
/>
</el-tab-pane>
</el-tabs>
```
- [ ] **Step 2: script 增加**
```typescript
import CheckupPackagePanel from './CheckupPackagePanel.vue'
const itemConfigTab = ref<'projects' | 'packages'>('projects')
```
- `openItemDialog` 时重置 `itemConfigTab = 'projects'`
- [ ] **Step 3: 弹窗宽度**
项目配置弹窗 `width` 调至 `960px` 或 `1000px`,避免 Tab 内表格拥挤。
- [ ] **Step 4: 机构操作列(可选)**
主列表操作列可增加「套餐配置」按钮,`openItemDialog(row)` 且 `itemConfigTab = 'packages'`(非必须,Tab 已可达)。
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/pilot-service/checkup-institution/index.vue
git commit -m "feat(web-admin): integrate package tab in checkup institution config dialog"
```
---
## Task 8: 联调与验收
- [ ] **Step 1: 后端 lint / 启动**
```bash
cd backend && source venv/bin/activate && python -c "from app.api.pilot_service import router; print('routes OK')"
cd web-admin && pnpm lint
```
- [ ] **Step 2: 手工验收清单(Phase 1)**
| # | 场景 | 预期 |
|---|------|------|
| 1 | 新增套餐,编码必填,选 2 个项目并改价 | 保存成功;列表 `totalFee` = 两行费用之和 |
| 2 | 同机构重复套餐编码 | 接口 400,提示编码已存在 |
| 3 | 套餐含停用项目(新加行) | 保存失败 |
| 4 | 编辑套餐:删除一行、改价 | 合计随动;保存后列表更新 |
| 5 | 软删除某机构项目(被套餐引用) | 删除成功;编辑套餐见「源项目已删除」,合计仍含该行 |
| 6 | 删除套餐 | 列表不再显示 |
- [ ] **Step 3: Commit(若有验收期小修)**
```bash
git commit -m "fix: checkup package QA adjustments after manual test"
```
---
## Spec Coverage(自检)
| 设计要求 | 任务 |
|----------|------|
| 套餐编码必填、机构内唯一 | Task 3 `checkup_package_code_exists`;Task 6 表单校验 |
| 总价 = 明细之和、不落库 | Task 3 `_sum_package_item_fees`;Task 6 computed |
| 明细价可改、不回写机构项目 | Task 3 只写 package_item;Task 6 可编辑列 |
| 机构项目软删不拦截、明细保留 | Task 3 校验 + `sourceProjectDeleted`;Task 3 不改 delete 逻辑 |
| 含停用项禁止保存(新行) | Task 3 `_validate_package_items_for_save` |
| 5 个 API 端点 | Task 4 |
| Tab:项目 / 套餐 | Task 7 |
| Phase 2 从套餐带入 | **未纳入本计划** |
---
## Phase 2 备忘(本计划不实施)
中试申请单体检模块开发时:
- 选机构 →「从套餐添加」→ 仅展开 `del_flag=0 && status=1` 的源项目行
- 写入 `biz_pilot_checkup_reserve_item` 快照
详见设计文档 §6。