Files
zkxw/docs/superpowers/plans/2026-10-08-sample-freight-menu.md
T
2026-10-08 09:44:38 +08:00

488 lines
16 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.
# Sample Freight Menu 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_sample_freight` 与现有 CRUD/API;扩展列表 `includeItems` 与筛选;新增样本 options 接口;前端标准列表 + `el-table` `span-method` 渲染批次 rowspan。前端固定菜单模式须同步 `sqxp.ts` 与 `backend/app/api/menu.py`。
**Tech Stack:** FastAPI、SQLAlchemy、Vue 3、Element Plus、MySQL 菜单 SQL。
**Spec:** `docs/superpowers/specs/2026-10-08-sample-freight-menu-design.md`
**Demo:** `docs/demos/2026-10-08-sample-freight-menu-demo.html`
---
## File map
| 文件 | 职责 |
|------|------|
| `docs/sql/migrations/2026-10-08-sample-freight-menu.sql` | sys_menu + 角色授权(DB 菜单模式) |
| `backend/app/api/menu.py` | 前端菜单树插入运费信息 |
| `web-admin/src/router/modules/sqxp.ts` | 前端路由 |
| `backend/app/crud/sample_freight.py` | list 增强、items enrichment、sample_options |
| `backend/app/api/sample_freight.py` | 查询参数 + sample-options 路由 |
| `backend/tests/test_sample_freight.py` | list filters / span helper 单测 |
| `web-admin/src/api/sample-freight.ts` | 客户端类型与 API |
| `web-admin/src/views/cell-storage/sample-freight/index.vue` | 列表页 |
| `web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue` | 配置运费弹窗 |
| `web-admin/src/views/cell-storage/sample-freight/FreightEditDialog.vue` | 编辑方式/金额 |
| `web-admin/src/App.vue` | 标题竖条规则(若需显式注册「运费信息」) |
---
### Task 1: 菜单 SQL + 前后端路由骨架
**Files:**
- Create: `docs/sql/migrations/2026-10-08-sample-freight-menu.sql`
- Modify: `backend/app/api/menu.py`(`CellStorageSampleInfo` 节点后插入)
- Modify: `web-admin/src/router/modules/sqxp.ts`(同上)
- Create: `web-admin/src/views/cell-storage/sample-freight/index.vue`(空壳列表)
- [ ] **Step 1: 写菜单迁移 SQL**
菜单 id 使用:`50000000000000000000000000000021`
parent:`CEL00000000000000000000000000001`
perms:`cell-storage:sample-freight:list`
path:`sample-freight`
component:`/cell-storage/sample-freight/index`
order_num:取样本信息菜单 `50000000000000000000000000000013` 的 `order_num + 1`(或固定写在样本信息之后的数值,实现时查库确认;幂等 INSERT)。
授权:已有 `cell-storage:sample-info:list`(menu id `50000000000000000000000000000013`)的角色补 `sys_role_menu`(对照 `2026-10-05-presign-order-menu.sql`)。
- [ ] **Step 2: `menu.py` 在 SampleInfo 后插入**
```python
{
"id": "50000000000000000000000000000021",
"name": "CellStorageSampleFreight",
"path": "sample-freight",
"component": "/cell-storage/sample-freight/index",
"meta": {
"title": "运费信息",
"keepAlive": True,
"menuCode": "cell-storage:sample-freight:list",
},
},
```
- [ ] **Step 3: `sqxp.ts` 同步插入同结构路由**(`CellStorageSampleInfo` 与 `CellStoragePaymentConfirm` 之间)
- [ ] **Step 4: 空壳页面**
```vue
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">运费信息</h2>
</div>
<div class="filter-block"><!-- Task 5 填充 --></div>
<div class="list-toolbar"><!-- Task 5 --></div>
<div class="list-body list-wrap">
<el-empty description="运费列表加载中…" />
</div>
</section>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageSampleFreight' })
</script>
```
- [ ] **Step 5: Commit**
```bash
git add docs/sql/migrations/2026-10-08-sample-freight-menu.sql \
backend/app/api/menu.py \
web-admin/src/router/modules/sqxp.ts \
web-admin/src/views/cell-storage/sample-freight/index.vue
git commit -m "feat: add sample freight menu route and page shell"
```
---
### Task 2: 后端 list 增强(includeItems + 筛选)— TDD
**Files:**
- Modify: `backend/app/crud/sample_freight.py`
- Modify: `backend/app/api/sample_freight.py`
- Modify: `backend/tests/test_sample_freight.py`
- [ ] **Step 1: 写失败测试 — enrich item 字段形状与 filter helper**
在 `test_sample_freight.py` 追加:
```python
class TestFreightListHelpers(unittest.TestCase):
def test_item_dict_has_enrichment_keys(self):
from app.crud.sample_freight import _item_to_dict_enriched
class I:
id = "i1"
freight_id = "f1"
sample_source = "cell_storage"
sample_id = "s1"
sample_code = "CODE1"
protocol_id = "p1"
d = _item_to_dict_enriched(
I(),
protocol_no="CP001",
sample_type_name="脐带血",
)
self.assertEqual(d["protocolNo"], "CP001")
self.assertEqual(d["sampleTypeName"], "脐带血")
self.assertEqual(d["sampleSource"], "cell_storage")
```
- [ ] **Step 2: 跑测确认 FAIL**
```bash
cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_sample_freight.py::TestFreightListHelpers -v
```
Expected: FAIL(`_item_to_dict_enriched` 不存在)
- [ ] **Step 3: 实现 enrichment + 扩展 `list_freights`**
签名改为:
```python
def list_freights(
db,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
transport_mode: Optional[str] = None,
sample_source: Optional[str] = None, # cell_storage | research_donation
create_time_from: Optional[str] = None, # YYYY-MM-DD or datetime
create_time_to: Optional[str] = None,
include_items: bool = False,
) -> Tuple[List[Dict[str, Any]], int]:
```
行为要点:
1. `transport_mode` 精确匹配 `BizSampleFreight.transport_mode`(若传)。
2. `sample_source`:仅保留「存在该 source 的 active item」的 freight(`exists` 子查询)。
3. `keyword`:扩展为 OR — 运输方式 value/label、item.sample_code、协议单号(细胞存储 `BizCollectionProtocol.protocol_no`、科研捐赠对应单号字段,查 `research_donation` model 确认字段名,常见 `donation_no` / `protocol_no`)。
4. 时间范围过滤 `create_time`。
5. `include_items=True`:对本页 freight_ids 批量查 items,批量解析 protocolNo / sampleTypeName,写入每条 `items`。
`_item_to_dict_enriched` 在 `_item_to_dict` 基础上增加 `protocolNo`、`sampleTypeName`。
- [ ] **Step 4: API 层透传参数**
`GET /sample-freights` 增加 Query:
- `includeItems: bool = False`
- `transportMode: Optional[str]`
- `sampleSource: Optional[str]`
- `createTimeFrom` / `createTimeTo`
注意:`/sample-freights/sample-options` 须注册在 `/{freight_id}` **之前**,避免路径被当成 id。
- [ ] **Step 5: 跑测 PASS + Commit**
```bash
cd backend && PYTHONPATH=. pytest tests/test_sample_freight.py -v
git add backend/app/crud/sample_freight.py backend/app/api/sample_freight.py backend/tests/test_sample_freight.py
git commit -m "feat(backend): enrich sample freight list with items and filters"
```
---
### Task 3: 样本 options API
**Files:**
- Modify: `backend/app/crud/sample_freight.py`(`list_sample_options`)
- Modify: `backend/app/api/sample_freight.py`
- Modify: `backend/tests/test_sample_freight.py`(可选:纯函数规范化)
- [ ] **Step 1: 实现 `list_sample_options`**
```python
def list_sample_options(
db,
*,
tenant_no: str,
page: int = 1,
page_size: int = 20,
keyword: Optional[str] = None,
sample_source: Optional[str] = None, # None = both
) -> Tuple[List[Dict[str, Any]], int]:
"""
返回:
{
sampleSource, sampleId, sampleCode, protocolId, protocolNo,
sampleTypeName, freightId # 若已挂运费则带上,供前端提示覆盖
}
"""
```
实现策略(最小可用):
- 若 `sample_source in (None, cell_storage)`:查 `BizProtocolSample` join 协议主表(`del_flag=0`),关键字匹配 sample_code / protocol_no。
- 若 `sample_source in (None, research_donation)`:查捐赠样本 join 捐赠主表。
- 合并结果按 `create_time DESC` 内存分页**可接受首版**;若数据量大改为 UNION SQL。
- 左连 `biz_sample_freight_item` 取当前 `freightId`。
- [ ] **Step 2: 路由**
```python
@router.get("/sample-freights/sample-options")
async def list_sample_freight_options(...):
...
```
放在 `/{freight_id}` 路由之前。
- [ ] **Step 3: Commit**
```bash
git add backend/app/crud/sample_freight.py backend/app/api/sample_freight.py
git commit -m "feat(backend): add sample options for freight config dialog"
```
---
### Task 4: 前端 API 扩展
**Files:**
- Modify: `web-admin/src/api/sample-freight.ts`
- [ ] **Step 1: 扩展类型与方法**
```typescript
export type SampleFreightItem = {
sampleSource: 'cell_storage' | 'research_donation'
sampleId: string
protocolId?: string
sampleCode?: string
protocolNo?: string
sampleTypeName?: string
}
export function fetchSampleFreightList(params?: {
page?: number
pageSize?: number
keyword?: string
transportMode?: string
sampleSource?: string
createTimeFrom?: string
createTimeTo?: string
includeItems?: boolean
}) { ... }
export function fetchSampleFreightSampleOptions(params?: {
page?: number
pageSize?: number
keyword?: string
sampleSource?: string
}) {
return request.get<PageList<SampleFreightItem & { freightId?: string }>>({
url: '/web/admin/sample-freights/sample-options',
params
})
}
```
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/sample-freight.ts
git commit -m "feat(web-admin): extend sample-freight API client for menu page"
```
---
### Task 5: 列表页(筛选 + 合并单元格表格)
**Files:**
- Modify: `web-admin/src/views/cell-storage/sample-freight/index.vue`
- Reference: `docs/demos/2026-10-08-sample-freight-menu-demo.html`、`web-admin/src/views/system/unit/index.vue`
- [ ] **Step 1: 扁平行模型 + span-method**
```typescript
type FlatRow = {
freightId: string
rowspan: number // 该批次第一行 = items.length || 1;其余 0
isFirst: boolean
transportMode: string
transportModeLabel: string
freightAmount: number
linkedSampleCount: number
createTime: string
sampleSource?: string
protocolNo?: string
sampleCode?: string
sampleTypeName?: string
sampleId?: string
protocolId?: string
}
function flattenFreights(list: SampleFreight[]): FlatRow[] {
const rows: FlatRow[] = []
for (const f of list) {
const items = f.items?.length ? f.items : [null]
items.forEach((it, idx) => {
rows.push({
freightId: f.id,
rowspan: idx === 0 ? items.length : 0,
isFirst: idx === 0,
transportMode: f.transportMode,
transportModeLabel: f.transportModeLabel || f.transportMode,
freightAmount: f.freightAmount,
linkedSampleCount: f.linkedSampleCount ?? (f.items?.length || 0),
createTime: f.createTime || '',
sampleSource: it?.sampleSource,
protocolNo: it?.protocolNo,
sampleCode: it?.sampleCode,
sampleTypeName: it?.sampleTypeName,
sampleId: it?.sampleId,
protocolId: it?.protocolId
})
})
}
return rows
}
function spanMethod({ column, row }: any) {
const mergeProps = [
'selection',
'transportMode',
'freightAmount',
'linkedSampleCount',
'createTime',
'actions'
]
if (!mergeProps.includes(column.property) && column.type !== 'selection') {
return { rowspan: 1, colspan: 1 }
}
if (column.type === 'selection' || mergeProps.includes(column.property)) {
if (row.rowspan > 0) return { rowspan: row.rowspan, colspan: 1 }
return { rowspan: 0, colspan: 0 }
}
return { rowspan: 1, colspan: 1 }
}
```
注意:为 selection 列设置 `property` 或在 `span-method` 用 `columnIndex` 判断(实现时以 Element Plus 实际 column 对象为准,对照 Demo)。
- [ ] **Step 2: 筛选区 + 加载**
调用 `fetchSampleFreightList({ includeItems: true, page, pageSize, ...filters })`,字典运输方式用 `fetchDictDataOptions('sample_transport_mode')`。
- [ ] **Step 3: 工具栏**
已选 N 项(按 `freightId` 去重)→ 配置运费 / 取消关联 / 编辑运费;仅第一个主按钮 `type="primary"`;均带 icon。
- [ ] **Step 4: 表格列**
运输方式 Tag、运费金额、关联数、来源 Tag、协议单号、样本编码、样本类型、创建时间、操作(编辑 / 取消关联)。操作列 `fixed="right"` `min-width=260`。
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/cell-storage/sample-freight/index.vue
git commit -m "feat(web-admin): render sample freight list with rowspan cells"
```
---
### Task 6: 配置 / 编辑 / 取消关联弹窗
**Files:**
- Create: `web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue`
- Create: `web-admin/src/views/cell-storage/sample-freight/FreightEditDialog.vue`
- Modify: `web-admin/src/views/cell-storage/sample-freight/index.vue`
- [ ] **Step 1: FreightConfigDialog**
- `dialog-form-layout`、`label-position="top"`、`modal-class="protocol-dialog-mask"`
- Tabs:新建 | 关联已有
- 新建:运输方式 select `filterable` `allow-create`;金额 `AmountInput`;样本多选(远程 `fetchSampleFreightSampleOptions`,来源筛选)
- 关联已有:`fetchSampleFreightList` 单选 + 样本多选
- 确定:create 或 link;若样本已有 `freightId` 且不同,确认「将替换原运费关联」
- [ ] **Step 2: FreightEditDialog**
运输方式 + 金额 → `fetchUpdateSampleFreight`;提示同步 N 个样本。
- [ ] **Step 3: 取消关联**
- 工具栏:对每个选中 freightId `fetchSampleFreightDetail` 取 items 全量 unlink(或后端扩展 unlink-all;首版前端传全部 items)
- 行内:弹窗勾选该批次 items 再 unlink
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/cell-storage/sample-freight/
git commit -m "feat(web-admin): add freight configure/edit/unlink dialogs on menu page"
```
---
### Task 7: 标题竖条 + 收尾验证
**Files:**
- Modify: `web-admin/src/App.vue`(若「运费信息」未自动命中竖条,在 `panelTitleIconByText` / 等价规则中增加精确匹配「运费信息」)
- graphify
- [ ] **Step 1: 注册面板标题**「运费信息」以显示主色竖条(对照现有「样本信息管理」写法)
- [ ] **Step 2: 跑后端测试**
```bash
cd backend && PYTHONPATH=. pytest tests/test_sample_freight.py -v
```
Expected: PASS
- [ ] **Step 3: 手测清单**
1. 前端模式侧栏出现「运费信息」在样本信息管理下
2. 执行菜单 SQL(测试库)后 DB 模式亦可见(可选)
3. 列表合并单元格;混源样本展示
4. 配置/关联/取消/编辑与协议单数据一致
5. 合同金额不受影响
- [ ] **Step 4: 更新图谱**
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/App.vue backend/graphify-out web-admin/graphify-out
git commit -m "chore: register freight panel title and refresh graphify"
```
---
## Spec coverage
| Spec | Task |
|------|------|
| 菜单位置 / 路由 / 权限 | 1, 7 |
| 合并单元格列表 | 5 |
| 筛选(方式/来源/关键字/时间) | 2, 5 |
| includeItems + protocolNo/typeName | 2 |
| 配置新建/关联 + 混选样本 | 3, 6 |
| 取消关联 / 编辑 | 6 |
| 协议单入口保留 | 无改协议单(验收手测) |
| Demo 视觉对齐 | 5(对照 HTML) |
## Self-review notes
- 无 TBD;科研捐赠协议单号字段名实现时在 model 中确认后写入 `list_freights` keyword 查询。
- `sample-options` 路由顺序:必须在 `/{freight_id}` 之前。
- 前端固定菜单:`menu.py` + `sqxp.ts` 双写,不可只改 SQL。