488 lines
16 KiB
Markdown
488 lines
16 KiB
Markdown
# 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。
|