Files
shanxia2.0/docs/superpowers/plans/2026-09-10-plan-product-package-key.md
T

20 KiB
Raw Blame History

运输计划合同用量按「产品+包装」唯一键 — 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。

追加:

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, "")。例如:

# 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: 跑测确认失败
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。

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:

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:

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: 跑测通过
pytest tests/test_pl_contract_plan_qty.py -v

Expected: PASS

  • Step 5: Commit
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: 抽出合同行匹配辅助并单测

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:

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: 跑测

pytest tests/test_pl_contract_plan_qty.py tests/test_pl_contract_qty_arrange_check.py -v

Expected: PASS(evaluate_* 纯函数无需改签名)

  • Step 6: Commit
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: 写失败单测(纯函数,传入合同产品行 + 计划段产品)

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
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: 增加类型与函数

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
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: 扩展类型

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
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 增加合同行

contractProductRows?: ContractProductRowLite[]

父组件传入 Task 4 缓存的 rows。

  • Step 2: 绑合同时包装列改 el-select
<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
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
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 之后:

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
git commit -m "feat(web-admin): enforce package spec on plan form save"

Task 8: 验收与图谱

  • Step 1: 后端全量相关测
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: 前端类型检查(按需)
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
./scripts/update-graph.sh all
# 或分别 backend / frontend
  • Step 5: 最终 commit(若有 graph/文档微调)
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 ≈ 空键:仅兼容窗口;正式前端绑合同必传非空包装。