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

676 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 运输计划合同完善 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: 写失败单测(红)**
```python
# 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: 跑测确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_contract_gap.py -v
```
Expected: FAIL(模块或函数不存在)
- [ ] **Step 3: 实现纯函数**
```python
# 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: 跑测通过并提交**
```bash
pytest tests/test_pl_plan_contract_gap.py -v
```
```bash
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` 增加:
```python
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 (...))`。**本计划采用:空名视为非内置 → 缺合同**,实现时写成:
```python
supplier_not_internal = or_(
PlPlan.supplier_name.is_(None),
PlPlan.supplier_name == "",
~PlPlan.supplier_name.in_(tuple(INTERNAL_CUSTOMER_NAMES)),
)
```
- [ ] **Step 2: 单测编译 SQL**
```python
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,与现有一致):
```python
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: 提交**
```bash
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: 服务函数签名(实现要点)**
```python
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}")` 之前)**
```python
@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: 提交**
```bash
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: 服务编排**
```python
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: 路由**
```python
@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: 提交**
```bash
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: 常量与计数**
```python
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` 中:
```python
if MENU_CODE_PLAN_CONTRACT in requested:
counts[MENU_CODE_PLAN_CONTRACT] = count_plan_contract_gaps(db, tenant_no, user)
```
- [ ] **Step 2: 提交**
```bash
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: 幂等菜单脚本**
```sql
-- 运输计划合同完善:挂在 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` 之后增加:
```typescript
{
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: 提交**
```bash
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: 类型与方法**
```typescript
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: 提交**
```bash
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 映射:
```typescript
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 + 提交**
```bash
./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 状态更新
```bash
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}` 之前,避免被路径参数吞掉。