Browse Source

docs: add implementation plan for plan single-contract product rules

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 months ago
parent
commit
98db382e70
1 changed files with 837 additions and 0 deletions
  1. 837 0
      docs/superpowers/plans/2026-08-30-pl-plan-single-contract-products.md

+ 837 - 0
docs/superpowers/plans/2026-08-30-pl-plan-single-contract-products.md

@@ -0,0 +1,837 @@
+# 运输计划段次合同单选 + 装货产品限合同内 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`:
+
+```python
+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: 运行确认失败**
+
+```bash
+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` 附近)新增:
+
+```python
+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)` 之后立刻:
+
+```python
+    links = assert_at_most_one_contract(links, side_label)
+```
+
+- [ ] **Step 4: 跑通单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+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: 写失败单测(产品归属)**
+
+追加到同测试文件:
+
+```python
+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: 运行确认失败**
+
+```bash
+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`**
+
+```python
+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` 前调用:
+
+```python
+    assert_load_products_in_selected_contract(db, tenant_no, load)
+```
+
+注意:调用时 `load.contracts` 应为已 resolve 的列表;若 resolve 清空了内置客户合同,上述函数会因无合同而跳过。
+
+- [ ] **Step 4: 跑通单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_single_contract_rules.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+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 行后):
+
+```python
+    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` 中规范化后:
+
+```python
+    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: 回归相关单测**
+
+```bash
+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**
+
+```bash
+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: 新增工具**
+
+```typescript
+/** 从合同明细行收集 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**
+
+```bash
+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 改单选**
+
+将:
+
+```vue
+    <ContractSelector
+      v-if="!readonly"
+      v-model:visible="selectorVisible"
+      :customer-id="customerId"
+      :selectable-statuses="selectableStatuses"
+      multiple
+      :selected-ids="selectedIds"
+      hide-amount
+      @confirm-multiple="handleConfirmMultiple"
+    />
+```
+
+改为:
+
+```vue
+    <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: 替换确认处理与提示文案**
+
+```typescript
+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 读取(供表单使用)**
+
+```typescript
+defineExpose({
+  openSelector,
+  getContractProductIds: () =>
+    collectContractProductIds(detailRows.value.flatMap((row) => row.products || []))
+})
+```
+
+从 `@/utils/pl-plan-contract-product` 引入 `collectContractProductIds`。
+
+- [ ] **Step 4: Commit**
+
+```bash
+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**
+
+```typescript
+  const props = defineProps<{
+    visible: boolean
+    supplierId?: string | number
+    /** 非空时仅允许选择这些 CRM 产品 id(运输计划按合同限制) */
+    allowedProductIds?: string[]
+  }>()
+```
+
+- [ ] **Step 2: 列表加载后过滤;限制模式下隐藏「新增」**
+
+在 `getList` 赋值 `productList` 之后:
+
+```typescript
+      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
+```
+
+模板中「新增」按钮:
+
+```vue
+          <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**
+
+```bash
+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`:
+
+```vue
+      <el-button
+        v-if="!readonly"
+        class="btn-main"
+        type="primary"
+        size="small"
+        :disabled="addProductsDisabled"
+        @click="handleAddProductsClick"
+      >
+        <el-icon><Plus /></el-icon>添加产品
+      </el-button>
+```
+
+```typescript
+  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`:
+
+```typescript
+  const loadContractProductIds = ref<string[]>([])
+
+  const productSelectorAllowedIds = computed(() => {
+    if (isPlInternalCustomerName(loadForm.value.customerName)) return undefined
+    return loadContractProductIds.value
+  })
+```
+
+模板:
+
+```vue
+    <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:
+
+```typescript
+  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` 或单独函数中:
+
+```typescript
+  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**
+
+```bash
+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: 打开删除**
+
+将:
+
+```vue
+      <PlPlanStopProductTable
+        :products="delivery.products"
+        :editable="!readonly"
+        :compact="compactProducts"
+        :allow-remove="false"
+        :shared-readonly="true"
+        @remove="(productIndex) => removeStopProduct(index, productIndex)"
+      />
+```
+
+改为:
+
+```vue
+      <PlPlanStopProductTable
+        :products="delivery.products"
+        :editable="!readonly"
+        :compact="compactProducts"
+        :allow-remove="!readonly"
+        :shared-readonly="true"
+        @remove="(productIndex) => removeStopProduct(index, productIndex)"
+      />
+```
+
+确认 `removeStopProduct` 只改该送货段 `products`,不回写装货(现有实现应已满足)。
+
+- [ ] **Step 2: Commit**
+
+```bash
+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` 检查处改为:
+
+```typescript
+  if (!supplementDraft.contracts.length) {
+    ElMessage.warning('请先选择合同')
+    return
+  }
+  if (supplementDraft.contracts.length > 1) {
+    ElMessage.warning('只能保留一份合同,请移除多余合同后再保存')
+    return
+  }
+```
+
+(单选组件落地后正常不会 >1;历史数据打开弹窗仍可能 >1,故保留拦截。)
+
+- [ ] **Step 2: Commit**
+
+```bash
+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: 跑后端测试**
+
+```bash
+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: 更新知识图谱**
+
+```bash
+./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` 前后一致。