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

16 KiB
Raw Blame History

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 后插入
{
    "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: 空壳页面

<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
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 追加:

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

签名改为:

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

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: 路由

@router.get("/sample-freights/sample-options")
async def list_sample_freight_options(...):
    ...

放在 /{freight_id} 路由之前。

  • Step 3: Commit
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: 扩展类型与方法

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

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

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: 跑后端测试

cd backend && PYTHONPATH=. pytest tests/test_sample_freight.py -v

Expected: PASS

  • Step 3: 手测清单
  1. 前端模式侧栏出现「运费信息」在样本信息管理下
  2. 执行菜单 SQL(测试库)后 DB 模式亦可见(可选)
  3. 列表合并单元格;混源样本展示
  4. 配置/关联/取消/编辑与协议单数据一致
  5. 合同金额不受影响
  • Step 4: 更新图谱
./scripts/update-graph.sh all
  • Step 5: Commit
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。