Explorar o código

docs: add transport plan supplier/demander free-product design and plan.

Capture agreed rules for dual-party customers, multi-contract sides, ProductSelector-only products, and no historical migration before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
ca6aca2b14

+ 280 - 0
docs/superpowers/plans/2026-08-08-pl-plan-supplier-demander-free-product.md

@@ -0,0 +1,280 @@
+# 运输计划供方/需方 + 自由选产品 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:** `pl_plan` 用 `supplier_*` 表供方、`customer_*` 表需方;新表 `pl_plan_contract` 按 `side=supplier|demander` 存多合同。前端 `PlPlanFormDialog` 重构区块并接入 `CustomerSelector` + 合同多选 + `ProductSelector`。历史数据不迁移,编辑旧单供方/需方/合同为空。
+
+**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus;现有 `planApi.createFull` / 更新全量接口。
+
+**Spec:** `docs/superpowers/specs/2026-08-08-pl-plan-supplier-demander-free-product-design.md`
+
+---
+
+## 文件结构
+
+| 操作 | 文件 | 职责 |
+|------|------|------|
+| Create | `backend/scripts/sql/` 或 `docs/sql/` 增量脚本 | `pl_plan_contract` DDL |
+| Modify | `backend/app/models/pl_models.py` | `PlPlanContract` 模型 |
+| Modify | `backend/app/schemas/pl/plan.py` | 入参/出参合同列表、校验相关字段 |
+| Modify | `backend/app/services/pl_plan_service.py` | 分侧合同校验、读写关联、去掉单一合同必填 |
+| Modify | `backend/app/api/pl/plan.py` | create-full / update 路径串联新校验与持久化 |
+| Create | `backend/app/services/pl_internal_customer.py`(或 utils) | 内部客户名称常量与 `is_internal_customer(name)` |
+| Modify | `web-admin/src/api/pl/index.ts` | `Plan` / `PlanCreateFullPayload` 类型 |
+| Create/Modify | `web-admin/src/utils/pl-internal-customer.ts` | 与后端一致的内部客户判定 |
+| Create | `web-admin/src/components/business/pl-plan-contract-multi-select/`(或内联) | 按客户过滤的合同多选 |
+| Modify | `web-admin/src/components/business/pl-plan-form-dialog/index.vue` | 主表单重构 |
+| Modify | `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue` | 如需配合「添加产品」工具栏 |
+| Modify | `web-admin/src/views/pl/plan/index.vue` | 列表列:供方/需方/合同摘要 |
+| Modify | 查看弹窗相关(`SystemViewDialog` 字段或 plan 内嵌) | 展示供方/需方与两侧合同 |
+| Modify | 复制预填逻辑(同 form-dialog) | 复制新字段,不映射旧单合同 |
+
+---
+
+### Task 1: DDL + Model `pl_plan_contract`
+
+**Files:**
+- Create: `docs/sql/` 下增量 SQL(与仓库现有 pl 迁移脚本风格一致)
+- Modify: `backend/app/models/pl_models.py`
+
+- [ ] **Step 1: 编写建表 SQL**
+
+```sql
+CREATE TABLE IF NOT EXISTS `pl_plan_contract` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `plan_id` varchar(64) NOT NULL COMMENT '运输计划ID',
+  `side` varchar(20) NOT NULL COMMENT 'supplier-供方 demander-需方',
+  `contract_id` varchar(64) NOT NULL COMMENT 'CRM合同ID',
+  `contract_no` varchar(50) DEFAULT NULL COMMENT '合同编号快照',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0',
+  `order_num` int DEFAULT NULL,
+  `remark` varchar(512) DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL,
+  `del_flag` smallint NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  KEY `idx_pl_plan_contract_plan` (`plan_id`, `del_flag`),
+  KEY `idx_pl_plan_contract_side` (`plan_id`, `side`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-合同关联(分侧多选)';
+```
+
+- [ ] **Step 2: 增加 SQLAlchemy 模型 `PlPlanContract`**
+
+字段与上表一致;`side` 注释写清枚举。
+
+- [ ] **Step 3: 本地执行 DDL(或文档注明部署时执行)并确认表存在**
+
+---
+
+### Task 2: 内部客户工具 + Schema
+
+**Files:**
+- Create: `backend/app/services/pl_internal_customer.py`(路径可微调,保持可单测)
+- Modify: `backend/app/schemas/pl/plan.py`
+
+- [ ] **Step 1: 内部客户常量与函数**
+
+```python
+INTERNAL_CUSTOMER_NAMES = frozenset({
+    "安庆市山峡化工有限公司",
+    "安徽云栋化工有限公司",
+})
+
+def is_internal_customer_name(name: Optional[str]) -> bool:
+    return (name or "").strip() in INTERNAL_CUSTOMER_NAMES
+```
+
+- [ ] **Step 2: Schema 增加合同列表结构**
+
+例如:
+
+```python
+class PlanContractLink(BaseModel):
+    contract_id: str = Field(..., alias="contractId")
+    contract_no: Optional[str] = Field(None, alias="contractNo")
+    model_config = {"populate_by_name": True}
+```
+
+在 `PlanCreateFull` / 更新体 / `PlanRead` 增加:
+
+- `supplier_contracts: list[PlanContractLink]`(alias `supplierContracts`)
+- `demander_contracts: list[PlanContractLink]`(alias `demanderContracts`)
+
+`contract_id` 主字段保留可选,不再作为创建必填。
+
+- [ ] **Step 3: 小单测(若项目便于加)覆盖 `is_internal_customer_name`**
+
+无现成服务单测基建时可跳过,改在 Task 3 用接口手工验收。
+
+---
+
+### Task 3: Service 校验与持久化
+
+**Files:**
+- Modify: `backend/app/services/pl_plan_service.py`
+- Modify: `backend/app/api/pl/plan.py`
+
+- [ ] **Step 1: 替换 `validate_create_full`**
+
+伪规则:
+
+```python
+def validate_create_full(payload: PlanCreateFull) -> None:
+    if not payload.supplier_id or not (payload.supplier_name or "").strip():
+        raise HTTPException(422, detail="请选择供方客户")
+    if not payload.customer_id or not (payload.customer_name or "").strip():
+        raise HTTPException(422, detail="请选择需方客户")
+    if not payload.products:
+        raise HTTPException(422, detail="请至少保留一条产品明细")
+
+    supplier_contracts = payload.supplier_contracts or []
+    demander_contracts = payload.demander_contracts or []
+    if not is_internal_customer_name(payload.supplier_name) and not supplier_contracts:
+        raise HTTPException(422, detail="请选择供方合同")
+    if not is_internal_customer_name(payload.customer_name) and not demander_contracts:
+        raise HTTPException(422, detail="请选择需方合同")
+    # 运费块原有逻辑保留
+```
+
+对每个合同 ID 调用现有 `assert_contract_selectable_for_plan`,并校验合同 `customer_id` 属于对应侧客户。
+
+- [ ] **Step 2: 创建/更新时写入 `pl_plan_contract`**
+
+- 创建:插入两侧关联行。
+- 更新:该 plan 下旧关联软删,再插入新集合。
+- 主表:写 `supplier_*`、`customer_*`;`contract_id`/`contract_no` 置空或不更新(按设计「不再作为唯一源」)。
+- 可选:`destination_name = customer_name` 以兼容 `determine_plan_type`。
+
+- [ ] **Step 3: 详情组装返回 `supplierContracts` / `demanderContracts`**
+
+列表若需要摘要:拼接合同号或返回数组由前端处理。
+
+- [ ] **Step 4: 手工/接口验证**
+
+- 外部+外部无合同 → 422  
+- 内部供方 + 外部需方仅需方合同 → 200  
+- 多合同回显正确  
+
+---
+
+### Task 4: 前端类型与内部客户工具
+
+**Files:**
+- Modify: `web-admin/src/api/pl/index.ts`
+- Create: `web-admin/src/utils/pl-internal-customer.ts`
+
+- [ ] **Step 1: 类型对齐后端**
+
+`Plan`、`PlanCreateFullPayload` 增加供方/需方合同数组;`supplierId`/`customerId` 用于提交。
+
+- [ ] **Step 2: 镜像内部客户判定**
+
+```ts
+export const PL_INTERNAL_CUSTOMER_NAMES = [
+  '安庆市山峡化工有限公司',
+  '安徽云栋化工有限公司'
+] as const
+
+export function isPlInternalCustomerName(name?: string | null): boolean {
+  return PL_INTERNAL_CUSTOMER_NAMES.includes((name || '').trim() as any)
+}
+```
+
+---
+
+### Task 5: 合同多选组件
+
+**Files:**
+- Create: `web-admin/src/components/business/pl-plan-contract-multi-select/index.vue`(名称可按项目习惯)
+
+- [ ] **Step 1: Props**
+
+- `customerId: string`
+- `modelValue: string[]`(合同 ID)或 `{contractId, contractNo}[]`
+- `disabled`(无客户时禁用)
+
+- [ ] **Step 2: 行为**
+
+- 有 `customerId` 时拉取该客户下 `approved`/`executing` 合同(复用现有合同列表 API,参考 `contract-selector`)。
+- 多选;展示合同编号/名称。
+- `customerId` 变化时清空已选并 `emit`。
+
+- [ ] **Step 3: 在故事外用表单临时挂载目测可选/清空**
+
+---
+
+### Task 6: 重构 `PlPlanFormDialog` 主流程
+
+**Files:**
+- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue`
+- Modify: `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue`(若需要)
+
+- [ ] **Step 1: 替换顶部「合同信息」为供方/需方两节**
+
+每节:客户选择 + 合同多选;合同 `required` 绑定 `!isPlInternalCustomerName(name)`。
+
+- [ ] **Step 2: 产品区改为报价单模式**
+
+- 按钮「添加产品」打开 `ProductSelector`(参考 `QuoteFormPanel.vue`)。
+- 移除合同产品预览表及 `handleSelectContractProduct` 主路径。
+- 保留明细编辑/删除。
+
+- [ ] **Step 3: 提交 payload**
+
+组装 `supplierContracts` / `demanderContracts`;不再要求 `contractId`。前端保存前按 3.2 规则本地校验并 `ElMessage` 提示。
+
+- [ ] **Step 4: 编辑加载**
+
+- 从详情填供方/需方与两侧合同。
+- **不要**用旧 `contractId` 回填多选(历史为空策略)。
+- 复制模式:复制新字段;旧源若两侧为空则复制结果也为空。
+
+- [ ] **Step 5: 浏览器走查新建三种组合 + 编辑回显 + 保存并安排**
+
+---
+
+### Task 7: 列表与查看展示
+
+**Files:**
+- Modify: `web-admin/src/views/pl/plan/index.vue`
+- 查看弹窗字段配置(同页或共用组件)
+
+- [ ] **Step 1: 列表列**
+
+- 保留/明确「客户名称」= 需方 `customerName`。
+- 增加「供方」列(`supplierName`)。
+- 「合同」列:展示两侧摘要(如 `供: A,B / 需: C`),无则 `-`。
+
+- [ ] **Step 2: 查看弹窗**
+
+展示供方客户、供方合同列表、需方客户、需方合同列表。
+
+- [ ] **Step 3: 筛选**
+
+`customerName` 继续筛需方;本期可不加供方筛选(与设计一致)。
+
+---
+
+### Task 8: 收尾
+
+- [ ] **Step 1: 跑前端相关 lint / 后端受影响导入检查**
+- [ ] **Step 2: 更新设计文档状态为「已实现」**(实现完成后)
+- [ ] **Step 3: 按需执行 `./scripts/update-graph.sh`(有代码改动后)
+- [ ] **Step 4: 提交(仅在用户要求时)
+
+---
+
+## 验收清单(对照 Spec §8)
+
+- [ ] 供方/需方客户必填
+- [ ] 外部侧无合同不可保存;内部侧可无合同
+- [ ] 多合同落库与回显
+- [ ] 产品仅 ProductSelector
+- [ ] 归属公司仍手选必填
+- [ ] 安排运输流程不变
+- [ ] 旧单打开供方/需方/新合同为空

+ 195 - 0
docs/superpowers/specs/2026-08-08-pl-plan-supplier-demander-free-product-design.md

@@ -0,0 +1,195 @@
+# 运输计划:供方/需方 + 合同多选 + 产品自由选择
+
+**日期:** 2026-08-08  
+**状态:** 待评审  
+**选定方案:** 方案 A — 主表存供方/需方客户;关联表存多侧多合同;产品走产品库 `ProductSelector`
+
+## 1. 目标
+
+改造运输计划新建/编辑(`/#/pl/plan-module/plan` →「新增计划」/「编辑」弹窗),使:
+
+1. 明确维护 **供方信息**、**需方信息**(均从 CRM 客户选择,均必填)。
+2. 供方/需方可各自绑定 **多份合同**;合同是否必填取决于该侧客户是否为内置山峡/云栋。
+3. 产品明细 **完全按报价单方式** 从产品库自由添加;本期不做「从合同带入产品」。
+4. **归属公司** 继续手选,与供方/需方无关。
+5. 保存后的安排运输、等通知、打印、归档等 **后续流程保持现状**。
+
+## 2. 非目标
+
+- 不从合同产品预览/合同明细添加产品(可后续迭代)。
+- 不做历史数据自动迁移(旧合同、旧客户不预填到新字段)。
+- 不改造安排运输弹窗、司机/现场 H5、归档、运费结算核心流程。
+- 不改造小程序端运输计划。
+- 不改变「归属公司」枚举与手选交互。
+
+## 3. 业务规则
+
+### 3.1 客户
+
+| 字段 | 规则 |
+|------|------|
+| 供方客户 | 必填;`CustomerSelector`(或等价客户选择组件)从 CRM 客户选择 |
+| 需方客户 | 必填;同上 |
+
+内置内部客户(客户表已存在,按 **客户名称精确匹配** 判定):
+
+- `安庆市山峡化工有限公司`
+- `安徽云栋化工有限公司`
+
+### 3.2 合同(分侧、可多选)
+
+对供方、需方 **分别** 应用:
+
+| 该侧客户 | 合同 |
+|----------|------|
+| 是山峡或云栋 | 可不选(允许 0 份) |
+| 不是山峡/云栋 | **至少选 1 份**;可多选 |
+
+组合示例:
+
+| 供方 | 需方 | 供方合同 | 需方合同 |
+|------|------|----------|----------|
+| 外部 | 外部 | 必选(多) | 必选(多) |
+| 内部 | 外部 | 可选 | 必选(多) |
+| 外部 | 内部 | 必选(多) | 可选 |
+| 内部 | 内部 | 可选 | 可选 |
+
+其它约束:
+
+- 合同选择器按 **该侧已选客户** 过滤;未选客户时不可选合同。
+- 可选合同状态与现状一致:`approved` / `executing`(已审批、执行中)。
+- 更换客户时:清空该侧已选合同(避免串户)。
+
+### 3.3 产品
+
+- 交互对齐报价单:`添加产品` → `ProductSelector` → 确认加入明细。
+- 明细可改数量、备注、别名/品牌等(沿用现有计划产品行可编辑能力)。
+- 至少保留一条产品明细方可保存(与现状一致)。
+- **不**展示「合同产品预览 + 选择」入口(本期移除该路径)。
+
+### 3.4 归属公司与其它基础字段
+
+- `belongsCompany`:必填,手选(安庆山峡 / 安徽云栋),与供方/需方无关。
+- 创建日期、计划日期、单据类型、跟进人、运输信息等:保持现有语义;运输安排仍在列表「安排运输」完成。
+
+### 3.5 历史数据
+
+- 改造后打开旧计划编辑:供方客户、需方客户、两侧合同 **均显示为空**,不把旧 `contractId` / `customerName` 等映射进新 UI。
+- 业务后续手工补全;保存时按新规则校验。
+- 库内旧 `pl_plan.contract_id` / `contract_no` / `customer_*` 等字段可保留原值,但 **新读写以新模型为准**;新保存不再依赖「单一合同必填」。
+
+## 4. 交互结构(新建/编辑弹窗)
+
+建议区块顺序:
+
+```
+供方信息
+  ├─ 选择客户(必填)
+  └─ 选择合同(多选;按 3.2 必填/可选)
+需方信息
+  ├─ 选择客户(必填)
+  └─ 选择合同(多选;按 3.2 必填/可选)
+基础信息
+  └─ 创建日期 / 计划日期 / 归属公司 / 单据类型 / 跟进人 …
+产品信息
+  └─ 添加产品(ProductSelector)+ 明细表
+运输信息
+  └─ 与现状一致(车牌/承运商/取送货等)
+```
+
+- 仍为单页长表单(非 `el-steps` 向导)。
+- 底部:取消 / 保存(及现有「保存并安排」若保留则行为不变:先按新规则校验保存,再打开安排弹窗)。
+
+## 5. 数据模型
+
+### 5.1 主表字段语义调整(`pl_plan`)
+
+| 用途 | 字段 | 说明 |
+|------|------|------|
+| 供方客户 | `supplier_id` / `supplier_name` | 存 CRM 客户 ID/名称;新建编辑必填 |
+| 需方客户 | `customer_id` / `customer_name` | 复用现有客户字段表示需方;列表「客户名称」即需方 |
+| 兼容保留 | `destination_id` / `destination_name` | 本期可不作为需方主数据源;若需兼容 `determine_plan_type`,保存时可把需方名称同步到 `destination_name`(可选实现细节) |
+| 旧单合同 | `contract_id` / `contract_no` | **废弃为唯一合同源**;新保存可清空或不再更新;读取以关联表为准 |
+
+> 说明:需方使用 `customer_*`,便于列表筛选「客户名称」自然对到需方,改动面较小。
+
+### 5.2 新增关联表 `pl_plan_contract`
+
+建议字段(满足 database-conventions 公共字段):
+
+| 字段 | 说明 |
+|------|------|
+| `id` | VARCHAR(64) PK |
+| `plan_id` | 计划 ID |
+| `side` | `supplier` \| `demander` |
+| `contract_id` | CRM 合同 ID |
+| `contract_no` | 合同编号快照 |
+| `tenant_no` … `del_flag` | 公共字段 |
+
+约束建议:
+
+- 同一 `plan_id + side + contract_id` 在未删除下唯一。
+- 软删除;计划删除/更新时替换该侧关联集合。
+
+### 5.3 API
+
+- `createFull` / `updateFull`(或现有等价全量接口):
+  - 入参增加:`supplierId`/`supplierName`、`customerId`/`customerName`(需方)、`supplierContractIds: string[]`、`demanderContractIds: string[]`(或结构化合同列表)。
+  - **去掉**「`contractId` 必填」校验;改为分侧合同规则校验。
+  - 产品:仍提交产品明细列表;**不再要求**产品必须属于某合同行。
+- 详情/列表:返回供方/需方客户;返回两侧合同列表(至少 id + no);列表可摘要展示合同编号(逗号拼接或「N 份」——实现时与 UI 对齐,默认详情完整、列表可摘要)。
+
+### 5.4 内部客户判定(前后端一致)
+
+常量(前后端各一份或后端为准、前端镜像):
+
+```text
+安庆市山峡化工有限公司
+安徽云栋化工有限公司
+```
+
+- 前端:控制合同多选是否 `required`、提示文案。
+- 后端:保存时强制校验,防止绕过。
+
+## 6. 前端改动要点
+
+| 区域 | 变更 |
+|------|------|
+| `pl-plan-form-dialog` | 去掉「选合同 → 合同产品预览选产品」主路径;改为供方/需方区块 + `ProductSelector` |
+| 客户选择 | 复用现有 `CustomerSelector`(报价单/合同已用) |
+| 合同多选 | 扩展或新建「按客户过滤的合同多选」组件;状态过滤同现状 |
+| 产品表 | 复用/对齐 `PlPlanFormProductTable` 的可编辑能力;增加「添加产品」工具按钮 |
+| 列表/查看 | 展示供方、需方;合同由单号改为两侧摘要;筛选「客户名称」继续筛需方 `customerName` |
+| 复制计划 | 复制供方/需方客户与两侧合同 ID、产品与现有可复制字段;仍不复制车辆/安排态 |
+
+UI 规范:Fresh Mint;弹窗 `dialog-form-layout`;按钮带图标;状态 Tag 等既有约定不变。
+
+## 7. 后端改动要点
+
+| 区域 | 变更 |
+|------|------|
+| SQL | `docs/sql/` 增加 `pl_plan_contract`;同步基线说明 |
+| Model / Schema / CRUD / Service | 读写关联合同;`validate_create_full` 等改为分侧规则 |
+| 产品 | 创建计划产品不再依赖合同产品行匹配(若现有逻辑有强绑定则拆除) |
+
+## 8. 验收标准
+
+1. 新建:供方/需方客户必填;外部客户侧无合同不可保存;内部客户侧无合同可保存。
+2. 两侧均可选择多份合同并成功落库、再次编辑回显。
+3. 产品仅能通过产品库添加;无合同产品预览选择。
+4. 归属公司仍必填手选。
+5. 保存后安排运输等流程与改造前一致。
+6. 打开历史计划:供方/需方/新合同列表为空;补全前按新规则拦截保存。
+
+## 9. 风险与后续
+
+- 列表「客户名称」语义变为 **需方**;若业务仍想按供方筛,需另加筛选项(本期可不做)。
+- 旧 `contract_id` 残留可能被其它报表/脚本读取——若有依赖需盘点;本期以运输计划主流程为准。
+- 后续若要「从合同带入产品」,可在产品区增加第二入口,不阻塞本期。
+
+## 10. 自检
+
+- [x] 无 TBD/占位符未决项(实现细节如列表合同摘要文案可在实现计划中定)
+- [x] 与「产品自由选、合同分侧多选、历史留空」无矛盾
+- [x] 范围不含安排运输改造
+- [x] 方案 A 与用户确认一致