|
@@ -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
|
|
|
|
|
+- [ ] 归属公司仍手选必填
|
|
|
|
|
+- [ ] 安排运输流程不变
|
|
|
|
|
+- [ ] 旧单打开供方/需方/新合同为空
|