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

885 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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**
- `<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**
```bash
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: 跑后端相关测试**
```bash
cd backend && pytest tests/test_sample_freight.py tests/test_protocol_list_sample_reception_qc.py -v
```
Expected: PASS(若后者依赖旧 transport 写入,按失败信息改为不依赖或跳过该断言)
- [ ] **Step 3: 更新知识图谱**
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 4: Commit**
```bash
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`。