docs: add implementation plan for plan product+package qty key.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ff42d7fe77
commit
3e6ae41e4b
1 file changed
+612
@@ -0,0 +1,612 @@
|
|||||||
|
# 运输计划合同用量按「产品+包装」唯一键 — 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:** 合同用量统计、可安排量、安排硬拦与合同详情三列改为按 `(productId, packageSpec)` 分桶;绑合同时计划行包装必填且选项仅来自合同。
|
||||||
|
|
||||||
|
**Architecture:** 在 `pl_contract_plan_qty.py` 引入归一化与复合键,所有累加/挂量/校验统一换键;API `contract-product-arrange-qty` 增加 `packageSpec`;前端缓存合同行摘要、包装下拉与 cacheKey/保存校验对齐。无 DB 迁移。
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI / SQLAlchemy / pytest;Vue 3 + Element Plus(web-admin)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-10-plan-product-package-key-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/services/pl_contract_plan_qty.py` | 复合键、聚合、挂量、可安排量、硬拦 |
|
||||||
|
| `backend/app/api/pl/plan.py` | arrange-qty Query 透传 `packageSpec`;create/update 调包装校验 |
|
||||||
|
| `backend/app/services/pl_plan_stop_service.py` 或新建小 helper | 绑合同段产品包装必填校验(可放 qty 服务旁) |
|
||||||
|
| `backend/tests/test_pl_contract_plan_qty.py` | 聚合/挂量复合键单测 |
|
||||||
|
| `backend/tests/test_pl_contract_qty_arrange_check.py` | 必要时扩展;主要改 plan_qty 侧集成式纯函数测 |
|
||||||
|
| `web-admin/src/utils/pl-plan-contract-product.ts` | 合同行收集、包装列表、归一化、缺货比对 |
|
||||||
|
| `web-admin/src/api/pl/index.ts` | arrange-qty / reminder 类型 |
|
||||||
|
| `web-admin/src/components/business/pl-plan-stop-product-table/index.vue` | 包装下拉、cacheKey、请求 |
|
||||||
|
| `web-admin/src/components/business/pl-plan-form-dialog/index.vue` | 合同行缓存、选品自动填包装、保存校验 |
|
||||||
|
| `web-admin/src/utils/pl-plan-stop-form.ts` | `matchPrevStopProduct` 带包装 |
|
||||||
|
| `web-admin/src/utils/pl-contract-qty-reminder.ts` | 展示包装 |
|
||||||
|
| `web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/*` | 若有:传 packageSpec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 后端复合键工具 + 更新累加/合并 + 单测
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/services/pl_contract_plan_qty.py`
|
||||||
|
- Modify: `backend/tests/test_pl_contract_plan_qty.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在测试中增加复合键用例(先写失败断言)**
|
||||||
|
|
||||||
|
在 `test_pl_contract_plan_qty.py` 顶部 import 增加 `normalize_package_spec`, `product_package_key`, `attach_plan_qty_fields_to_products`。
|
||||||
|
|
||||||
|
追加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_normalize_package_spec():
|
||||||
|
from app.services.pl_contract_plan_qty import normalize_package_spec
|
||||||
|
assert normalize_package_spec(None) == ""
|
||||||
|
assert normalize_package_spec(" ") == ""
|
||||||
|
assert normalize_package_spec(" 槽罐车 ") == "槽罐车"
|
||||||
|
assert normalize_package_spec("25kg/桶") != normalize_package_spec("25Kg/袋")
|
||||||
|
|
||||||
|
|
||||||
|
def test_accumulate_splits_same_product_different_package():
|
||||||
|
acc = defaultdict(float)
|
||||||
|
accumulate_stop_products(
|
||||||
|
acc,
|
||||||
|
[
|
||||||
|
SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", plan_qty=15),
|
||||||
|
SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", plan_qty=700),
|
||||||
|
SimpleNamespace(product_id="SXWL007344", package_spec=" 槽罐车 ", plan_qty=10),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
k_bucket = ("SXWL007344", "25kg/桶")
|
||||||
|
k_tank = ("SXWL007344", "槽罐车")
|
||||||
|
assert acc[k_bucket] == 15.0
|
||||||
|
assert acc[k_tank] == 710.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_product_qty_maps_composite_keys():
|
||||||
|
out = merge_product_qty_maps(
|
||||||
|
archived={("p1", "A"): 10.0},
|
||||||
|
arranged_unarchived={("p1", "B"): 20.0, ("p1", "A"): 5.0},
|
||||||
|
)
|
||||||
|
assert out[("p1", "A")]["arrangedTotalQty"] == 15.0
|
||||||
|
assert out[("p1", "B")]["arrangedTotalQty"] == 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_plan_qty_fields_matches_package():
|
||||||
|
qty_map = {
|
||||||
|
("pid1", "25kg/桶"): {
|
||||||
|
"archivedQty": 1.0,
|
||||||
|
"arrangedUnarchivedQty": 2.0,
|
||||||
|
"arrangedTotalQty": 3.0,
|
||||||
|
},
|
||||||
|
("pid1", "槽罐车"): {
|
||||||
|
"archivedQty": 10.0,
|
||||||
|
"arrangedUnarchivedQty": 20.0,
|
||||||
|
"arrangedTotalQty": 30.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
products = [
|
||||||
|
{"productId": "pid1", "packageSpec": "25kg/桶"},
|
||||||
|
{"productId": "pid1", "packageSpec": "槽罐车"},
|
||||||
|
]
|
||||||
|
attach_plan_qty_fields_to_products(products, qty_map)
|
||||||
|
assert products[0]["arrangedTotalQty"] == 3.0
|
||||||
|
assert products[1]["arrangedTotalQty"] == 30.0
|
||||||
|
```
|
||||||
|
|
||||||
|
同时把**既有**依赖裸 `product_id` 键的测试改为复合键:无 `package_spec` 时键为 `(pid, "")`。例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_accumulate_stop_products 期望
|
||||||
|
assert acc[("p1", "")] == 20.0
|
||||||
|
|
||||||
|
# test_sum_qtys_explicit_delivery_stop_only 等
|
||||||
|
assert qtys == {("p1", ""): 40.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 `SimpleNamespace(product_id=...)` 行可不显式加 `package_spec`(实现用 getattr 默认空)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && source venv/bin/activate && pytest tests/test_pl_contract_plan_qty.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL(`normalize_package_spec` 未定义或键仍为 str)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现工具与累加/合并/挂量**
|
||||||
|
|
||||||
|
在 `pl_contract_plan_qty.py` 顶部 typing 增加 `Tuple`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
ProductPackageKey = Tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_package_spec(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
|
||||||
|
pid = str(product_id or "").strip()
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
return (pid, normalize_package_spec(package_spec))
|
||||||
|
```
|
||||||
|
|
||||||
|
改 `merge_product_qty_maps`:参数/返回键改为 `ProductPackageKey`(dict key 为 tuple)。
|
||||||
|
|
||||||
|
改 `accumulate_stop_products`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
key = product_package_key(
|
||||||
|
getattr(row, "product_id", None),
|
||||||
|
getattr(row, "package_spec", None),
|
||||||
|
)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
...
|
||||||
|
target[key] += qty
|
||||||
|
```
|
||||||
|
|
||||||
|
改 `attach_plan_qty_fields_to_products`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
key = product_package_key(item.get("productId"), item.get("packageSpec") or item.get("package_spec"))
|
||||||
|
stats = (qty_by_product_id.get(key) if key else None) or {
|
||||||
|
"archivedQty": 0.0,
|
||||||
|
"arrangedUnarchivedQty": 0.0,
|
||||||
|
"arrangedTotalQty": 0.0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(参数名可保留 `qty_by_product_id` 或改名为 `qty_by_key`,调用处一并改。)
|
||||||
|
|
||||||
|
`aggregate_contract_product_plan_qtys` 返回类型自然变为复合键 dict(`archived`/`arranged_unarchived` 已是 defaultdict)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测通过**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_pl_contract_plan_qty.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/services/pl_contract_plan_qty.py backend/tests/test_pl_contract_plan_qty.py
|
||||||
|
git commit -m "feat(pl): key contract plan qty by product and package spec"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 可安排量 API + 硬拦按复合键
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/services/pl_contract_plan_qty.py`(`get_contract_product_arrange_qty_detail`、`collect_plan_contract_qty_checks`)
|
||||||
|
- Modify: `backend/app/api/pl/plan.py`(Query `packageSpec`)
|
||||||
|
- Modify: `backend/tests/test_pl_contract_plan_qty.py`(纯函数级:可测合同行匹配 helper,或在同文件加小测)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 抽出合同行匹配辅助并单测**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def match_contract_product_for_key(
|
||||||
|
products: Iterable[Any],
|
||||||
|
key: ProductPackageKey,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""同键多行时 quantity 相加的合成对象可选;业务约定唯一,取第一行并防御求和。"""
|
||||||
|
pid, pkg = key
|
||||||
|
matched = []
|
||||||
|
for p in products or []:
|
||||||
|
k = product_package_key(getattr(p, "product_id", None), getattr(p, "package_spec", None))
|
||||||
|
if k == key:
|
||||||
|
matched.append(p)
|
||||||
|
if not matched:
|
||||||
|
return None
|
||||||
|
if len(matched) == 1:
|
||||||
|
return matched[0]
|
||||||
|
# 防御:合并 quantity 到第一个的 SimpleNamespace 副本或改写 quantity
|
||||||
|
total_qty = sum(_to_float(getattr(p, "quantity", None)) for p in matched)
|
||||||
|
first = matched[0]
|
||||||
|
first.quantity = total_qty # 仅当 ORM 对象可写;否则用 SimpleNamespace 拷贝字段
|
||||||
|
return first
|
||||||
|
```
|
||||||
|
|
||||||
|
单测:两行同 pid 不同包装,按包装取到对应 quantity(15 vs 700)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改 `get_contract_product_arrange_qty_detail`**
|
||||||
|
|
||||||
|
签名增加 `package_spec: Optional[str] = None`。
|
||||||
|
|
||||||
|
- empty 响应增加 `"packageSpec": normalize_package_spec(package_spec)`
|
||||||
|
- 查合同行:先 `filter(product_id==pid, del_flag==0)` 拉列表,再用 `match_contract_product_for_key(..., product_package_key(pid, package_spec))`
|
||||||
|
- `qty_map.get(key)` 取 arranged
|
||||||
|
- 列计划明细时过滤/展示同一 key(若现有 plans 列表按 product 过滤,改为复合键)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改 API**
|
||||||
|
|
||||||
|
`plan.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
package_spec: Optional[str] = Query(default=None, alias="packageSpec"),
|
||||||
|
...
|
||||||
|
return get_contract_product_arrange_qty_detail(
|
||||||
|
...,
|
||||||
|
product_id=product_id,
|
||||||
|
package_spec=package_spec,
|
||||||
|
exclude_plan_id=exclude_plan_id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 改 `collect_plan_contract_qty_checks`**
|
||||||
|
|
||||||
|
- `products_by_key = { product_package_key(p.product_id, p.package_spec): p for p in products if product_package_key(...) }`
|
||||||
|
(同键多行先用 match helper 建 map)
|
||||||
|
- `for key, new_qty_raw in plan_qtys.items():` 解包 `pid, pkg = key`
|
||||||
|
- item 增加 `"packageSpec": pkg or None`
|
||||||
|
- `qty_map.get(key)`
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑测**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_pl_contract_plan_qty.py tests/test_pl_contract_qty_arrange_check.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS(evaluate_* 纯函数无需改签名)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/services/pl_contract_plan_qty.py backend/app/api/pl/plan.py backend/tests/test_pl_contract_plan_qty.py
|
||||||
|
git commit -m "feat(pl): arrange-qty and overflow checks use product+package key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 计划保存时包装必填(绑合同)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create or Modify: `backend/app/services/pl_contract_plan_qty.py` 增加 `assert_plan_products_package_spec_for_contracts`
|
||||||
|
或 `backend/app/services/pl_plan_package_validate.py`
|
||||||
|
- Modify: `backend/app/api/pl/plan.py`(`create-full` / `update` 在写库前调用)
|
||||||
|
- Create: `backend/tests/test_pl_plan_package_validate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败单测(纯函数,传入合同产品行 + 计划段产品)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_require_package_when_contract_bound():
|
||||||
|
from app.services.pl_contract_plan_qty import validate_stop_products_package_against_contract
|
||||||
|
|
||||||
|
contract_products = [
|
||||||
|
SimpleNamespace(product_id="p1", package_spec="25kg/桶"),
|
||||||
|
SimpleNamespace(product_id="p1", package_spec="槽罐车"),
|
||||||
|
]
|
||||||
|
ok, err = validate_stop_products_package_against_contract(
|
||||||
|
contract_products=contract_products,
|
||||||
|
stop_products=[SimpleNamespace(product_id="p1", package_spec="")],
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
assert "包装" in (err or "")
|
||||||
|
|
||||||
|
def test_package_must_be_in_contract_set():
|
||||||
|
...
|
||||||
|
stop_products=[SimpleNamespace(product_id="p1", package_spec="吨桶")]
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
def test_valid_package_passes():
|
||||||
|
...
|
||||||
|
stop_products=[SimpleNamespace(product_id="p1", package_spec="槽罐车")]
|
||||||
|
assert ok is True
|
||||||
|
```
|
||||||
|
|
||||||
|
规则(与 spec 一致):
|
||||||
|
- 仅校验「出现在合同产品集合中的 productId」
|
||||||
|
- 该 productId 在合同上的包装集合(归一后)非空 → 计划行必须非空且 ∈ 集合
|
||||||
|
- 计划产品不在合同明细:跳过(归属另有规则)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 实现 `validate_stop_products_package_against_contract` + 在 create-full/update 对每个绑定合同的装/送货段产品调用;失败 `HTTPException 422`**
|
||||||
|
|
||||||
|
定位:`create_full` 在 `validate_create_full` 之后、持久化之前;`update` 对称位置。若段产品嵌在 payload.loads/deliveries,遍历 `products`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 跑测并 Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_pl_plan_package_validate.py -v
|
||||||
|
git add ... && git commit -m "feat(pl): require contract package spec on plan save"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 前端工具函数
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/utils/pl-plan-contract-product.ts`
|
||||||
|
- 可选 Create: `web-admin/src/utils/pl-plan-contract-product.spec.ts`(若项目已有 vitest;无则跳过单测,手工验)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 增加类型与函数**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ContractProductRowLite = {
|
||||||
|
productId?: string | null
|
||||||
|
packageSpec?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
productNo?: string | null
|
||||||
|
quantity?: number | null
|
||||||
|
quantityMode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackageSpec(value?: string | null): string {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productPackageKey(
|
||||||
|
productId?: string | null,
|
||||||
|
packageSpec?: string | null
|
||||||
|
): string {
|
||||||
|
const pid = String(productId || '').trim()
|
||||||
|
if (!pid) return ''
|
||||||
|
return `${pid}\0${normalizePackageSpec(packageSpec)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectContractProductRows(
|
||||||
|
products?: ContractProductRowLite[] | null
|
||||||
|
): ContractProductRowLite[] {
|
||||||
|
return (products || [])
|
||||||
|
.map((row) => ({
|
||||||
|
productId: String(row?.productId || '').trim(),
|
||||||
|
packageSpec: normalizePackageSpec(row?.packageSpec),
|
||||||
|
productName: row?.productName,
|
||||||
|
productNo: row?.productNo,
|
||||||
|
quantity: row?.quantity,
|
||||||
|
quantityMode: row?.quantityMode
|
||||||
|
}))
|
||||||
|
.filter((row) => row.productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本合同该产品的包装列表(去重,保序);过滤空串 */
|
||||||
|
export function packageSpecsForProduct(
|
||||||
|
rows: ContractProductRowLite[],
|
||||||
|
productId: string
|
||||||
|
): string[] {
|
||||||
|
const pid = String(productId || '').trim()
|
||||||
|
const out: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const row of rows || []) {
|
||||||
|
if (String(row.productId || '').trim() !== pid) continue
|
||||||
|
const pkg = normalizePackageSpec(row.packageSpec)
|
||||||
|
if (!pkg || seen.has(pkg)) continue
|
||||||
|
seen.add(pkg)
|
||||||
|
out.push(pkg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPackageAllowedForContractProduct(
|
||||||
|
rows: ContractProductRowLite[],
|
||||||
|
productId: string,
|
||||||
|
packageSpec?: string | null
|
||||||
|
): boolean {
|
||||||
|
const allowed = packageSpecsForProduct(rows, productId)
|
||||||
|
if (!allowed.length) return true // 无合同包装行:不在此强制(后端/脏数据)
|
||||||
|
return allowed.includes(normalizePackageSpec(packageSpec))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保留 `collectContractProductIds`(可由 rows 派生)。
|
||||||
|
|
||||||
|
改 `findDeliveryProductsMissingFromLoad`:用 `productPackageKey` 比对;展示名建议 `` `${name}(${pkg || '无包装'})` ``。
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-admin/src/utils/pl-plan-contract-product.ts
|
||||||
|
git commit -m "feat(web-admin): add contract product package helper utils"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 前端 API 类型与 arrange-qty 传参
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/api/pl/index.ts`
|
||||||
|
- Modify: `web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue`(若存在)
|
||||||
|
- Modify: `web-admin/src/utils/pl-contract-qty-reminder.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 扩展类型**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ContractProductArrangeQtyDetail {
|
||||||
|
...
|
||||||
|
packageSpec?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
getContractProductArrangeQty: (params: {
|
||||||
|
contractId: string
|
||||||
|
productId: string
|
||||||
|
packageSpec?: string
|
||||||
|
excludePlanId?: string
|
||||||
|
}) => ...
|
||||||
|
|
||||||
|
export interface ContractQtyReminderItem {
|
||||||
|
...
|
||||||
|
packageSpec?: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: reminder 文案带包装**
|
||||||
|
|
||||||
|
在 `pl-contract-qty-reminder.ts` 产品名后追加包装(有值时):`` `${productName}(${packageSpec})` ``。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(web-admin): pass packageSpec on arrange-qty API types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 段产品表 — 包装下拉 + 可安排量 cacheKey
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/components/business/pl-plan-stop-product-table/index.vue`
|
||||||
|
- 检查 `PlPlanFormProductTable.vue` 是否仍用于主路径;主路径以 stop-product-table 为准,旧表若仍展示包装则同步。
|
||||||
|
|
||||||
|
- [ ] **Step 1: Props 增加合同行**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
contractProductRows?: ContractProductRowLite[]
|
||||||
|
```
|
||||||
|
|
||||||
|
父组件传入 Task 4 缓存的 rows。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 绑合同时包装列改 el-select**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-select
|
||||||
|
v-if="contractId && packageOptionsFor(row).length"
|
||||||
|
v-model="row.packageSpec"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="请选择包装规格"
|
||||||
|
@change="onPackageChange(row)"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in packageOptionsFor(row)" :key="opt" :label="opt" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
<ArtDictSelect v-else ... dict-type="crm_product_gg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`packageOptionsFor(row) => packageSpecsForProduct(props.contractProductRows || [], row.productId)`
|
||||||
|
|
||||||
|
- [ ] **Step 3: cacheKey / ensureArrangeQty**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function cacheKey(productId: string, packageSpec?: string | null) {
|
||||||
|
return `${props.contractId}::${productId}::${normalizePackageSpec(packageSpec)}::${excludePlanId || ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureArrangeQty:若有合同且 packageSpecsForProduct 非空且行上包装为空 → 不请求,available 显示提示
|
||||||
|
await planApi.getContractProductArrangeQty({
|
||||||
|
contractId,
|
||||||
|
productId: pid,
|
||||||
|
packageSpec: normalizePackageSpec(row.packageSpec),
|
||||||
|
excludePlanId
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
包装变更时清 cache 并重新拉取。弹窗 props 增加 `packageSpec`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(web-admin): plan stop products select package from contract"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 计划表单 — 缓存、选品、保存校验、匹配
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue`
|
||||||
|
- Modify: `web-admin/src/utils/pl-plan-stop-form.ts`
|
||||||
|
- Modify: `web-admin/src/components/business/pl-plan-delivery-section/index.vue`(缺货提醒若需)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 合同缓存**
|
||||||
|
|
||||||
|
将 `contractProductIdsCache` 扩展为同时存 `ContractProductRowLite[]`(或并行 `contractProductRowsCache`)。`fetchContractProductIds` 改为拉详情后 `collectContractProductRows` + ids。
|
||||||
|
|
||||||
|
向 `PlPlanLoadSection` / `PlPlanDeliverySection` / `PlPlanStopProductTable` 传入 `contract-product-rows`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 选品确认**
|
||||||
|
|
||||||
|
`handleProductConfirm`:不要再无脑 `packageSpec: ''` 后结束;若 `packageSpecsForProduct(rows, id).length === 1`,自动填入该包装;多个则 `''` 待选。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 保存校验**
|
||||||
|
|
||||||
|
在 `validateSingleContractPerStop` 之后:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function validateContractPackageSpecs(): boolean {
|
||||||
|
// 遍历装/送货段:有 contractId 且该产品 packageSpecs 非空 → 必填且 in 列表
|
||||||
|
// ElMessage.error(`请选择包装规格:${productName}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`submit` 路径调用;失败 return。
|
||||||
|
|
||||||
|
- [ ] **Step 4: `matchPrevStopProduct`**
|
||||||
|
|
||||||
|
在 `pl-plan-stop-form.ts`:`byProductId` 改为先匹配 `productId + normalizePackageSpec(packageSpec)`,再回退仅 productId(兼容旧空包装行)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(web-admin): enforce package spec on plan form save"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: 验收与图谱
|
||||||
|
|
||||||
|
- [ ] **Step 1: 后端全量相关测**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
pytest tests/test_pl_contract_plan_qty.py tests/test_pl_contract_qty_arrange_check.py tests/test_pl_plan_package_validate.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 前端类型检查(按需)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 手工验收清单(对照生产 309 逻辑,可用本地/测试合同造数)**
|
||||||
|
|
||||||
|
1. 同 productId 两包装:下拉仅两选项;不选不能保存
|
||||||
|
2. 选槽罐车:可安排量按大额度
|
||||||
|
3. 选 25kg/桶:按小额度
|
||||||
|
4. 合同详情两行三列不串量
|
||||||
|
5. 安排运输超限文案含包装
|
||||||
|
|
||||||
|
- [ ] **Step 4: 更新 graphify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-graph.sh all
|
||||||
|
# 或分别 backend / frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 最终 commit(若有 graph/文档微调)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: update graphify after plan product package key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec coverage checklist
|
||||||
|
|
||||||
|
| Spec 项 | Task |
|
||||||
|
|---------|------|
|
||||||
|
| 复合键 + 归一化 | Task 1 |
|
||||||
|
| 聚合 / 合同详情三列挂量 | Task 1(attach) |
|
||||||
|
| 可安排量 API | Task 2 |
|
||||||
|
| 安排硬拦 + reminder packageSpec | Task 2 + Task 5 |
|
||||||
|
| 保存包装必填 | Task 3 + Task 7 |
|
||||||
|
| 选品仍按产品 + 合同包装下拉 | Task 6–7 |
|
||||||
|
| 缺货/段匹配带包装 | Task 4 + Task 7 |
|
||||||
|
| 无 schema / 不刷历史 | 全程遵守 |
|
||||||
|
| 309 验收 | Task 8 |
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- 键类型:后端用 `Tuple[str,str]`;前端 cache 用 `` `${pid}\0${pkg}` `` 字符串,勿混用未归一的 raw。
|
||||||
|
- 既有单测凡断言 `{"p1": n}` 必须改为 `{("p1",""): n}`,否则 Task 1 绿测假象。
|
||||||
|
- `products_by_pid` last-wins 必须删除,改为 `products_by_key` / match helper。
|
||||||
|
- 旧前端不传 `packageSpec` ≈ 空键:仅兼容窗口;正式前端绑合同必传非空包装。
|
||||||
Reference in new issue
Block a user