21 KiB
体检机构项目套餐(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
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
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 类
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.pyCRUD 顶部 import
在 backend/app/crud/pilot_service.py 的 from app.models.models import ( 中追加:
BizCheckupInstitutionPackage,
BizCheckupInstitutionPackageItem,
- Step 3: 验证 import
cd backend && source venv/bin/activate && python -c "from app.models.models import BizCheckupInstitutionPackage, BizCheckupInstitutionPackageItem; print('OK')"
Expected: OK
- Step 4: Commit
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 规则:
items至少 1 条;同一请求内checkup_institution_project_id不重复。- 对每条明细:
- 若
project_id属于existing_item_project_ids(更新时保留的历史行):允许源项目del_flag=1,但须校验曾属于本机构。 - 否则:源项目须
del_flag=0、status=1、且checkup_institution_id匹配。
- 若
- 若任一新添加行源项目
status=0:抛出ValueError("套餐不能包含已停用的体检项目")。
- Step 2: 实现列表 / 详情 / 创建 / 更新 / 删除
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 等价)
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
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
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)
# 登录后替换 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
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 个函数
// 中试服务管理 — 体检机构项目套餐
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
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:
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:
{
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:
{
checkupInstitutionProjectId: row.id,
projectName: row.projectName,
projectFee: row.projectFee ?? 0,
sourceProjectDeleted: false
}
- Step 4: 删除套餐
ElMessageBox.confirm → fetchDeleteCheckupInstitutionPackage → loadPackages()
- Step 5: Commit
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
<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 增加
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
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 / 启动
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(若有验收期小修)
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。