|
|
@@ -0,0 +1,126 @@
|
|
|
+# 运输计划合同用量:按「产品 + 包装规格」唯一键
|
|
|
+
|
|
|
+**日期:** 2026-09-10
|
|
|
+**状态:** 已确认(方案 1:复合键贯穿用量链路)
|
|
|
+**关联:** `2026-08-30-pl-plan-form-available-qty-design.md`、`2026-08-30-pl-plan-contract-qty-check-design.md`、`2026-08-30-crm-contract-plan-linkage-design.md`
|
|
|
+**触发案例:** 生产合同 `AQFD-HT-[2025]309号`,产品 `SXWL007344`(次氯酸钠)两行:`25kg/桶` 15 吨 vs `槽罐车` 700 吨;现行按 `product_id` 聚合导致可安排量误用 15 吨。
|
|
|
+
|
|
|
+## 背景
|
|
|
+
|
|
|
+合同允许同一 `product_id` 多行、包装规格不同。运输计划装/送货段产品来自合同,但下列链路均以 **`product_id` only** 为键:
|
|
|
+
|
|
|
+- 可安排数量 API / 前端 cache
|
|
|
+- 安排运输硬拦(`assert_contract_qty_for_arrange`)
|
|
|
+- 合同详情产品列表「已归档数量 / 未归档已安排 / 已安排合计」挂量
|
|
|
+
|
|
|
+同品多包装时会出现 last-wins / `.first()`,额度串行。
|
|
|
+
|
|
|
+合同管理界面要求包装规格必填,故正常业务下合同行必有非空 `packageSpec`。
|
|
|
+
|
|
|
+## 已确认决策
|
|
|
+
|
|
|
+| 项 | 决策 |
|
|
|
+|----|------|
|
|
|
+| 唯一键 | `(productId + packageSpec)`,空值归一后比较 |
|
|
|
+| 合同同品同包装两行 | 业务上不会出现;脏数据防御为数量相加 |
|
|
|
+| 选品 UX | 仍按产品选,再选包装(不改为按合同行 id 选品) |
|
|
|
+| 包装选项(绑合同) | 仅本合同该产品已有包装,不用全局字典全量 |
|
|
|
+| 包装必填(绑合同) | 必填;合同侧本身也要求包装必填 |
|
|
|
+| 无合同 / 内部客户 | 不强制合同包装;可继续字典下拉;无合同额度复合键硬拦 |
|
|
|
+| 历史计划空包装 | 不批量刷库;再次保存/安排前须补选 |
|
|
|
+| 数量硬拦时机 | 仍仅安排运输硬拦;保存可超合同量(仅警告);**包装必填在保存即拦** |
|
|
|
+| 合同详情三列 | 同迭代改后端挂量,按行复合键,前端列定义可不动 |
|
|
|
+| DB schema | 不新增列、不加唯一索引 |
|
|
|
+
|
|
|
+## 设计
|
|
|
+
|
|
|
+### 1. 复合键与归一化
|
|
|
+
|
|
|
+```text
|
|
|
+key = (product_id, normalize_package_spec(package_spec))
|
|
|
+```
|
|
|
+
|
|
|
+`normalize_package_spec`:
|
|
|
+
|
|
|
+| 输入 | 结果 |
|
|
|
+|------|------|
|
|
|
+| `null` / 缺失 | `''` |
|
|
|
+| 仅空白 | `''` |
|
|
|
+| 其它 | `strip()` 后原文字符串(区分大小写与写法,如 `25kg/桶` ≠ `25Kg/袋`) |
|
|
|
+
|
|
|
+实现建议:map 内部用 `tuple[str, str]`;或稳定拼接串(须保证 `product_id` 不含分隔歧义)。前后端归一规则必须一致。
|
|
|
+
|
|
|
+### 2. 后端(真相源)
|
|
|
+
|
|
|
+文件:`backend/app/services/pl_contract_plan_qty.py`
|
|
|
+
|
|
|
+| 函数 / API | 改动 |
|
|
|
+|------------|------|
|
|
|
+| `accumulate_stop_products` | 按复合键累加;读取计划行 `package_spec` |
|
|
|
+| `merge_product_qty_maps` | key 改为复合键 |
|
|
|
+| `aggregate_contract_product_plan_qtys` | 返回复合键 → 三数量 |
|
|
|
+| `attach_plan_qty_fields_to_products` | 按行 `productId` + `packageSpec` 挂 `archivedQty` / `arrangedUnarchivedQty` / `arrangedTotalQty` |
|
|
|
+| `get_contract_product_arrange_qty_detail` | 入参增加 `packageSpec`;合同行按 `product_id` + 归一包装匹配,禁止裸 `.first()` |
|
|
|
+| `collect_plan_contract_qty_checks` / `assert_contract_qty_for_arrange` | 本计划量、已安排、合同 quantity 均按复合键;violation/reminder 带 `packageSpec` |
|
|
|
+| `GET .../contract-product-arrange-qty` | Query 增加 `packageSpec`;响应带回 `packageSpec` |
|
|
|
+
|
|
|
+计划 create/update:绑定合同时校验段产品 `packageSpec` 非空且属于该合同该产品包装集合;失败返回明确错误(与前端保存校验一致)。
|
|
|
+
|
|
|
+**不变:** `_bucket_for_plan`、装/送范围、`customer_demand` 不硬拦数量、create/update 不硬拦合同数量超额。
|
|
|
+
|
|
|
+测试:`test_pl_contract_plan_qty.py`、`test_pl_contract_qty_arrange_check.py` 覆盖同品异包装分桶与 309 类场景。
|
|
|
+
|
|
|
+### 3. 前端(运输计划)
|
|
|
+
|
|
|
+主路径:`PlPlanFormDialog` → 装/送货段 → `PlPlanStopProductTable`。
|
|
|
+
|
|
|
+1. **合同缓存**:除 `productId[]` 外缓存合同产品行摘要(至少 `productId`、`packageSpec`、数量相关字段)。工具:`utils/pl-plan-contract-product.ts` 增补收集与 `packageSpecsForProduct`。
|
|
|
+2. **选品**:`ProductSelector` 仍按去重 `productId` 限制。选中后若该产品合同侧仅一个包装可自动填入;多个则留空待选。
|
|
|
+3. **包装控件(绑合同)**:`el-select` options = 本合同该产品包装列表;换产品/合同时非法包装清空。无合同则保持字典 `crm_product_gg`。
|
|
|
+4. **可安排量**:`cacheKey` 含归一后 `packageSpec`;请求 API 传 `packageSpec`;未选包装时不请求或提示先选包装。
|
|
|
+5. **保存校验**:绑合同 → 包装必填且 ∈ 合同该产品包装集合。
|
|
|
+6. **提醒 / 弹窗**:超限与可安排弹窗展示包装以区分行。
|
|
|
+7. **次要一致性(同迭代)**:`findDeliveryProductsMissingFromLoad`、`matchPrevStopProduct` 等按 `productId + packageSpec` 匹配。
|
|
|
+
|
|
|
+合同详情 `detail.vue` / `ContractDetailDialog.vue`:列可不改,依赖后端挂量正确。
|
|
|
+
|
|
|
+### 4. 数据流(概念)
|
|
|
+
|
|
|
+```text
|
|
|
+合同明细行 (productId, packageSpec, quantity)
|
|
|
+ │
|
|
|
+ ├─► 计划选品:productId 限制 + 包装下拉来自合同行
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+计划装/送货行 (productId, packageSpec, planQty)
|
|
|
+ │
|
|
|
+ ▼
|
|
|
+pl_contract_plan_qty 按 (productId, norm(packageSpec)) 聚合
|
|
|
+ │
|
|
|
+ ├─► 合同详情三列
|
|
|
+ ├─► 可安排数量 API
|
|
|
+ └─► 安排运输硬拦
|
|
|
+```
|
|
|
+
|
|
|
+## 验收用例
|
|
|
+
|
|
|
+合同 `AQFD-HT-[2025]309号`,`SXWL007344`:
|
|
|
+
|
|
|
+1. 选产品后包装下拉仅 `25kg/桶`、`槽罐车`;不选包装不能保存。
|
|
|
+2. 选 `槽罐车`:可安排量按 700 吨额度,不被 15 吨占用。
|
|
|
+3. 选 `25kg/桶`:额度按 15 吨。
|
|
|
+4. 合同详情两行三列各自独立、不串量。
|
|
|
+5. 安排运输时两包装额度互不影响;超限文案含包装规格。
|
|
|
+
|
|
|
+## 上线与回滚
|
|
|
+
|
|
|
+- 前后端同发;旧前端不传 `packageSpec` 视为空键(仅短窗口兼容)。
|
|
|
+- 无 schema 迁移;回滚恢复代码即可。
|
|
|
+- 不批量修复历史计划空包装。
|
|
|
+
|
|
|
+## 非目标
|
|
|
+
|
|
|
+- 不改为 `contractProductId` 选品/绑行。
|
|
|
+- 不为合同表加 `(product_id, package_spec)` DB 唯一约束。
|
|
|
+- 不改变「保存可超合同量、安排再硬拦」的数量策略(包装必填除外)。
|
|
|
+- 不改装+送双边绑定的计数范围语义。
|