diff --git a/docs/superpowers/plans/2026-10-07-sample-freight-batch.md b/docs/superpowers/plans/2026-10-07-sample-freight-batch.md new file mode 100644 index 00000000..4dc0d589 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-sample-freight-batch.md @@ -0,0 +1,884 @@ +# 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** + +```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** + +```bash +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 类** + +```python +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** + +```bash +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`: + +```python +"""样本运费:运输方式字典解析与关联规则""" + +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: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_sample_freight.py::TestResolveTransportMode -v +``` + +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现 `sample_transport_mode.py`** + +```python +"""样本运输方式字典 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: 跑测通过** + +```bash +cd backend && pytest tests/test_sample_freight.py::TestResolveTransportMode -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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(关键函数签名,实现须完整)** + +必须提供: + +```python +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 或内存逻辑)** + +至少覆盖: + +```python +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: 跑测** + +```bash +cd backend && pytest tests/test_sample_freight.py -v +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +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`: + +```python +from app.api.sample_freight import router as sample_freight_router +# __all__ 增加 "sample_freight_router" +``` + +`main.py`: + +```python +from app.api import ..., sample_freight_router +app.include_router(sample_freight_router, prefix=settings.API_V1_PREFIX) +``` + +- [ ] **Step 3: Commit** + +```bash +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 行),改为: + +```python +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: 手动或补测** + +```bash +cd backend && pytest tests/test_sample_freight.py -v +``` + +- [ ] **Step 4: Commit** + +```bash +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** + +```bash +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: 实现** + +```typescript +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 +} + +type PageList = { list: T[]; total: number; page: number; pageSize: number } + +export function fetchSampleFreightList(params?: { + page?: number + pageSize?: number + keyword?: string +}) { + return request.get>({ + url: '/web/admin/sample-freights', + params + }) +} + +export function fetchSampleFreightDetail(id: string) { + return request.get({ url: `/web/admin/sample-freights/${id}` }) +} + +export function fetchCreateSampleFreight(data: { + transportMode: string + freightAmount: number + items: SampleFreightItem[] +}) { + return request.post({ + url: '/web/admin/sample-freights', + data, + showSuccessMessage: true + }) +} + +export function fetchUpdateSampleFreight( + id: string, + data: { transportMode?: string; freightAmount?: number } +) { + return request.put({ + url: `/web/admin/sample-freights/${id}`, + data, + showSuccessMessage: true + }) +} + +export function fetchLinkSampleFreight(id: string, items: SampleFreightItem[]) { + return request.post({ + url: `/web/admin/sample-freights/${id}/link`, + data: { items }, + showSuccessMessage: true + }) +} + +export function fetchUnlinkSampleFreight(id: string, items: SampleFreightItem[]) { + return request.post({ + url: `/web/admin/sample-freights/${id}/unlink`, + data: { items }, + showSuccessMessage: true + }) +} +``` + +- [ ] **Step 2: Commit** + +```bash +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`,表格单选一行 + +**确定逻辑:** + +```typescript +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** + +```bash +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** + +- `