|
|
@@ -0,0 +1,675 @@
|
|
|
+# 运输计划合同完善 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}` 之前,避免被路径参数吞掉。
|