diff --git a/docs/superpowers/plans/2026-08-30-pl-plan-single-contract-products.md b/docs/superpowers/plans/2026-08-30-pl-plan-single-contract-products.md new file mode 100644 index 000000000..601a24f15 --- /dev/null +++ b/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() + 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( + 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 + +``` + +改为: + +```vue + +``` + +(若 `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 + +``` + +(`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 + + 添加产品 + +``` + +```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([]) + + const productSelectorAllowedIds = computed(() => { + if (isPlInternalCustomerName(loadForm.value.customerName)) return undefined + return loadContractProductIds.value + }) +``` + +模板: + +```vue + +``` + +用 `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 + +``` + +改为: + +```vue + +``` + +确认 `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` 前后一致。