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>
This commit is contained in:
1 parent
a6138d60bc
commit
95a28d5f0f
1 file changed
+884
@@ -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<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`。
|
||||
Reference in new issue
Block a user