# 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
```
- [ ] **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>({
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。