# 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。