Просмотр исходного кода

docs: add design for plan contract qty keyed by product and package spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 месяц назад
Родитель
Сommit
ff42d7fe77
1 измененных файлов с 126 добавлено и 0 удалено
  1. 126 0
      docs/superpowers/specs/2026-09-10-plan-product-package-key-design.md

+ 126 - 0
docs/superpowers/specs/2026-09-10-plan-product-package-key-design.md

@@ -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 唯一约束。
+- 不改变「保存可超合同量、安排再硬拦」的数量策略(包装必填除外)。
+- 不改装+送双边绑定的计数范围语义。