Files
shanxia2.0/docs/superpowers/specs/2026-08-26-pl-multi-stop-redesign-design.md
T
2026-08-27 14:23:18 +08:00

382 lines
14 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.
# 运输计划 · 一车多卸重构 — 设计文档
> **日期**: 2026-08-26
> **状态**: 待审阅(brainstorming 已确认核心决策)
> **前置**: `docs/运输计划管理-需求说明文档.md`、`docs/superpowers/specs/2026-06-25-pl-plan-archive-design.md`、`docs/superpowers/specs/2026-06-21-pl-arrange-transport-refine-design.md`
> **示意稿**: `docs/fresh-mint-pl-multi-stop-understanding-demo.html`、`docs/fresh-mint-pl-multi-stop-print-rules-demo.html`
## 目标
支持**一车多卸**:一个运输计划可包含 **1 个装货段 + N 个送货段**;保留计划级基础信息、产品总量、运输信息与运费;将原「供方/需方」拆为装货/送货停靠段,并联动归档、地磅、司机/现场链接、打印与运费结算。
---
## 已确认决策(2026-08-26)
| # | 决策 | 说明 |
|---|------|------|
| D1 | **装货仅 1 段** | 不支持一车多装;取货/装货信息唯一 |
| D2 | **送货多段** | 支持多客户、多地址、多合同 |
| D3 | **数量校验** | 装货合计 / 送货合计 / 计划产品总量不平衡时 **仅告警**,允许暂存 |
| D4 | **内部主体** | `安庆市山峡化工有限公司`、`安徽云栋化工有限公司`(`is_internal_customer_name`) |
| D5 | **装货 + 内部主体** | 免合同;对公司=**出库** → 默认勾选 **地磅出库单** |
| D6 | **送货 + 内部主体** | 免合同;对公司=**入库** → 默认勾选 **地磅入库单** |
| D7 | **归档字段命名** | 装货段固定 **入库数量/单价/金额**;送货段固定 **出库数量/单价/金额**(不因客户切换字段名) |
| D8 | **归档粒度** | **整单一起归档**;所有段数据齐备后一次提交 |
| D9 | **司机链接** | **1 个/计划**;页内按装货 + 各送货段分别上传附件 |
| D10 | **现场链接** | **1 个/计划**;**仅展示**山峡/云栋且勾选地磅的段;外部客户段不展示;先数量后附件 |
| D11 | **送提货单打印** | 多送货客户时先选客户;**同客户多段合并一张**;地址/联系人取该客户 **第一个送货段**;地址不同不拆单 |
| D12 | **入库单打印** | **送货单位** = 装货客户(唯一) |
| D13 | **分装单打印** | 不考虑客户信息 |
| D14 | **运费** | 仍计划级;新增 **`freight_remark` 运费备注** |
| D15 | **历史迁移** | 旧计划自动拆为 **1 装货 + 1 送货** |
---
## 非目标(本期不做)
- 一车多装(装货多段)
- 分段先后归档
- 运费按装货/送货段拆分
- 分装单按客户筛选
- CRM / 地磅系统本体改造(仅联动字段与生成规则)
---
## 一、业务模型
### 1.1 典型场景(客户举例)
供应商装 **30t 硫酸** → A 客户 **10t** → B 客户 **15t** → 山峡入库 **5t**。
```
pl_plan(计划总量 30t)
├── pl_plan_load(装货 ×1) XX 供应商 · 30t
└── pl_plan_delivery(送货 ×N)
├── #1 A 客户 · 10t
├── #2 B 客户 · 15t
└── #3 山峡 · 5t(内部入库地磅)
```
### 1.2 保留 vs 改造
| 区域 | 处理 |
|------|------|
| 基础信息 | **保留**于 `pl_plan` |
| 产品信息 | **保留**于 `pl_plan_product`(计划总量主数据) |
| 运输信息 | **保留**于 `pl_plan` + `pl_transport`(安排后) |
| 供方/需方 | **废弃 UI**;数据迁入装货/送货段 |
| 取送地址 | **下沉**至装货段 / 各送货段 |
| 运费 | **保留计划级** + `freight_remark` |
### 1.3 `plan_type` 处理
- 用户 **不再手选** 直销/入库/出库。
- 系统根据段组合 **推导展示 Tag**(列表/查看用),规则示例:
- 存在内部装货段 → 含「出库」
- 存在内部送货段 → 含「入库」
- 仅外部装货+外部送货 → 「直销/调拨」
- 数据库 `pl_plan.plan_type` **保留列**(兼容报表/迁移),由段推导写入或置 `direct` 兜底。
---
## 二、数据模型
### 2.1 新增表
#### `pl_plan_load`(装货段,plan 1:1)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(64) PK | UUID |
| plan_id | VARCHAR(64) | 计划 ID,唯一(每计划一条) |
| customer_id | VARCHAR(64) | 装货客户 |
| customer_name | VARCHAR(200) | 冗余 |
| address | VARCHAR(500) | 装货地址 |
| lng/lat | DECIMAL | 坐标 |
| load_time | DATETIME | 装货时间 |
| contact | VARCHAR(200) | 联系方式 |
| notes | VARCHAR(500) | 注意事项 |
| generate_dibang | TINYINT | 是否生成地磅(内部默认 1) |
| dibang_direction | VARCHAR(10) | `outbound`(内部装货)/ `none` |
| 公共字段 | … | tenant_no … del_flag |
#### `pl_plan_load_product`(装货产品行)
| 字段 | 说明 |
|------|------|
| load_id / plan_id | 外键 |
| plan_product_id | 关联计划产品主行 |
| product_id / product_name / model / unit | 快照 |
| plan_qty | 本段计划数量 |
| package_spec | 包装规格(可覆盖) |
| delivery_method | 送货方式(可覆盖) |
| remark | 段内备注 |
| inbound_qty / inbound_price / inbound_amount | **归档**(装货侧入库字段) |
#### `pl_plan_delivery`(送货段,plan 1:N)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | VARCHAR(64) PK | UUID |
| plan_id | VARCHAR(64) | 计划 ID |
| seq | INT | 顺序(打印、链接展示) |
| customer_id / customer_name | | 送货客户 |
| address / lng / lat | | 送货地址 |
| delivery_time | DATETIME | 送货时间 |
| contact / notes | | 联系 / 注意事项 |
| generate_dibang | TINYINT | 内部默认 1 |
| dibang_direction | VARCHAR(10) | `inbound`(内部送货)/ `none` |
| 公共字段 | … | |
#### `pl_plan_delivery_product`(送货产品行)
| 字段 | 说明 |
|------|------|
| delivery_id / plan_id | 外键 |
| plan_product_id | 关联计划产品 |
| plan_qty / package_spec / delivery_method / remark | 段内可编辑 |
| outbound_qty / outbound_price / outbound_amount | **归档**(送货侧出库字段) |
#### `pl_plan` 新增字段
| 字段 | 说明 |
|------|------|
| freight_remark | VARCHAR(512) 运费备注 |
### 2.2 合同关联
扩展 `pl_plan_contract`:
| 变更 | 说明 |
|------|------|
| `side` | 新增枚举 `load` / `delivery`(保留 `supplier`/`demander` 只读兼容旧数据) |
| `stop_id` | VARCHAR(64),装货段 ID 或送货段 ID |
规则:内部客户段 **不强制合同**;外部客户段 **至少选一个合同**(与现逻辑一致)。
### 2.3 链接与回传存储
**司机附件(按段)** — 新增 `pl_transport_driver_feedback` 或扩展现有司机回传 JSON:
| 字段 | 说明 |
|------|------|
| transport_id / plan_id | |
| stop_type | `load` / `delivery` |
| stop_id | 段 ID |
| attachment_urls | TEXT JSON |
| remark | 可选 |
**现场回传(仅内部+地磅段)** — 扩展 `pl_transport_site_link` 或等价结构:
| 字段 | 说明 |
|------|------|
| stop_type / stop_id | 关联装货或送货段 |
| quantity | 先填数量 |
| site_feedback_images | 后传附件 |
| business_type | 入库/出库(地磅方向) |
**Token 策略(D9/D10)**:
- `pl_transport.driver_access_token`:**1 个/运输单**
- 现场:运输单级 **1 个 site token**(可复用现有 transport 级 token 字段);公网页按 `generate_dibang=1` 且内部客户的段渲染分区;**不展示外部段**
### 2.4 地磅联动
`pl_transport_db_link` 增加 `stop_type`、`stop_id`,按段生成地磅计划:
| 段 | 客户 | generate_dibang | 地磅业务类型 |
|----|------|-----------------|--------------|
| load | 外部 | 0 | — |
| load | 内部 | 1 | **出库** |
| delivery | 外部 | 0 | — |
| delivery | 内部 | 1 | **入库** |
安排运输弹窗:按段列出勾选状态,可改;调整运输时沿用现有 revoke 规则。
---
## 三、表单(`PlPlanFormDialog`)
### 3.1 区域顺序
```
基础信息 → 产品信息 → 装货信息(单段)→ 送货信息(可增删)→ 运输信息(安排前只读/占位)
```
### 3.2 装货信息
- 客户选择 → 合同多选(内部免选)
- 产品表:从 `pl_plan_product` 带出,可改 `plan_qty`、包装、送货方式、备注
- 地址 / 时间 / 联系 / 注意事项
- 勾选「生成地磅单」:外部默认关;内部默认开(出库)
### 3.3 送货信息
- **+ 添加送货段**;每段独立客户、合同、产品行、地址信息
- 内部客户:免合同 + 默认入库地磅
- 删除段、排序(`seq`)
### 3.4 保存校验
| 校验 | 行为 |
|------|------|
| 装货段缺失 | **阻断** |
| 送货段 0 条 | **阻断** |
| 数量不平衡 | **ElMessage.warning**,允许保存 |
| 外部段无合同 | **阻断**(提交/安排时) |
### 3.5 列表列调整(建议)
- 「客户」列改为摘要:`装货: XX / 送货: A,B,山峡` 或主送货客户 + `等N家`
- 保留车牌、跟进状态等
---
## 四、安排运输
- 仍 **一车一次** `create_transport_for_plan`
- 地磅勾选汇总各段 `generate_dibang`
- 生成司机 token ×1、现场 token ×1
- 入库/出库类型计划 IO 单:按内部段生成(与地磅方向一致)
- 危化证 `hazard_cert_ready`:**仍计划级**,安排前校验
---
## 五、司机 / 现场公网页
### 5.1 司机页(1 URL)
```
[装货] {customerName} — 附件上传区
[送货 #1] {customerName} — 附件上传区
[送货 #2] ...
```
### 5.2 现场页(1 URL)
仅渲染满足 `is_internal_customer_name && generate_dibang` 的段:
```
[装货·山峡出库] 数量 → 附件
[送货·山峡入库] 数量 → 附件
```
外部客户段 **不出现**。
---
## 六、打印
| 单据 | 入口 | 数据规则 |
|------|------|----------|
| **送(提)货单** | 计划列表/查看/安排 | 送货客户数 ≥2 → `PlPlanDeliveryCustomerSelectDialog`;同 `customer_id` 合并产品行;地址/联系人取 **seq 最小** 的送货段;忽略地址差异 |
| **入库单** | 同上 | **送货单位** = `pl_plan_load.customer_name`;产品取装货段或计划总量(实现时与模板对齐) |
| **分装单** | 同上 | 不读客户;按计划产品 |
涉及文件:
- `web-admin/src/components/business/pl-delivery-slip-print-dialog/`
- `web-admin/src/components/business/pl-inbound-slip-print-dialog/`
- `web-admin/src/components/business/pl-subpack-slip-print-dialog/`
- `web-admin/src/utils/pl-delivery-slip-template.ts` 等
---
## 七、归档(`PlPlanArchiveDialog`)
### 7.1 弹窗结构
```
上区
├── 装货归档(入库数量/单价/金额 × 产品行)
├── 送货归档 #1 … #N(出库数量/单价/金额 × 产品行)
└── 运费(计划级 + freight_remark)
下区:PlPlanViewTabs(含分段链接回传)
```
### 7.2 提交规则(D8)
- 装货 + **全部**送货段归档字段必填(含品牌分量若启用)
- **一次 API** 原子:写各段归档字段 + 计划运费 + `archive_status=archived` + `follow_status=completed`
- 已归档调整:同现有「调整」模式
### 7.3 库存 / 金额汇总
- 内部装货出库、内部送货入库:同步库存台账(沿用 `apply_archive_product_quantities` 思路,改为按段聚合)
- `pl_plan_product` 汇总字段可由段归档回写或仅展示段级(实现时 `_row_to_dict` 为权威)
---
## 八、运费结算
- **不变**:`pl_freight_settlement` 仍关联计划/运输;准入「已归档 + has_freight」
- **新增展示**:归档页、结算草稿可显示 `freight_remark`
- 未结算计划列表:客户摘要可展示多送货客户
---
## 九、历史数据迁移
脚本:`backend/scripts/migrate_pl_plan_to_multi_stop.py`(新建)
对每条 `pl_plan`(`del_flag=0`):
1. 创建 `pl_plan_load`:`supplier_id/name` → customer;`pickup_*` → address/time/contact/notes
2. 创建 `pl_plan_delivery` seq=1:`customer_id/name`、`destination_*` → 送货地址
3. 复制 `pl_plan_product` → `load_product` + `delivery_product`(数量用原 `inbound_estimate`/`outbound_estimate` 或 `quantity`)
4. 迁移 `pl_plan_contract`:`supplier`→`load`,`demander`→`delivery`(挂 stop_id)
5. 内部客户:按 D5/D6 设置 `generate_dibang`
6. **不修改**已归档计划的归档数量(从原 `pl_plan_product.inbound_*` / `outbound_*` 写入对应段产品行)
迁移后 `pl_plan` 上 `supplier_*` / `customer_*` / `pickup_*` / `destination_*` **保留冗余**(只读兼容),新写入走段表。
---
## 十、API 概要
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/transport/plan/{id}` | 返回 load + deliveries + products |
| POST | `/transport/plan/create-full` | Body 含 load、deliveries[] |
| PUT | `/transport/plan/{id}` | 全量更新段 |
| POST | `/transport/plan/{id}/archive` | Body 含各段归档明细 + freight_remark |
| GET | `/transport/plan/{id}/print-delivery-slip?customerId=` | 按客户聚合送提货单数据 |
Schema 权威:`_row_to_dict` + Pydantic `PlanLoadRead` / `PlanDeliveryRead`;跑 `check_field_consistency.py`。
---
## 十一、测试要点
| 场景 | 期望 |
|------|------|
| 30=10+15+5 保存 | 成功;不平衡时 warning |
| 装货山峡 | 默认出库地磅 |
| 送货山峡 | 默认入库地磅 |
| 送提货单 3 客户 | 弹出客户选择 |
| 同客户 2 段 | 合并 1 张;地址取第一段 |
| 司机页 | 全段可见;现场页仅内部段 |
| 归档 | 缺一段字段 → 阻断 |
| 迁移旧单 | 1 装货 + 1 送货;打印/归档可用 |
---
## 十二、界面模式
- 计划表单:P06 + `PlPlanFormDialog` 扩展
- 归档:P06 + `PlPlanArchiveDialog`
- 打印选客户:新小弹窗(P09 选型)
---
## 附录:决策对照(原文 vs 实现口径)
| 客户表述 | 实现口径 |
|----------|----------|
| 取货信息叫「入库」 | 归档字段名 `inbound_*`;内部装货时地磅为 **出库** |
| 送货信息叫「出库」 | 归档字段名 `outbound_*`;内部送货时地磅为 **入库** |
| 一个计划一个现场/司机链接 | transport 级 token;页内按段分区 |
| 送提货单同客户 | 合并;地址取首段 |