Files
zkxw/docs/superpowers/plans/2026-10-07-sample-freight-batch.md
T
zlwuandCursor 95a28d5f0f docs: add sample freight batch implementation plan
Break the approved freight-batch design into TDD-oriented tasks covering SQL, APIs, protocol wiring, and web-admin UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:13:05 +08:00

30 KiB
Raw Blame History

Sample Freight Batch 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 + biz_sample_freight_item;独立 CRUD/API;协议详情用 freight enrichment;已落库样本配置运费立即调 API;新建草稿用 freightDraft.clientKey 在协议保存时建批次。前端演进 SampleTransportFormSection 为运费配置 UI。

Tech Stack: MySQL 5.7、FastAPI、SQLAlchemy、Pydantic v2、Vue 3、Element Plus、pytest/unittest。

Spec: docs/superpowers/specs/2026-10-07-sample-freight-batch-design.md


File map

文件 职责
docs/sql/migrations/2026-10-07-sample-freight-batch.sql 表 + 字典预置
backend/app/models/models.py BizSampleFreight / BizSampleFreightItem
backend/app/models/__init__.py 导出新 model
backend/app/services/sample_transport_mode.py 运输方式字典解析/自动入库(可单测、无 commit 副作用用 flush)
backend/app/crud/sample_freight.py 运费批次 CRUD + 关联 + 协议保存挂钩
backend/app/api/sample_freight.py REST 路由
backend/app/api/__init__.py / backend/app/main.py 注册路由
backend/app/crud/collection_protocol.py 详情 freight;保存停写 transport;处理 freightDraft
backend/app/crud/research_donation.py 同上
backend/tests/test_sample_freight.py 字典解析 + 关联规则单测
web-admin/src/api/sample-freight.ts 前端 API
web-admin/src/components/business/sample-transport-form-section/index.vue 改造成运费配置
ProtocolFormDialog.vue / DonationFormDialog.vue payload / 草稿
ProtocolViewDialog.vue / DonationViewDialog.vue 查看态只展示方式+运费

Task 1: SQL 迁移(表 + 字典)

Files:

  • Create: docs/sql/migrations/2026-10-07-sample-freight-batch.sql

  • Step 1: 写入迁移 SQL

-- 样本运费批次 + 关联 + 运输方式字典预置(租户 zkxw;其他租户首次用时由后端按需补齐预置也可只插 zkxw)

CREATE TABLE IF NOT EXISTS biz_sample_freight (
  id VARCHAR(64) NOT NULL COMMENT '主键ID',
  transport_mode VARCHAR(100) NOT NULL DEFAULT '' COMMENT '运输方式字典值 sample_transport_mode',
  freight_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '运费(元)',
  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  order_num INT DEFAULT NULL COMMENT '排序号',
  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  create_time DATETIME NOT NULL COMMENT '创建时间',
  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
  update_time DATETIME NOT NULL COMMENT '更新时间',
  del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
  PRIMARY KEY (id),
  KEY idx_sample_freight_tenant_ctime (tenant_no, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本运费批次';

CREATE TABLE IF NOT EXISTS biz_sample_freight_item (
  id VARCHAR(64) NOT NULL COMMENT '主键ID',
  freight_id VARCHAR(64) NOT NULL COMMENT '运费批次ID',
  sample_source VARCHAR(32) NOT NULL COMMENT 'cell_storage|research_donation',
  sample_id VARCHAR(64) NOT NULL COMMENT '样本ID',
  sample_code VARCHAR(32) DEFAULT NULL COMMENT '样本编码快照',
  protocol_id VARCHAR(64) DEFAULT NULL COMMENT '协议单ID',
  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  order_num INT DEFAULT NULL COMMENT '排序号',
  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  create_time DATETIME NOT NULL COMMENT '创建时间',
  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
  update_time DATETIME NOT NULL COMMENT '更新时间',
  del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
  PRIMARY KEY (id),
  KEY idx_freight_item_freight (freight_id, del_flag),
  KEY idx_freight_item_sample (tenant_no, sample_source, sample_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本运费关联';

-- 字典类型(若不存在)
INSERT INTO sys_dict_type (
  id, dict_name, dict_type, tenant_no, order_num, remark,
  create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
  REPLACE(UUID(),'-',''), '样本运输方式', 'sample_transport_mode', 'zkxw', 0, '协议单运费批次运输方式',
  NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
  SELECT 1 FROM sys_dict_type
  WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND del_flag = 0
);

INSERT INTO sys_dict_data (
  id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
  create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT REPLACE(UUID(),'-',''), '顺丰快递', 'sfkd', 'sample_transport_mode', 'zkxw', 1, NULL,
  NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
  SELECT 1 FROM sys_dict_data
  WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND dict_value = 'sfkd' AND del_flag = 0
);

INSERT INTO sys_dict_data (
  id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
  create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT REPLACE(UUID(),'-',''), '专线车', 'zxc', 'sample_transport_mode', 'zkxw', 2, NULL,
  NULL, '', NOW(), NULL, NOW(), 0
FROM DUAL
WHERE NOT EXISTS (
  SELECT 1 FROM sys_dict_data
  WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND dict_value = 'zxc' AND del_flag = 0
);
  • Step 2: Commit
git add docs/sql/migrations/2026-10-07-sample-freight-batch.sql
git commit -m "docs(sql): add sample freight batch tables and transport mode dict"

Task 2: SQLAlchemy models

Files:

  • Modify: backend/app/models/models.py(在 BizProtocolSampleTransport 附近新增)

  • Modify: backend/app/models/__init__.py(导出)

  • Step 1: 添加 model 类

class BizSampleFreight(Base):
    """样本运费批次 biz_sample_freight"""

    __tablename__ = "biz_sample_freight"

    id = Column(String(64), primary_key=True, comment="主键ID")
    transport_mode = Column(String(100), nullable=False, default="", comment="运输方式字典值")
    freight_amount = Column(Numeric(18, 2), nullable=False, default=0, comment="运费(元)")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizSampleFreightItem(Base):
    """样本运费关联 biz_sample_freight_item"""

    __tablename__ = "biz_sample_freight_item"

    id = Column(String(64), primary_key=True, comment="主键ID")
    freight_id = Column(String(64), nullable=False, comment="运费批次ID")
    sample_source = Column(String(32), nullable=False, comment="cell_storage|research_donation")
    sample_id = Column(String(64), nullable=False, comment="样本ID")
    sample_code = Column(String(32), nullable=True, comment="样本编码快照")
    protocol_id = Column(String(64), nullable=True, comment="协议单ID")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
  • Step 2: 在 __init__.py 导出 BizSampleFreight, BizSampleFreightItem

  • Step 3: Commit

git add backend/app/models/models.py backend/app/models/__init__.py
git commit -m "feat(backend): add BizSampleFreight models"

Task 3: 运输方式字典解析(TDD)

Files:

  • Create: backend/app/services/sample_transport_mode.py

  • Create: backend/tests/test_sample_freight.py

  • Step 1: 写失败单测(纯函数逻辑可用 mock;优先测解析优先级文案)

在 backend/tests/test_sample_freight.py:

"""样本运费:运输方式字典解析与关联规则"""

import unittest
from unittest.mock import MagicMock, patch

from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value


class TestResolveTransportMode(unittest.TestCase):
    def test_empty_raises(self):
        db = MagicMock()
        with self.assertRaises(ValueError):
            resolve_transport_mode_value(db, tenant_no="zkxw", raw="", user_id=None)

    def test_hit_by_value(self):
        db = MagicMock()
        row = MagicMock(dict_value="sfkd", dict_label="顺丰快递", del_flag=0)
        db.query.return_value.filter.return_value.first.return_value = row
        self.assertEqual(
            resolve_transport_mode_value(db, tenant_no="zkxw", raw="sfkd", user_id=None),
            "sfkd",
        )

    def test_hit_by_label_then_create_path_not_called(self):
        db = MagicMock()
        # first() 第一次 value 未命中返回 None,第二次 label 命中
        miss = None
        hit = MagicMock(dict_value="zxc", dict_label="专线车", del_flag=0)
        db.query.return_value.filter.return_value.first.side_effect = [miss, hit]
        self.assertEqual(
            resolve_transport_mode_value(db, tenant_no="zkxw", raw="专线车", user_id=None),
            "zxc",
        )


if __name__ == "__main__":
    unittest.main()
  • Step 2: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_sample_freight.py::TestResolveTransportMode -v

Expected: FAIL(模块不存在)

  • Step 3: 实现 sample_transport_mode.py
"""样本运输方式字典 sample_transport_mode:解析 / 自动入库(flush,不单独 commit)"""

from __future__ import annotations

from datetime import datetime
from typing import Optional

from sqlalchemy.orm import Session

from app.core.utils import generate_uuid  # 若项目用别处 uuid 工具则对齐现有 import
from app.models.models import SysDictData, SysDictType

DICT_TYPE = "sample_transport_mode"


def ensure_dict_type(db: Session, tenant_no: str, user_id: Optional[str]) -> None:
    exists = (
        db.query(SysDictType.id)
        .filter(
            SysDictType.dict_type == DICT_TYPE,
            SysDictType.tenant_no == tenant_no,
            SysDictType.del_flag == 0,
        )
        .first()
    )
    if exists:
        return
    db.add(
        SysDictType(
            id=generate_uuid(),
            dict_name="样本运输方式",
            dict_type=DICT_TYPE,
            tenant_no=tenant_no,
            order_num=0,
            remark="协议单运费批次运输方式",
            create_by=user_id,
            create_dept_id="",
            create_time=datetime.now(),
            update_by=user_id,
            update_time=datetime.now(),
            del_flag=0,
        )
    )
    db.flush()


def resolve_transport_mode_value(
    db: Session, *, tenant_no: str, raw: str, user_id: Optional[str]
) -> str:
    text = (raw or "").strip()
    if not text:
        raise ValueError("运输方式不能为空")
    ensure_dict_type(db, tenant_no, user_id)

    by_value = (
        db.query(SysDictData)
        .filter(
            SysDictData.dict_type == DICT_TYPE,
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_value == text,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if by_value:
        return by_value.dict_value

    by_label = (
        db.query(SysDictData)
        .filter(
            SysDictData.dict_type == DICT_TYPE,
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_label == text,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if by_label:
        return by_label.dict_value

    value = text
    clash = (
        db.query(SysDictData.id)
        .filter(
            SysDictData.dict_type == DICT_TYPE,
            SysDictData.tenant_no == tenant_no,
            SysDictData.dict_value == value,
            SysDictData.del_flag == 0,
        )
        .first()
    )
    if clash:
        value = f"{text}_{generate_uuid()[:8]}"

    db.add(
        SysDictData(
            id=generate_uuid(),
            dict_label=text,
            dict_value=value,
            dict_type=DICT_TYPE,
            tenant_no=tenant_no,
            order_num=99,
            remark=None,
            create_by=user_id,
            create_dept_id="",
            create_time=datetime.now(),
            update_by=user_id,
            update_time=datetime.now(),
            del_flag=0,
        )
    )
    db.flush()
    return value

实现时核对项目中 generate_uuid 的真实导入路径(常见 app.core.security / app.utils),与周边 CRUD 保持一致。

  • Step 4: 跑测通过
cd backend && pytest tests/test_sample_freight.py::TestResolveTransportMode -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/sample_transport_mode.py backend/tests/test_sample_freight.py
git commit -m "feat(backend): resolve sample transport mode dict with auto-create"

Task 4: 运费 CRUD 核心

Files:

  • Create: backend/app/crud/sample_freight.py

  • Modify: backend/tests/test_sample_freight.py

  • Step 1: 实现 CRUD(关键函数签名,实现须完整)

必须提供:

SAMPLE_SOURCE_CELL = "cell_storage"
SAMPLE_SOURCE_DONATION = "research_donation"

def _row_to_dict(obj, *, linked_sample_count: int = 0, transport_mode_label: str = "") -> dict: ...

def list_freights(db, *, tenant_no, page, page_size, keyword=None) -> tuple[list, int]: ...
def get_freight(db, freight_id, tenant_no) -> Optional[BizSampleFreight]: ...
def get_freight_detail(db, freight_id, tenant_no) -> dict: ...  # 含 items

def create_freight(
    db, *, tenant_no, user_id, transport_mode_raw, freight_amount, items: list[dict]
) -> dict:
    # resolve mode → 建主表 → link items(先 unlink 旧关联)→ commit/flush 与项目风格一致

def update_freight(db, freight_id, *, tenant_no, user_id, transport_mode_raw=None, freight_amount=None) -> dict: ...

def link_samples(db, freight_id, *, tenant_no, user_id, items: list[dict]) -> dict:
    # 每个 item: sampleSource, sampleId, protocolId?, sampleCode?
    # 若样本已挂其他批次:软删旧 item(或 reactivation upsert)再挂新批次

def unlink_samples(db, freight_id, *, tenant_no, user_id, items: list[dict]) -> dict:
    # 软删匹配 item;若该 freight 下 active item 数为 0 → 软删主表

def freight_map_for_samples(
    db, *, tenant_no, sample_source: str, sample_ids: list[str]
) -> dict[str, dict]:
    """sample_id -> { id, transportMode, transportModeLabel, freightAmount, linkedSampleCount }"""

def apply_freight_on_protocol_save(
    db, *, tenant_no, user_id, sample_source: str, protocol_id: str,
    saved_samples: list, input_samples: list,
) -> None:
    """
    遍历 input_samples 与 saved_samples 对齐:
    - freightId → link 到该批次
    - freightDraft { clientKey, transportMode, freightAmount } → 同 clientKey 只 create 一次再 link
    - 二者皆无 → 不改现有关联(或按产品:不自动 unlink;删除样本时另路径处理)
    本阶段协议保存不再调用 save_sample_transports。
    """

关联 upsert 规则(软删兼容):

  1. 查 (tenant_no, sample_source, sample_id) 任意 del_flag 行;
  2. 若存在:改 freight_id、快照字段、del_flag=0;
  3. 否则新建。

金额校验:Decimal(str(amount)),< 0 则 ValueError("运费不能为负数");None/空则 ValueError("运费不能为空")。

  • Step 2: 补充单测(mock db 或内存逻辑)

至少覆盖:

class TestFreightAmountValidation(unittest.TestCase):
    def test_negative_rejected(self):
        from app.crud.sample_freight import _parse_amount
        with self.assertRaises(ValueError):
            _parse_amount(-1)

    def test_zero_ok(self):
        from app.crud.sample_freight import _parse_amount
        self.assertEqual(_parse_amount(0), Decimal("0"))

(将 _parse_amount 抽成可测小函数。)

  • Step 3: 跑测
cd backend && pytest tests/test_sample_freight.py -v

Expected: PASS

  • Step 4: Commit
git add backend/app/crud/sample_freight.py backend/tests/test_sample_freight.py
git commit -m "feat(backend): add sample freight CRUD and link/unlink"

Task 5: 运费 REST API + 路由注册

Files:

  • Create: backend/app/api/sample_freight.py

  • Modify: backend/app/api/__init__.py

  • Modify: backend/app/main.py

  • Step 1: 实现路由(对齐 presign_order.py:APIRouter(prefix="/web/admin", ...) + _get_tenant_no)

Method Path Handler
GET /sample-freights list
GET /sample-freights/{freight_id} detail
POST /sample-freights create body: transportMode, freightAmount, items[]
PUT /sample-freights/{freight_id} update
POST /sample-freights/{freight_id}/link body: items[]
POST /sample-freights/{freight_id}/unlink body: items[]

Body 用 Pydantic ConfigDict(extra="ignore", populate_by_name=True) + Field(alias=...)。

权限:Depends(require_admin_or_super_admin)(与预签约一致;若协议单用菜单码则跟采集协议单 API 对齐)。

  • Step 2: 注册

api/__init__.py:

from app.api.sample_freight import router as sample_freight_router
# __all__ 增加 "sample_freight_router"

main.py:

from app.api import ..., sample_freight_router
app.include_router(sample_freight_router, prefix=settings.API_V1_PREFIX)
  • Step 3: Commit
git add backend/app/api/sample_freight.py backend/app/api/__init__.py backend/app/main.py
git commit -m "feat(backend): expose sample freight admin APIs"

Task 6: 细胞存储协议 — enrichment + 停写旧运输 + 保存挂钩

Files:

  • Modify: backend/app/crud/collection_protocol.py

  • Step 1: 详情 enrichment

在 get_protocol_detail(约 1309–1361 行)样本组装处:

  1. 调用 sample_freight.freight_map_for_samples(db, tenant_no=..., sample_source="cell_storage", sample_ids=[s.id for s in s_rows])。
  2. item["freight"] = freight_map.get(s.id)(无则 None)。
  3. 保留旧 transport 字段读取(历史只读),但前端将不再展示;新单无运输行时 transport 可为 null。
  • Step 2: 保存时停写运输、改挂钩运费

找到 save_sample_transports(...) 调用处(约 2818 行),改为:

from app.crud import sample_freight as sample_freight_svc

# 不再: save_sample_transports(...)
sample_freight_svc.apply_freight_on_protocol_save(
    db,
    tenant_no=tenant_no,
    user_id=user_id,
    sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
    protocol_id=collection_protocol_id,
    saved_samples=result,
    input_samples=samples,
)

确认外层事务:apply_freight_on_protocol_save 应 flush 而不额外 commit(若原 save_sample_transports 内有 db.commit(),运费路径改为与协议保存同一事务——按该函数周边实际 commit 边界调整,避免双 commit)。

  • Step 3: 手动或补测
cd backend && pytest tests/test_sample_freight.py -v
  • Step 4: Commit
git add backend/app/crud/collection_protocol.py
git commit -m "feat(backend): wire cell-storage protocol save to sample freight"

Task 7: 科研捐赠协议 — 同样挂钩

Files:

  • Modify: backend/app/crud/research_donation.py

  • Step 1: 详情样本增加 freight(sample_source=research_donation)

  • Step 2: 保存路径停止写 BizResearchDonationSampleTransport(等价于去掉/跳过 donation 的 save transports),改为 apply_freight_on_protocol_save(..., SAMPLE_SOURCE_DONATION, ...)

定位现有运输保存(约 2132 行附近 express_fee / transport create),改为运费挂钩。

  • Step 3: Commit
git add backend/app/crud/research_donation.py
git commit -m "feat(backend): wire research-donation protocol save to sample freight"

Task 8: 前端 API 模块

Files:

  • Create: web-admin/src/api/sample-freight.ts

  • Step 1: 实现

import request from '@/utils/http'

export type SampleFreightItem = {
  sampleSource: 'cell_storage' | 'research_donation'
  sampleId: string
  protocolId?: string
  sampleCode?: string
}

export type SampleFreight = {
  id: string
  transportMode: string
  transportModeLabel?: string
  freightAmount: number
  linkedSampleCount?: number
  createTime?: string
  items?: Array<SampleFreightItem & { protocolNo?: string }>
}

type PageList<T> = { list: T[]; total: number; page: number; pageSize: number }

export function fetchSampleFreightList(params?: {
  page?: number
  pageSize?: number
  keyword?: string
}) {
  return request.get<PageList<SampleFreight>>({
    url: '/web/admin/sample-freights',
    params
  })
}

export function fetchSampleFreightDetail(id: string) {
  return request.get<SampleFreight>({ url: `/web/admin/sample-freights/${id}` })
}

export function fetchCreateSampleFreight(data: {
  transportMode: string
  freightAmount: number
  items: SampleFreightItem[]
}) {
  return request.post<SampleFreight>({
    url: '/web/admin/sample-freights',
    data,
    showSuccessMessage: true
  })
}

export function fetchUpdateSampleFreight(
  id: string,
  data: { transportMode?: string; freightAmount?: number }
) {
  return request.put<SampleFreight>({
    url: `/web/admin/sample-freights/${id}`,
    data,
    showSuccessMessage: true
  })
}

export function fetchLinkSampleFreight(id: string, items: SampleFreightItem[]) {
  return request.post<SampleFreight>({
    url: `/web/admin/sample-freights/${id}/link`,
    data: { items },
    showSuccessMessage: true
  })
}

export function fetchUnlinkSampleFreight(id: string, items: SampleFreightItem[]) {
  return request.post<SampleFreight>({
    url: `/web/admin/sample-freights/${id}/unlink`,
    data: { items },
    showSuccessMessage: true
  })
}
  • Step 2: Commit
git add web-admin/src/api/sample-freight.ts
git commit -m "feat(web-admin): add sample freight API client"

Task 9: 改造 SampleTransportFormSection → 运费 UI

Files:

  • Modify: web-admin/src/components/business/sample-transport-form-section/index.vue

  • Step 1: 列表列

移除:运输类型、目的站点、预计到达、快递/携带。
新增:

  • 运输方式:row.freight?.transportModeLabel || '—'
  • 运费(元):有 freight 则 formatMoneyDisplay(freight.freightAmount),若 linkedSampleCount > 1 后缀 (共用)

样本行数据结构:用 freight / freightDraft 替代写入 transport(可暂时保留空 transport 兼容父组件 normalize,但 UI 不读)。

  • Step 2: 工具栏

  • 按钮文案「配置运输信息」→「配置运费」

  • 增加「取消运费关联」:无勾选或勾选行均无 freight/freightDraft 时 disabled

  • Step 3: 配置运费弹窗

宽度约 720px,dialog-form-layout,label-position="top"。

顶部 el-radio-group:新建运费 | 关联已有。

新建:

  • 运输方式:el-select filterable allow-create default-first-option,options 来自字典 sample_transport_mode(父组件传入或组件内 fetchDictData——对齐项目现有字典拉取方式)
  • 运费:el-input type="number" 或 AmountInput,≥ 0

关联已有:

  • 调用 fetchSampleFreightList,表格单选一行

确定逻辑:

async function confirmFreight() {
  const selected = selectedSamples.value
  if (!selected.length) return
  const persisted = selected.filter((r) => r.id)
  const drafts = selected.filter((r) => !r.id)

  if (mode === 'create') {
    if (!transportMode || freightAmount === null || freightAmount === '') {
      ElMessage.warning('请填写运输方式与运费')
      return
    }
    if (persisted.length) {
      const res = await fetchCreateSampleFreight({
        transportMode,
        freightAmount: Number(freightAmount),
        items: persisted.map((r) => ({
          sampleSource: props.sampleSource, // 新增 prop: cell_storage | research_donation
          sampleId: r.id,
          protocolId: props.protocolId || undefined,
          sampleCode: r.sampleCode
        }))
      })
      // 写回 persisted 行 freight = res
    }
    if (drafts.length) {
      const clientKey = `fd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
      const next = props.samples.map((row) => {
        if (!drafts.includes(row)) return row
        return {
          ...row,
          freightDraft: { clientKey, transportMode, freightAmount: Number(freightAmount) },
          freight: {
            id: '',
            transportMode,
            transportModeLabel: transportModeLabelOf(transportMode),
            freightAmount: Number(freightAmount),
            linkedSampleCount: drafts.length
          }
        }
      })
      updateSamples(next)
    }
  } else {
    // link existing: fetchLinkSampleFreight for persisted; drafts 只写 freightId + freight 摘要
  }
}

新增 props:sampleSource: 'cell_storage' | 'research_donation'、protocolId?: string。

取消关联:persisted 调 fetchUnlinkSampleFreight;drafts 清空 freight/freightDraft。

  • Step 4: 删除站点/快递公司远程搜索等仅服务于旧运输的逻辑(若无其他引用)

  • Step 5: Commit

git add web-admin/src/components/business/sample-transport-form-section/index.vue
git commit -m "feat(web-admin): replace sample transport UI with freight batch config"

Task 10: 接入协议表单 / 查看弹窗

Files:

  • Modify: web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

  • Modify: web-admin/src/views/research-donation/list/DonationFormDialog.vue

  • Modify: web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue

  • Modify: web-admin/src/views/research-donation/list/DonationViewDialog.vue

  • Step 1: FormDialog

  • <SampleTransportFormSection 增加 :sample-source="'cell_storage'" / 'research_donation',:protocol-id="formData.id || ''"

  • normalizeSampleRow:增加 freight、freightDraft;transport 可保留空对象但不提交依赖

  • 提交 body:每个 sample 带 freightId(来自 freight.id)或 freightDraft;不要再要求 transport.transportType

  • Step 2: ViewDialog

运输信息区改为两行数据:运输方式、运费(元);共用时显示关联样本数。删除单号/站点/携带人展示。

  • Step 3: 本地手测清单(开发者执行)
  1. 编辑已有细胞存储协议:两样本新建「专线车 / 800」→ 两行均显示 800(共用)
  2. 另一协议关联该运费 → linkedSampleCount 增大
  3. 手输「同城闪送」→ 字典出现新项
  4. 新建未保存协议:两草稿样本配置运费 → 保存后详情有真实 freight.id
  5. 合同金额不因运费改变
  6. 科研捐赠同样走一遍
  • Step 4: Commit
git add web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue \
  web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue \
  web-admin/src/views/research-donation/list/DonationFormDialog.vue \
  web-admin/src/views/research-donation/list/DonationViewDialog.vue
git commit -m "feat(web-admin): wire protocol dialogs to sample freight"

Task 11: 回归测试 + graphify

Files:

  • Modify: backend/tests/test_sample_freight.py(若需补 apply_freight clientKey 逻辑单测)

  • graphify 输出(脚本更新)

  • Step 1: 补 apply_freight_on_protocol_save 的 clientKey 归并单测(可用 MagicMock 断言 create_freight 只调用一次)

  • Step 2: 跑后端相关测试

cd backend && pytest tests/test_sample_freight.py tests/test_protocol_list_sample_reception_qc.py -v

Expected: PASS(若后者依赖旧 transport 写入,按失败信息改为不依赖或跳过该断言)

  • Step 3: 更新知识图谱
./scripts/update-graph.sh all
  • Step 4: Commit
git add backend/tests/test_sample_freight.py backend/graphify-out web-admin/graphify-out
git commit -m "test: cover freight draft clientKey; refresh graphify"

Spec coverage checklist

Spec 要求 Task
biz_sample_freight / _item + 公共字段 1, 2
字典 sample_transport_mode 预置 sfkd/zxc + 自动入库 1, 3
运费 API CRUD/link/unlink 4, 5
协议详情 freight enrichment 6, 7
停写旧运输 6, 7
freightDraft.clientKey 协议保存 4, 6, 7, 10
配置运费 UI 新建/关联/取消 9, 10
查看态仅方式+金额 10
不进合同/缴费 9/10 不改 payment;验收手测
旧表保留不迁移 1(不删表)、6/7 只停写
graphify 11

Placeholder / 一致性自检

  • 无 TBD;generate_uuid 导入路径实现时与仓库现用处对齐。
  • sampleSource 常量全程使用 cell_storage / research_donation。
  • API 路径统一 /web/admin/sample-freights。
  • 金额字段名统一 freightAmount / freight_amount。