Files
shanxia2.0/docs/superpowers/plans/2026-08-20-pl-plan-contract-complete.md
T
zlwuandCursor 900fb20d50 docs: add implementation plan for plan contract completion
Break down backend gap APIs, menu badge, SQL menu, and the inline-expand web-admin page into sequenced tasks with verification steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:52:25 +08:00

23 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: 新增独立菜单页「运输计划合同完善」:只列出未作废且仍缺合同的计划,支持行内展开、分侧多选并各自保存已有 CRM 合同,并显示侧栏待完善角标。

Architecture: 后端在 pl_plan_service 提供缺口判定与 SQL 过滤,暴露 GET /transport/plan/contract-gaps 与 PUT /transport/plan/{id}/contracts/{side};角标复用 pl_menu_badge_service。前端新页 views/pl/plan-contract/index.vue 用 el-table 展开行 + 复用 PlPlanContractMultiSelect/ContractSelector,不打开合同表单。

Tech Stack: FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus;现有 planApi / 菜单角标

Spec: docs/superpowers/specs/2026-08-20-pl-plan-contract-complete-design.md
Demo: docs/fresh-mint-pl-plan-contract-complete-demo.html(方案 B)


File map

操作 文件 职责
Create backend/app/services/pl_plan_contract_gap.py 缺口判定纯函数 + SQL EXISTS 过滤器 + 列表装配辅助
Modify backend/app/schemas/pl/plan.py PlanContractGapRead、PlanSideContractsUpdateBody、列表响应
Modify backend/app/api/pl/plan.py GET /contract-gaps(须在 /{id} 之前)、PUT /{id}/contracts/{side}
Modify backend/app/services/pl_plan_service.py 复用 replace_plan_contracts / assert_contract_selectable_for_plan / load_plan_contracts_grouped(单侧保存编排可放 gap 模块或本文件)
Modify backend/app/services/pl_menu_badge_service.py MENU_CODE_PLAN_CONTRACT + count_plan_contract_gaps
Create backend/tests/test_pl_plan_contract_gap.py 缺口判定与 EXISTS SQL 编译单测
Create backend/scripts/migrate_20260820_pl_plan_contract_complete_menu.sql 菜单 + 按钮权限(幂等)
Modify web-admin/src/api/pl/index.ts listContractGaps / updatePlanSideContracts 类型与方法
Modify web-admin/src/router/modules/pl.ts plan-contract 子路由
Create web-admin/src/views/pl/plan-contract/index.vue 列表 + 行内展开 + 查看弹窗
Modify web-admin/src/App.vue(若需) panelTitleBarTextRules 注册「运输计划合同完善」
Modify graphify 改完后 ./scripts/update-graph.sh all

Task 1: 缺口判定纯函数 + 单测

Files:

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

  • Create: backend/tests/test_pl_plan_contract_gap.py

  • Step 1: 写失败单测(红)

# backend/tests/test_pl_plan_contract_gap.py
from app.services.pl_plan_contract_gap import compute_contract_gap_sides
from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES

INTERNAL = next(iter(INTERNAL_CUSTOMER_NAMES))


def test_both_external_no_contracts_gaps_both():
    sides = compute_contract_gap_sides(
        supplier_name="外部供方A",
        customer_name="外部需方B",
        supplier_contract_count=0,
        demander_contract_count=0,
    )
    assert sides == ["supplier", "demander"]


def test_internal_supplier_external_demander_missing_only_demander():
    sides = compute_contract_gap_sides(
        supplier_name=INTERNAL,
        customer_name="外部需方B",
        supplier_contract_count=0,
        demander_contract_count=0,
    )
    assert sides == ["demander"]


def test_both_internal_no_gap():
    sides = compute_contract_gap_sides(
        supplier_name=INTERNAL,
        customer_name=INTERNAL,
        supplier_contract_count=0,
        demander_contract_count=0,
    )
    assert sides == []


def test_external_with_contracts_no_gap():
    sides = compute_contract_gap_sides(
        supplier_name="外部供方A",
        customer_name="外部需方B",
        supplier_contract_count=1,
        demander_contract_count=2,
    )
    assert sides == []
  • Step 2: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_contract_gap.py -v

Expected: FAIL(模块或函数不存在)

  • Step 3: 实现纯函数
# backend/app/services/pl_plan_contract_gap.py
from __future__ import annotations
from typing import List, Optional

from app.services.pl_internal_customer import is_internal_customer_name

def compute_contract_gap_sides(
    *,
    supplier_name: Optional[str],
    customer_name: Optional[str],
    supplier_contract_count: int,
    demander_contract_count: int,
) -> List[str]:
    gaps: List[str] = []
    if not is_internal_customer_name(supplier_name) and supplier_contract_count <= 0:
        gaps.append("supplier")
    if not is_internal_customer_name(customer_name) and demander_contract_count <= 0:
        gaps.append("demander")
    return gaps


def side_is_exempt(name: Optional[str]) -> bool:
    return is_internal_customer_name(name)
  • Step 4: 跑测通过并提交
pytest tests/test_pl_plan_contract_gap.py -v
git add backend/app/services/pl_plan_contract_gap.py backend/tests/test_pl_plan_contract_gap.py
git commit -m "feat(pl): add plan contract gap side computation helpers"

Task 2: SQL EXISTS 过滤器 + Schema

Files:

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

  • Modify: backend/app/schemas/pl/plan.py

  • Modify: backend/tests/test_pl_plan_contract_gap.py

  • Step 1: 增加 EXISTS 过滤器(供列表 query.filter(...))

在 pl_plan_contract_gap.py 增加:

from sqlalchemy import and_, exists, func, or_, select
from sqlalchemy.orm import Session
from app.models.pl_models import PlPlan, PlPlanContract
from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES
from app.services.pl_plan_service import (
    PLAN_CONTRACT_SIDE_DEMANDER,
    PLAN_CONTRACT_SIDE_SUPPLIER,
)

def _side_has_contract_exists(plan_id_col, side: str):
    return exists().where(
        and_(
            PlPlanContract.plan_id == plan_id_col,
            PlPlanContract.side == side,
            PlPlanContract.del_flag == 0,
        )
    )

def plan_has_contract_gap_exists():
    """未作废计划中,任一侧(非内置且无合同)构成缺口。"""
    supplier_gap = and_(
        ~PlPlan.supplier_name.in_(tuple(INTERNAL_CUSTOMER_NAMES)),
        ~_side_has_contract_exists(PlPlan.id, PLAN_CONTRACT_SIDE_SUPPLIER),
    )
    demander_gap = and_(
        ~PlPlan.customer_name.in_(tuple(INTERNAL_CUSTOMER_NAMES)),
        ~_side_has_contract_exists(PlPlan.id, PLAN_CONTRACT_SIDE_DEMANDER),
    )
    return or_(supplier_gap, demander_gap)

注意:supplier_name / customer_name 为 NULL 时,NOT IN (内置) 在 SQL 中为 UNKNOWN,不会命中缺口——与「无客户名」极少见一致;若需把空名当缺口,改为 (name IS NULL OR name NOT IN (...))。本计划采用:空名视为非内置 → 缺合同,实现时写成:

supplier_not_internal = or_(
    PlPlan.supplier_name.is_(None),
    PlPlan.supplier_name == "",
    ~PlPlan.supplier_name.in_(tuple(INTERNAL_CUSTOMER_NAMES)),
)
  • Step 2: 单测编译 SQL
def test_plan_has_contract_gap_exists_mentions_contract_and_internal_names():
    from sqlalchemy.dialects import mysql
    from app.services.pl_plan_contract_gap import plan_has_contract_gap_exists
    clause = plan_has_contract_gap_exists()
    sql = str(clause.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})).lower()
    assert "pl_plan_contract" in sql
    assert "supplier" in sql
    assert "demander" in sql
    assert "安庆市山峡化工有限公司" in sql or "shanxia" in sql  # 名称应被绑定进 SQL

(若 dialect 把中文绑成参数导致字面量不出现,改为断言 pl_plan_contract + side 即可。)

  • Step 3: Schema

在 backend/app/schemas/pl/plan.py 增加(camelCase alias,与现有一致):

class PlanSideContractsUpdateBody(BaseModel):
    contracts: list[PlanContractLink] = Field(default_factory=list)
    model_config = {"populate_by_name": True}


class PlanContractGapRead(BaseModel):
    id: str
    plan_no: str = Field(..., alias="planNo")
    plan_date: Optional[date] = Field(None, alias="planDate")
    follow_status: str = Field(..., alias="followStatus")
    supplier_id: Optional[str] = Field(None, alias="supplierId")
    supplier_name: Optional[str] = Field(None, alias="supplierName")
    customer_id: Optional[str] = Field(None, alias="customerId")
    customer_name: Optional[str] = Field(None, alias="customerName")
    gap_sides: list[str] = Field(default_factory=list, alias="gapSides")
    supplier_contract_exempt: bool = Field(..., alias="supplierContractExempt")
    demander_contract_exempt: bool = Field(..., alias="demanderContractExempt")
    supplier_contracts: list[PlanContractLink] = Field(default_factory=list, alias="supplierContracts")
    demander_contracts: list[PlanContractLink] = Field(default_factory=list, alias="demanderContracts")
    model_config = {"populate_by_name": True, "from_attributes": True}


class PlanContractGapListResponse(BaseModel):
    total: int
    list: list[PlanContractGapRead]

并从 app.schemas.pl / plan.py 导出(若 __init__.py 有显式导出则补上)。

  • Step 4: 提交
git add backend/app/services/pl_plan_contract_gap.py backend/app/schemas/pl/plan.py backend/tests/test_pl_plan_contract_gap.py
git commit -m "feat(pl): add contract-gap SQL filter and response schemas"

Task 3: 列表 API GET /contract-gaps

Files:

  • Modify: backend/app/services/pl_plan_contract_gap.py(或 pl_plan_service.py)增加 list_plan_contract_gaps(...)

  • Modify: backend/app/api/pl/plan.py

  • Step 1: 服务函数签名(实现要点)

def list_plan_contract_gaps(
    db: Session,
    *,
    tenant_no: str,
    user,
    page: int = 1,
    page_size: int = 20,
    plan_no: Optional[str] = None,
    gap_type: Optional[str] = None,  # supplier | demander | both | None
    customer_keyword: Optional[str] = None,
    plan_date_from: Optional[date] = None,
    plan_date_to: Optional[date] = None,
    q: Optional[str] = None,
) -> tuple[int, list[PlanContractGapRead]]:
    query = db.query(PlPlan).filter(
        PlPlan.tenant_no == tenant_no,
        PlPlan.del_flag == 0,
        PlPlan.follow_status != "cancelled",
        plan_has_contract_gap_exists(),
    )
    query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
    # 可选筛选:plan_no / 日期 / 客户关键词 / q
    # gap_type:在加载合同计数后内存过滤,或额外 AND 侧缺口表达式
    query = query.order_by(PlPlan.plan_date.desc(), PlPlan.create_time.desc())
    total = query.count()
    rows = query.offset((page - 1) * page_size).limit(page_size).all()
    # load_plan_contracts_grouped → compute_contract_gap_sides → 组装 PlanContractGapRead
    ...

gap_type 推荐在 SQL 侧用与 plan_has_contract_gap_exists 相同的侧表达式再 AND,避免分页偏差。

  • Step 2: 路由(必须写在 @router.get("/{id}") 之前)
@router.get("/contract-gaps", response_model=PlanContractGapListResponse)
def list_contract_gaps(
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100, alias="pageSize"),
    plan_no: Optional[str] = Query(None, alias="planNo"),
    gap_type: Optional[str] = Query(None, alias="gapType"),
    customer_keyword: Optional[str] = Query(None, alias="customerKeyword"),
    plan_date_from: Optional[date] = Query(None, alias="planDateFrom"),
    plan_date_to: Optional[date] = Query(None, alias="planDateTo"),
    q: Optional[str] = Query(None),
    db: Session = Depends(get_db),
    user: DbUserContext = Depends(get_db_current_user),
):
    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
    total, items = list_plan_contract_gaps(...)
    return PlanContractGapListResponse(total=total, list=items)
  • Step 3: 本地用 Swagger /docs 或 curl 冒烟(需登录态)

确认返回仅含缺合同、未作废计划。

  • Step 4: 提交
git commit -m "feat(pl): add GET transport/plan/contract-gaps endpoint"

Task 4: 单侧保存 API PUT /{id}/contracts/{side}

Files:

  • Modify: backend/app/services/pl_plan_contract_gap.py(或 pl_plan_service.py)

  • Modify: backend/app/api/pl/plan.py

  • Step 1: 服务编排

def update_plan_side_contracts(
    db: Session,
    *,
    tenant_no: str,
    plan: PlPlan,
    side: str,
    contracts: list[PlanContractLink],
    user_id: Optional[str],
    dept_id: Optional[str],
) -> PlanContractGapRead:
    if plan.follow_status == "cancelled":
        raise HTTPException(status_code=400, detail="已作废的计划不能修改")
    if side not in (PLAN_CONTRACT_SIDE_SUPPLIER, PLAN_CONTRACT_SIDE_DEMANDER):
        raise HTTPException(status_code=422, detail="side 必须为 supplier 或 demander")

    current_s, current_d = load_plan_contracts_grouped(db, tenant_no, [plan.id]).get(
        plan.id, ([], [])
    )
    links = _normalize_contract_links(contracts)
    # 校验本侧每份合同(允许空列表)
    expected_customer_id = plan.supplier_id if side == PLAN_CONTRACT_SIDE_SUPPLIER else plan.customer_id
    side_label = "供方合同" if side == PLAN_CONTRACT_SIDE_SUPPLIER else "需方合同"
    resolved: list[PlanContractLink] = []
    for link in links:
        c = assert_contract_selectable_for_plan(
            db, link.contract_id, tenant_no,
            expected_customer_id=expected_customer_id,
            side_label=side_label,
        )
        resolved.append(PlanContractLink(contractId=c.id, contractNo=c.contract_no or link.contract_no))

    supplier_links = resolved if side == PLAN_CONTRACT_SIDE_SUPPLIER else current_s
    demander_links = resolved if side == PLAN_CONTRACT_SIDE_DEMANDER else current_d
    replace_plan_contracts(
        db,
        tenant_no=tenant_no,
        plan_id=plan.id,
        supplier_contracts=supplier_links,
        demander_contracts=demander_links,
        user_id=user_id,
        dept_id=dept_id,
    )
    # 重新 load + compute gap → 返回 PlanContractGapRead

禁止对本接口调用「非内置必须有合同」的强制校验。

  • Step 2: 路由
@router.put("/{id}/contracts/{side}", response_model=PlanContractGapRead)
def put_plan_side_contracts(
    id: str,
    side: str,
    payload: PlanSideContractsUpdateBody,
    db: Session = Depends(get_db),
    user: DbUserContext = Depends(get_db_current_user),
):
    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
    plan = _get_plan(db, tenant_no, id, user)
    result = update_plan_side_contracts(...)
    db.commit()
    return result
  • Step 3: 手动验证
  1. 只改供方合同,需方关联不变
  2. 清空非内置侧合同 → 200,且该计划仍出现在 contract-gaps
  3. 作废计划 → 400
  • Step 4: 提交
git commit -m "feat(pl): add PUT plan side contracts for contract completion"

Task 5: 菜单角标

Files:

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

  • Step 1: 常量与计数

MENU_CODE_PLAN_CONTRACT = "pl:plan-contract:list"
# 加入 SUPPORTED_MENU_CODES

def count_plan_contract_gaps(db: Session, tenant_no: str, user) -> int:
    from app.services.pl_plan_contract_gap import plan_has_contract_gap_exists
    query = db.query(PlPlan).filter(
        PlPlan.tenant_no == tenant_no,
        PlPlan.del_flag == 0,
        PlPlan.follow_status != "cancelled",
        plan_has_contract_gap_exists(),
    )
    query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
    return query.count()

在 build_menu_badge_counts 中:

if MENU_CODE_PLAN_CONTRACT in requested:
    counts[MENU_CODE_PLAN_CONTRACT] = count_plan_contract_gaps(db, tenant_no, user)
  • Step 2: 提交
git commit -m "feat(pl): badge count for plan contract completion menu"

Task 6: 菜单 SQL + 前端路由

Files:

  • Create: backend/scripts/migrate_20260820_pl_plan_contract_complete_menu.sql

  • Modify: web-admin/src/router/modules/pl.ts

  • Step 1: 幂等菜单脚本

-- 运输计划合同完善:挂在 PL_GROUP_PLAN 下,order_num=3
INSERT INTO `sys_menu` (
  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
) VALUES (
  'PL_PLAN_CONTRACT',
  '运输计划合同完善',
  'PL_GROUP_PLAN',
  'C',
  'plan-contract',
  'pl/plan-contract/index',
  'pl:plan-contract:list',
  1, '1', '1', 'shanxia2.0', 3,
  '缺合同运输计划补齐',
  'system', '', NOW(), NULL, NOW(), 0
)
ON DUPLICATE KEY UPDATE
  `menu_name`=VALUES(`menu_name`),
  `parent_id`=VALUES(`parent_id`),
  `path`=VALUES(`path`),
  `component`=VALUES(`component`),
  `perms`=VALUES(`perms`),
  `order_num`=VALUES(`order_num`),
  `visible`=VALUES(`visible`),
  `status`=VALUES(`status`);

INSERT INTO `sys_menu` (
  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
) VALUES (
  'PL_PLAN_CONTRACT_UPD',
  '保存合同关联',
  'PL_PLAN_CONTRACT',
  'F',
  '',
  NULL,
  'pl:plan-contract:update',
  1, '1', '1', 'shanxia2.0', 1,
  NULL,
  'system', '', NOW(), NULL, NOW(), 0
)
ON DUPLICATE KEY UPDATE
  `perms`=VALUES(`perms`),
  `parent_id`=VALUES(`parent_id`);

将 PL_PLAN_CONTRACT / PL_PLAN_CONTRACT_UPD 赋给需要该能力的角色(至少 super_admin 对应角色;可复制现有 PL_PLAN 的 sys_role_menu 角色集合,用新 UUID 插入,脚本内写清)。

  • Step 2: 路由

在 pl.ts 的 plan-module children 中、plan-archive 之后增加:

{
  id: 'PL_PLAN_CONTRACT',
  name: 'PlPlanContract',
  path: 'plan-contract',
  component: '/pl/plan-contract/index',
  meta: { title: '运输计划合同完善', keepAlive: true, menuCode: 'pl:plan-contract:list' }
}
  • Step 3: 本地执行 SQL 后重新登录,确认侧栏出现菜单(角标可能为 0)

  • Step 4: 提交

git commit -m "feat(pl): add plan-contract menu SQL and web-admin route"

Task 7: 前端 API

Files:

  • Modify: web-admin/src/api/pl/index.ts

  • Step 1: 类型与方法

export interface PlanContractGapItem {
  id: string
  planNo: string
  planDate?: string
  followStatus: string
  supplierId?: string
  supplierName?: string
  customerId?: string
  customerName?: string
  gapSides: Array<'supplier' | 'demander'>
  supplierContractExempt: boolean
  demanderContractExempt: boolean
  supplierContracts: PlanContractLink[]
  demanderContracts: PlanContractLink[]
}

export interface PlanContractGapQuery {
  page?: number
  pageSize?: number
  planNo?: string
  gapType?: 'supplier' | 'demander' | 'both' | ''
  customerKeyword?: string
  planDateFrom?: string
  planDateTo?: string
  q?: string
}

// 在 planApi 上增加:
listContractGaps: (params?: PlanContractGapQuery) =>
  request.get<PlListResponse<PlanContractGapItem>>('/transport/plan/contract-gaps', { params }),
updateSideContracts: (id: string, side: 'supplier' | 'demander', contracts: PlanContractLink[]) =>
  request.put<PlanContractGapItem>(`/transport/plan/${id}/contracts/${side}`, { contracts }),

确认 PlanContractLink 类型已在同文件存在;没有则按 { contractId: string; contractNo?: string } 补上。

  • Step 2: 提交
git commit -m "feat(pl): frontend API for plan contract gaps"

Task 8: 页面列表 + 行内展开(方案 B)

Files:

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

  • Modify: web-admin/src/App.vue(如有 panelTitleBarTextRules,加入「运输计划合同完善」)

  • Step 1: 页面骨架

对照 plan-cert/index.vue + Demo 方案 B:

  • panel-top 标题 + 「待完善 N 条」(可用 total)
  • filter-block:计划编号、缺口类型、供方/需方、计划日期
  • list-toolbar:ListToolbarDateShortcuts + ListQuickSearchInput + 刷新(无新增)
  • el-table:type="expand" 或自定义展开行;列:计划编号(el-link)、计划日期、供方、需方、合同缺口 Tag、跟进状态、操作
  • 分页:list-body.list-wrap + el-pagination
  • PlPlanViewDialog:编号链接 / 「查看计划」打开(同 plan-cert)

缺口 Tag 映射:

function gapTagLabel(sides: string[]) {
  if (sides.includes('supplier') && sides.includes('demander')) return { text: '两侧均缺', type: 'danger' }
  if (sides.includes('supplier')) return { text: '缺供方合同', type: 'warning' }
  if (sides.includes('demander')) return { text: '缺需方合同', type: 'warning' }
  return { text: '—', type: 'info' }
}

跟进状态用现有 PL_PLAN_FOLLOW_STATUS / DictTag 方式(与 plan 列表一致)。

  • Step 2: 展开区

  • expandedRowId 仅允许一行;切换前若 dirty → ElMessageBox.confirm

  • 左右两栏:

    • 豁免:灰态文案,无按钮
    • 非豁免:PlPlanContractMultiSelect(:required="false"),本地 v-model 绑定草稿;「保存」调用 planApi.updateSideContracts
  • 保存成功:ElMessage.success;用返回的 gapSides 更新行;若 gapSides.length===0 则从 tableData 移除并刷新角标(若项目有 refreshMenuBadges / store 方法则调用,否则依赖下次进入菜单)

  • 保存按钮:有 pl:plan-contract:update 权限才显示(对齐项目权限指令,如 v-hasPermi;若本仓库用其它写法,跟随 plan 页批量操作权限模式)

  • Step 3: 手动 UI 验收清单

  1. 列表只有缺合同、未作废计划
  2. 行内展开互斥;未保存收起有确认
  3. 单侧保存后对侧不变;两侧齐后行消失
  4. 无新建/编辑合同入口
  5. 侧栏角标数字与列表 total 口径一致(同筛选默认条件)
  6. Fresh Mint:标题竖条、筛选、工具栏「已选」规则本页无批量主按钮即可
  • Step 4: graphify + 提交
./scripts/update-graph.sh all
git add -A && git commit -m "feat(pl): add plan contract completion page with inline expand"

Task 9: Spec 状态与收尾

  • 将 docs/superpowers/specs/2026-08-20-pl-plan-contract-complete-design.md 状态改为「已实现(待联调验收)」
  • 确认增量 SQL 已在目标库执行,角色可见菜单
  • 提交 docs 状态更新
git commit -m "docs: mark plan contract completion design as implemented"

Spec coverage checklist

Spec 要求 Task
独立菜单页路径 1 6, 8
仅未作废;归档仍入列 3(filter 不含 archive_status)
缺口规则 / 内置可免 1, 2
行内展开方案 B 8
每侧多选 8(ContractSelector multiple)
两侧各自保存 4, 8
菜单角标 5, 6
无合同 CRUD 8(不引入合同表单)
API contract-gaps / side PUT 3, 4
权限 list/update 6, 8

Placeholder / 一致性自检

  • 路由前缀统一为前端已用的 /transport/plan/...(与 planApi 一致),对应后端 app/api/pl/plan.py router。
  • side 字面量:supplier / demander(与 PLAN_CONTRACT_SIDE_* 一致)。
  • GET /contract-gaps 必须注册在 GET /{id} 之前,避免被路径参数吞掉。