Files
shanxia2.0/docs/superpowers/plans/2026-08-30-pl-plan-single-contract-products.md
T

25 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: 非内置客户每段最多 1 份合同;装货「添加产品」只能选自该合同明细;内置客户不限产品;送货可删本段产品;前后端强校验。

Architecture: 保留 contracts[],业务上限为 1。后端在 validate_plan_stops_write / update_plan_stop_contracts(及仍使用的 side contracts)校验份数与装货产品归属。前端 PlPlanContractMultiSelect 改为单选;ProductSelector 支持 allowedProductIds;装货未选合同禁用添加;换合同按 productId 过滤装货行;送货表 allow-remove=true。

Tech Stack: FastAPI、SQLAlchemy、pytest、Vue 3、Element Plus、TypeScript

Spec: docs/superpowers/specs/2026-08-30-pl-plan-single-contract-products-design.md

注意: 工作区可能有与本需求无关的未提交改动(如地磅勾选)。实现时只改本计划列出的文件,提交时勿夹带无关 diff。


文件结构

操作 文件 职责
Create backend/tests/test_pl_plan_single_contract_rules.py 合同上限 / 装货产品归属纯函数单测
Modify backend/app/services/pl_plan_stop_service.py _resolve_stop_contracts 上限 1;装货产品归属校验
Modify backend/app/services/pl_plan_contract_gap.py update_plan_stop_contracts 上限 1
Modify backend/app/services/pl_plan_service.py validate_plan_party_contracts 上限 1(旧路径)
Create web-admin/src/utils/pl-plan-contract-product.ts 合同 productId 集合、按合同过滤装货产品
Modify web-admin/src/components/business/pl-plan-contract-multi-select/index.vue 单选合同 + 历史多合同提示
Modify web-admin/src/components/business/product-selector/index.vue allowedProductIds 限制可选集
Modify web-admin/src/components/business/pl-plan-load-section/index.vue 未选合同禁用添加产品
Modify web-admin/src/components/business/pl-plan-delivery-section/index.vue 送货产品允许删除
Modify web-admin/src/components/business/pl-plan-form-dialog/index.vue 传 allowedProductIds;换合同过滤;保存校验 >1
Modify web-admin/src/views/pl/plan-contract/index.vue 保存前校验最多 1 份

Task 1: 后端纯函数 — 合同最多 1 份

Files:

  • Create: backend/tests/test_pl_plan_single_contract_rules.py

  • Modify: backend/app/services/pl_plan_stop_service.py

  • Step 1: 写失败单测

在 backend/tests/test_pl_plan_single_contract_rules.py:

import pytest
from fastapi import HTTPException

from app.schemas.pl.plan import PlanContractLink
from app.services.pl_plan_stop_service import assert_at_most_one_contract


def test_assert_at_most_one_contract_allows_zero_or_one():
    assert assert_at_most_one_contract([], "装货合同") == []
    one = [PlanContractLink(contractId="c1", contractNo="N1")]
    assert assert_at_most_one_contract(one, "装货合同") == one


def test_assert_at_most_one_contract_rejects_two():
    links = [
        PlanContractLink(contractId="c1", contractNo="N1"),
        PlanContractLink(contractId="c2", contractNo="N2"),
    ]
    with pytest.raises(HTTPException) as exc:
        assert_at_most_one_contract(links, "装货合同")
    assert exc.value.status_code == 422
    assert "只能选择 1 份" in str(exc.value.detail)
  • Step 2: 运行确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py::test_assert_at_most_one_contract_rejects_two -v

Expected: FAIL(assert_at_most_one_contract 未定义)

  • Step 3: 实现函数并挂到 _resolve_stop_contracts

在 pl_plan_stop_service.py(_normalize_contract_links 附近)新增:

def assert_at_most_one_contract(
    links: list[PlanContractLink],
    side_label: str,
) -> list[PlanContractLink]:
    if len(links) > 1:
        raise HTTPException(
            status_code=422,
            detail=f"{side_label}只能选择 1 份",
        )
    return links

在 _resolve_stop_contracts 中,links = _normalize_contract_links(contracts) 之后立刻:

    links = assert_at_most_one_contract(links, side_label)
  • Step 4: 跑通单测
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/pl_plan_stop_service.py backend/tests/test_pl_plan_single_contract_rules.py
git commit -m "$(cat <<'EOF'
feat(pl): enforce at most one contract per plan stop

EOF
)"

Task 2: 后端 — 装货产品必须属于所选合同

Files:

  • Modify: backend/tests/test_pl_plan_single_contract_rules.py

  • Modify: backend/app/services/pl_plan_stop_service.py

  • Step 1: 写失败单测(产品归属)

追加到同测试文件:

from types import SimpleNamespace
from app.schemas.pl.plan import PlanLoadBody, PlanStopProductBody
from app.services.pl_plan_stop_service import assert_load_products_in_selected_contract


class _FakeQuery:
    def __init__(self, rows):
        self._rows = rows

    def filter(self, *args, **kwargs):
        return self

    def all(self):
        return self._rows


class _FakeDb:
    def __init__(self, product_ids):
        self._rows = [SimpleNamespace(product_id=pid) for pid in product_ids]

    def query(self, model):
        return _FakeQuery(self._rows)


def _load_with_products(product_ids, contract_id="c1"):
    return PlanLoadBody(
        customerId="cust-1",
        customerName="外部客户A",
        contracts=[PlanContractLink(contractId=contract_id, contractNo="N1")],
        products=[
            PlanStopProductBody(
                productId=pid,
                productNo=pid,
                productName=f"产品{pid}",
                unit="吨",
                quantity=1,
            )
            for pid in product_ids
        ],
    )


def test_assert_load_products_in_contract_passes_when_all_match():
    db = _FakeDb(["p1", "p2"])
    load = _load_with_products(["p1"])
    assert_load_products_in_selected_contract(db, "shanxia2.0", load)


def test_assert_load_products_in_contract_rejects_outside():
    db = _FakeDb(["p1"])
    load = _load_with_products(["p2"])
    with pytest.raises(HTTPException) as exc:
        assert_load_products_in_selected_contract(db, "shanxia2.0", load)
    assert exc.value.status_code == 422
    assert "不在所选合同明细中" in str(exc.value.detail)


def test_assert_load_products_skips_when_no_contract():
    db = _FakeDb([])
    load = PlanLoadBody(
        customerId="cust-1",
        customerName="外部客户A",
        contracts=[],
        products=[
            PlanStopProductBody(
                productId="p1",
                productNo="p1",
                productName="产品p1",
                unit="吨",
                quantity=1,
            )
        ],
    )
    assert_load_products_in_selected_contract(db, "shanxia2.0", load)


def test_assert_load_products_skips_internal_customer():
    from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES

    internal = next(iter(INTERNAL_CUSTOMER_NAMES))
    db = _FakeDb([])
    load = PlanLoadBody(
        customerId="cust-1",
        customerName=internal,
        contracts=[],
        products=[
            PlanStopProductBody(
                productId="p1",
                productNo="p1",
                productName="产品p1",
                unit="吨",
                quantity=1,
            )
        ],
    )
    assert_load_products_in_selected_contract(db, "shanxia2.0", load)

若 PlanLoadBody / PlanStopProductBody 构造字段与现网不一致,按 backend/app/schemas/pl/plan.py 实际必填字段微调测试(保持语义不变)。

  • Step 2: 运行确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py::test_assert_load_products_in_contract_rejects_outside -v

Expected: FAIL(函数未定义)

  • Step 3: 实现 assert_load_products_in_selected_contract
from app.models.crm_models import CrmContractProduct


def assert_load_products_in_selected_contract(
    db: Session,
    tenant_no: str,
    load: PlanLoadBody,
) -> None:
    if is_internal_customer_name(load.customer_name):
        return
    links = _normalize_contract_links(load.contracts)
    if not links:
        return
    contract_id = (links[0].contract_id or "").strip()
    if not contract_id:
        return
    rows = (
        db.query(CrmContractProduct)
        .filter(
            CrmContractProduct.contract_id == contract_id,
            CrmContractProduct.tenant_no == tenant_no,
            CrmContractProduct.del_flag == 0,
        )
        .all()
    )
    allowed = {
        str(r.product_id).strip()
        for r in rows
        if getattr(r, "product_id", None) and str(r.product_id).strip()
    }
    for prod in load.products or []:
        pid = str(getattr(prod, "product_id", None) or "").strip()
        name = (getattr(prod, "product_name", None) or pid or "未命名").strip()
        if not pid or pid not in allowed:
            raise HTTPException(
                status_code=422,
                detail=f"装货产品「{name}」不在所选合同明细中",
            )

在 validate_plan_stops_write 中,装货合同 resolve 完成、deliveries 循环结束后、return 前调用:

    assert_load_products_in_selected_contract(db, tenant_no, load)

注意:调用时 load.contracts 应为已 resolve 的列表;若 resolve 清空了内置客户合同,上述函数会因无合同而跳过。

  • Step 4: 跑通单测
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/pl_plan_stop_service.py backend/tests/test_pl_plan_single_contract_rules.py
git commit -m "$(cat <<'EOF'
feat(pl): require load products to belong to selected contract

EOF
)"

Task 3: 后端 — 合同完善与旧 side contracts 上限

Files:

  • Modify: backend/app/services/pl_plan_contract_gap.py

  • Modify: backend/app/services/pl_plan_service.py

  • Modify: backend/tests/test_pl_plan_single_contract_rules.py(可选:补文档化用例注释即可;合同完善路径复用同一 assert_at_most_one_contract)

  • Step 1: 在 update_plan_stop_contracts 规范化后校验

pl_plan_contract_gap.py 中 links = _normalize_contract_links(contracts) 之后(约 566 行后):

    from app.services.pl_plan_stop_service import assert_at_most_one_contract

    links = assert_at_most_one_contract(links, side_label)

若该文件已有本地 _normalize_contract_links,仍复用 stop_service 的 assert_at_most_one_contract,避免两套上限逻辑。

  • Step 2: 在 validate_plan_party_contracts 校验供/需两侧

pl_plan_service.py 中规范化后:

    from app.services.pl_plan_stop_service import assert_at_most_one_contract

    supplier_links = assert_at_most_one_contract(supplier_links, "供方合同")
    demander_links = assert_at_most_one_contract(demander_links, "需方合同")
  • Step 3: 回归相关单测
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py tests/test_pl_plan_contract_gap.py tests/test_pl_plan_stop_service.py -v

Expected: PASS(test_pl_plan_contract_gap 中「demander_contract_count=2」仅测缺口判定计数,不走写入校验,应仍通过)

  • Step 4: Commit
git add backend/app/services/pl_plan_contract_gap.py backend/app/services/pl_plan_service.py
git commit -m "$(cat <<'EOF'
feat(pl): enforce single contract on stop-contracts and legacy side paths

EOF
)"

Task 4: 前端工具函数 — 合同产品过滤

Files:

  • Create: web-admin/src/utils/pl-plan-contract-product.ts

  • Step 1: 新增工具

/** 从合同明细行收集 CRM productId */
export function collectContractProductIds(
  products?: Array<{ productId?: string | null }> | null
): string[] {
  const ids: string[] = []
  const seen = new Set<string>()
  for (const row of products || []) {
    const id = String(row?.productId || '').trim()
    if (!id || seen.has(id)) continue
    seen.add(id)
    ids.push(id)
  }
  return ids
}

/** 保留 productId 落在 allowed 内的装货行;allowed 为空则全部移除(换合同且新合同无产品) */
export function filterProductsByAllowedProductIds<T extends { productId?: string | null }>(
  products: T[],
  allowedProductIds: string[]
): T[] {
  const allowed = new Set(
    (allowedProductIds || []).map((id) => String(id).trim()).filter(Boolean)
  )
  if (!allowed.size) return []
  return (products || []).filter((row) => allowed.has(String(row.productId || '').trim()))
}
  • Step 2: Commit
git add web-admin/src/utils/pl-plan-contract-product.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add helpers to filter plan products by contract

EOF
)"

Task 5: PlPlanContractMultiSelect 改为单选

Files:

  • Modify: web-admin/src/components/business/pl-plan-contract-multi-select/index.vue

  • Step 1: ContractSelector 改单选

将:

    <ContractSelector
      v-if="!readonly"
      v-model:visible="selectorVisible"
      :customer-id="customerId"
      :selectable-statuses="selectableStatuses"
      multiple
      :selected-ids="selectedIds"
      hide-amount
      @confirm-multiple="handleConfirmMultiple"
    />

改为:

    <ContractSelector
      v-if="!readonly"
      v-model:visible="selectorVisible"
      :customer-id="customerId"
      :selectable-statuses="selectableStatuses"
      :selected-id="selectedIds[0] || ''"
      hide-amount
      @confirm="handleConfirmSingle"
    />

(若 ContractSelector 的单选 prop 名为 selectedId / 事件为 confirm,以组件 defineProps / defineEmits 为准对齐。)

  • Step 2: 替换确认处理与提示文案
async function handleConfirmSingle(contract: ContractVO) {
  const enriched = await enrichContractProducts(contract)
  detailRows.value = [enriched]
  emitLinksFromDetails(detailRows.value)
}

const hintText = computed(() => {
  if (!props.customerId) return '请先选择客户'
  if (selectedIds.value.length > 1) {
    return '只能保留一份合同,请移除多余合同后再保存'
  }
  if (hintWarn.value) return '请选择合同'
  if (selectedIds.value.length) return '已选 1 份'
  return ''
})

const hintWarn = computed(() => {
  if (!props.customerId) return false
  if (selectedIds.value.length > 1) return true
  return !!props.required && !selectedIds.value.length
})

删除或停用 handleConfirmMultiple(若无其它引用)。

  • Step 3: defineExpose 增加合同产品 id 读取(供表单使用)
defineExpose({
  openSelector,
  getContractProductIds: () =>
    collectContractProductIds(detailRows.value.flatMap((row) => row.products || []))
})

从 @/utils/pl-plan-contract-product 引入 collectContractProductIds。

  • Step 4: Commit
git add web-admin/src/components/business/pl-plan-contract-multi-select/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): make plan stop contract selector single-choice

EOF
)"

Task 6: ProductSelector 支持 allowedProductIds

Files:

  • Modify: web-admin/src/components/business/product-selector/index.vue

  • Step 1: 增加 prop

  const props = defineProps<{
    visible: boolean
    supplierId?: string | number
    /** 非空时仅允许选择这些 CRM 产品 id(运输计划按合同限制) */
    allowedProductIds?: string[]
  }>()
  • Step 2: 列表加载后过滤;限制模式下隐藏「新增」

在 getList 赋值 productList 之后:

      let list = data.list || data.rows || []
      const allowed = (props.allowedProductIds || [])
        .map((id) => String(id).trim())
        .filter(Boolean)
      if (allowed.length) {
        const allowSet = new Set(allowed)
        list = list.filter((p: ProductVO) => allowSet.has(String(p.id)))
      }
      productList.value = list

模板中「新增」按钮:

          <el-button
            v-if="!allowedProductIds?.length"
            class="btn-main"
            type="primary"
            @click="handleAddProduct"
          >

(allowedProductIds 需在 script 中通过 props.allowedProductIds 暴露到模板,或 const allowedProductIds = computed(() => props.allowedProductIds || [])。)

当 allowed.length > 0 且过滤后为空:ElMessage.warning('所选合同暂无产品明细')(仅在打开时提示一次即可,避免刷屏)。

说明:合同产品通常远少于一页商品库结果;若联调发现合同产品因分页未出现在当前页,改为:有 allowedProductIds 时用 fetchProductDetail 逐个拉取组装列表(实现时优先保证正确性)。

  • Step 3: Commit
git add web-admin/src/components/business/product-selector/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): allow ProductSelector to restrict by product ids

EOF
)"

Task 7: 装货禁用添加 + 表单联动过滤与校验

Files:

  • Modify: web-admin/src/components/business/pl-plan-load-section/index.vue

  • Modify: web-admin/src/components/business/pl-plan-form-dialog/index.vue

  • Step 1: 装货「添加产品」禁用条件

pl-plan-load-section/index.vue:

      <el-button
        v-if="!readonly"
        class="btn-main"
        type="primary"
        size="small"
        :disabled="addProductsDisabled"
        @click="handleAddProductsClick"
      >
        <el-icon><Plus /></el-icon>添加产品
      </el-button>
  const addProductsDisabled = computed(() => {
    if (isInternal.value) return false
    return !(props.modelValue.contracts || []).some((c) => String(c.contractId || '').trim())
  })

  function handleAddProductsClick() {
    if (addProductsDisabled.value) {
      ElMessage.warning('请先选择装货合同后再添加产品')
      return
    }
    emit('add-products')
  }

引入 ElMessage(若页面尚未导入)。

  • Step 2: 表单 ProductSelector 传入 allowedProductIds

在 pl-plan-form-dialog/index.vue:

  const loadContractProductIds = ref<string[]>([])

  const productSelectorAllowedIds = computed(() => {
    if (isPlInternalCustomerName(loadForm.value.customerName)) return undefined
    return loadContractProductIds.value
  })

模板:

    <ProductSelector
      :visible="productSelectorVisible"
      :allowed-product-ids="productSelectorAllowedIds"
      @update:visible="productSelectorVisible = $event"
      @confirm="handleProductConfirm"
    />

用 ref 拿到装货区 PlPlanContractMultiSelect(若当前未挂 ref,给 multi-select 加 ref="loadContractSelectRef",或在合同 v-model 变更时 getContract(id) 拉取 products:

  watch(
    () => loadForm.value.contracts,
    async (contracts, prev) => {
      const id = String(contracts?.[0]?.contractId || '').trim()
      const prevId = String(prev?.[0]?.contractId || '').trim()
      if (!id) {
        loadContractProductIds.value = []
        return
      }
      try {
        const res = await getContract(id)
        const detail = (res as any)?.data ?? res
        loadContractProductIds.value = collectContractProductIds(detail?.products || [])
      } catch {
        loadContractProductIds.value = []
      }
      // 合同变化时过滤装货产品(首次加载同 id 不刷)
      if (prev && id !== prevId) {
        const nextProducts = filterProductsByAllowedProductIds(
          loadForm.value.products || [],
          loadContractProductIds.value
        )
        loadForm.value = { ...loadForm.value, products: nextProducts }
        // 触发现有送货同步逻辑(若有 mirror/sync 函数则调用;无则依赖现有 watch)
      }
    },
    { deep: true }
  )

从 @/api/crm/contract 引入 getContract;从 @/utils/pl-plan-contract-product 引入两个 helper。若表单已有装货产品 mirror watch,过滤后确保调用现有 mirrorPlanProductsFromLoad / sync,避免送货残留合同外产品。

  • Step 3: 保存 / 安排前校验 contracts.length > 1

在 validateBusinessRules 或单独函数中:

  function validateSingleContractPerStop(): boolean {
    if (
      !isPlInternalCustomerName(loadForm.value.customerName) &&
      (loadForm.value.contracts || []).length > 1
    ) {
      ElMessage.warning('装货合同只能保留 1 份,请移除多余合同后再保存')
      return false
    }
    for (let i = 0; i < deliveryForms.value.length; i += 1) {
      const row = deliveryForms.value[i]
      if (isPlInternalCustomerName(row.customerName)) continue
      if ((row.contracts || []).length > 1) {
        ElMessage.warning(`送货段#${i + 1}合同只能保留 1 份,请移除多余合同后再保存`)
        return false
      }
    }
    return true
  }

在暂存/保存/安排运输提交路径中、现有 validateBusinessRules 之后调用;安排运输仍保留现有「至少 1 份」校验。

非内置且已选 1 份合同时,前端可用 loadContractProductIds 再校验装货产品(与后端一致);失败文案:装货产品须属于所选合同。

  • Step 4: Commit
git add web-admin/src/components/business/pl-plan-load-section/index.vue \
  web-admin/src/components/business/pl-plan-form-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): gate load products by selected contract

EOF
)"

Task 8: 送货产品允许删除

Files:

  • Modify: web-admin/src/components/business/pl-plan-delivery-section/index.vue

  • Step 1: 打开删除

将:

      <PlPlanStopProductTable
        :products="delivery.products"
        :editable="!readonly"
        :compact="compactProducts"
        :allow-remove="false"
        :shared-readonly="true"
        @remove="(productIndex) => removeStopProduct(index, productIndex)"
      />

改为:

      <PlPlanStopProductTable
        :products="delivery.products"
        :editable="!readonly"
        :compact="compactProducts"
        :allow-remove="!readonly"
        :shared-readonly="true"
        @remove="(productIndex) => removeStopProduct(index, productIndex)"
      />

确认 removeStopProduct 只改该送货段 products,不回写装货(现有实现应已满足)。

  • Step 2: Commit
git add web-admin/src/components/business/pl-plan-delivery-section/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): allow removing products on delivery stops

EOF
)"

Task 9: 合同完善页保存校验

Files:

  • Modify: web-admin/src/views/pl/plan-contract/index.vue

  • Step 1: 保存前最多 1 份

在 handleSaveStopContracts 中,contracts.length 检查处改为:

  if (!supplementDraft.contracts.length) {
    ElMessage.warning('请先选择合同')
    return
  }
  if (supplementDraft.contracts.length > 1) {
    ElMessage.warning('只能保留一份合同,请移除多余合同后再保存')
    return
  }

(单选组件落地后正常不会 >1;历史数据打开弹窗仍可能 >1,故保留拦截。)

  • Step 2: Commit
git add web-admin/src/views/pl/plan-contract/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): enforce single contract on plan-contract supplement save

EOF
)"

Task 10: 联调验收与图谱更新

  • Step 1: 跑后端测试
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py tests/test_pl_plan_contract_gap.py tests/test_pl_plan_stop_service.py -v

Expected: PASS

  • Step 2: 手工验收(对照 spec §8)

  • 非内置:合同单选;再选替换

  • 非内置未选合同:添加产品禁用

  • 非内置已选合同:只能勾选合同内产品

  • 换合同:仅移除不匹配装货行

  • 送货可删产品;「从装货同步」可补回

  • 历史多合同:提示 + 保存失败直到剩 1 份

  • 合同完善页同样最多 1 份

  • 内置客户:无合同、产品全量可选

  • Step 3: 更新知识图谱

./scripts/update-graph.sh all

(若脚本仅支持 backend|frontend|all,按仓库实际参数执行。)

  • Step 4: 如有图谱文件变更可另提交;功能代码勿与无关 WIP 混提

Spec 覆盖自检

Spec 要求 Task
非内置每段最多 1 份合同(前后端) 1, 3, 5, 7, 9
装货产品限合同内(前后端) 2, 4, 6, 7
内置不限产品、免合同 2(skip)、7(不传 allowed)
送货同步 + 可删 8
换合同保留匹配行 4, 7
历史多合同提示 + 保存强拦 5, 7, 9
合同完善页 3, 5, 9
不改 contracts[] 模型 / 不做送货独立添加 全计划未引入相反改动

无 TBD/TODO 占位;函数名 assert_at_most_one_contract / assert_load_products_in_selected_contract / filterProductsByAllowedProductIds 前后一致。