Files
shanxia2.0/docs/superpowers/plans/2026-09-02-pl-plan-draft-save.md
T
zlwuandCursor 16c657b75e docs: add implementation plan for plan draft save / drafting status
Break the approved design into backend helpers, saveMode API, list tab filters, and form dialog tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 11:37:11 +08:00

17 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: 在运输计划新增/编辑弹窗支持【暂存】(跟进状态 drafting / 草稿中),列表「待安排/草稿」Tab 默认同时展示待安排与草稿,正式保存将草稿升为待安排。

Architecture: 跟进状态新增 drafting;create-full / PUT 增加 saveMode: 'draft' | 'formal'。draft 仅校验归属公司(创建日期可缺省当天),跳过业务校验;formal 保持全量校验并在原状态为 drafting 时升为 pending_arrange。前端展示常量、列表 Tab/筛选、弹窗按钮对齐该状态机。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2 + pytest;Vue 3 + Element Plus + TypeScript

Spec: docs/superpowers/specs/2026-09-02-pl-plan-draft-save-design.md

注意: 工作区可能有无关未提交改动(如 backend/app.pid)。只改本计划列出的文件,提交时勿夹带无关 diff。


文件结构

操作 文件 职责
Create backend/tests/test_pl_plan_draft_save.py saveMode / 草稿门禁纯函数单测
Modify backend/app/services/pl_plan_service.py validate_draft_min_fields、resolve_follow_status_on_save、assert_can_save_as_draft
Modify backend/app/schemas/pl/plan.py PlanCreateFull / PlanUpdate 增加 save_mode;跟进状态注释含 drafting
Modify backend/app/api/pl/plan.py create-full / update 的 draft 分支;wait-notice / delete / force 白名单
Modify web-admin/src/utils/pl-display.ts 选项、默认筛选、Tab 文案与 filter、Tag/Label
Modify web-admin/src/api/pl/index.ts saveMode 类型
Modify web-admin/src/views/pl/plan/index.vue Tab 切入默认值、取消 disabled、canWaitNotice
Modify web-admin/src/components/business/pl-plan-form-dialog/index.vue 【暂存】按钮与 draft 提交路径
Verify web-admin/src/views/pl/plan-cert/index.vue 默认筛选随 PL_PLAN_DEFAULT_FOLLOW_STATUSES 自动含草稿

Task 1: 后端纯函数 — 草稿校验与跟进状态决议

Files:

  • Create: backend/tests/test_pl_plan_draft_save.py

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

  • Step 1: 写失败单测

在 backend/tests/test_pl_plan_draft_save.py:

import pytest
from fastapi import HTTPException

from app.services.pl_plan_service import (
    DRAFTABLE_FOLLOW_STATUSES,
    assert_can_save_as_draft,
    resolve_follow_status_on_save,
    validate_draft_min_fields,
)


def test_validate_draft_min_fields_requires_belongs_company():
    with pytest.raises(HTTPException) as exc:
        validate_draft_min_fields(belongs_company="", create_date=None)
    assert exc.value.status_code == 422
    assert "归属公司" in str(exc.value.detail)


def test_validate_draft_min_fields_allows_missing_create_date():
    validate_draft_min_fields(belongs_company="shanxia", create_date=None)


def test_resolve_follow_status_draft_always_drafting():
    assert resolve_follow_status_on_save(save_mode="draft", current="pending_arrange", is_create=False) == "drafting"
    assert resolve_follow_status_on_save(save_mode="draft", current=None, is_create=True) == "drafting"


def test_resolve_follow_status_formal_promotes_drafting():
    assert resolve_follow_status_on_save(save_mode="formal", current=None, is_create=True) == "pending_arrange"
    assert resolve_follow_status_on_save(save_mode="formal", current="drafting", is_create=False) == "pending_arrange"
    assert resolve_follow_status_on_save(save_mode="formal", current="arranged", is_create=False) == "arranged"
    assert resolve_follow_status_on_save(save_mode="formal", current="wait_notice", is_create=False) == "wait_notice"


def test_assert_can_save_as_draft_rejects_arranged():
    assert "drafting" in DRAFTABLE_FOLLOW_STATUSES
    assert "pending_arrange" in DRAFTABLE_FOLLOW_STATUSES
    with pytest.raises(HTTPException) as exc:
        assert_can_save_as_draft("arranged")
    assert exc.value.status_code == 400
    assert "不可暂存" in str(exc.value.detail)
  • Step 2: 运行确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_draft_save.py -v

Expected: FAIL(符号未定义)

  • Step 3: 实现纯函数

在 backend/app/services/pl_plan_service.py(靠近 validate_create_full)新增:

DRAFTABLE_FOLLOW_STATUSES = frozenset({"drafting", "pending_arrange"})


def validate_draft_min_fields(*, belongs_company: Optional[str], create_date) -> None:
    if not (belongs_company or "").strip():
        raise HTTPException(status_code=422, detail="请选择归属公司")
    # create_date 允许为空,由 create/update 用 _resolve_create_date 兜底当天


def resolve_follow_status_on_save(
    *,
    save_mode: str,
    current: Optional[str],
    is_create: bool,
) -> str:
    mode = (save_mode or "formal").strip() or "formal"
    if mode == "draft":
        return "drafting"
    if is_create:
        return "pending_arrange"
    if current == "drafting":
        return "pending_arrange"
    return (current or "pending_arrange").strip() or "pending_arrange"


def assert_can_save_as_draft(current_status: Optional[str]) -> None:
    status = (current_status or "").strip()
    if status not in DRAFTABLE_FOLLOW_STATUSES:
        raise HTTPException(status_code=400, detail="当前状态不可暂存")

确保已有 Optional、HTTPException 导入。

  • Step 4: 运行确认通过
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_draft_save.py -v

Expected: PASS

  • Step 5: Commit
git add backend/tests/test_pl_plan_draft_save.py backend/app/services/pl_plan_service.py
git commit -m "$(cat <<'EOF'
feat(backend): add draft save helpers for plan follow status drafting

Introduce min-field validation and follow_status resolution for saveMode draft/formal.
EOF
)"

Task 2: Schema 增加 saveMode

Files:

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

  • Step 1: 更新跟进状态字段说明

PlanBase.follow_status 的 description 改为:

跟进状态 drafting/wait_notice/pending_arrange/arranged/completed/cancelled

PlanForceFollowStatusBody 同类 description 同步。

  • Step 2: 在 PlanCreateFull 与 PlanUpdate 增加字段
save_mode: Optional[str] = Field(
    default="formal",
    alias="saveMode",
    description="保存模式 draft=暂存 formal=正式(默认)",
)

后续 update 的 model_dump(exclude=...) 必须排除 save_mode,勿写入 ORM。

  • Step 3: Commit
git add backend/app/schemas/pl/plan.py
git commit -m "$(cat <<'EOF'
feat(backend): add saveMode field on plan create-full and update schemas
EOF
)"

Task 3: create-full / update 接入 draft / formal

Files:

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

  • Step 1: 导入 helper

从 pl_plan_service 增加导入:

assert_can_save_as_draft, resolve_follow_status_on_save, validate_draft_min_fields

  • Step 2: 改造 create_plan_full

开头:

save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
is_draft = save_mode == "draft"

草稿分支:

if is_draft:
    validate_draft_min_fields(
        belongs_company=payload.belongs_company,
        create_date=payload.create_date,
    )
    multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
    qty_warnings: list[str] = []
    load_body = None
    delivery_bodies: list = []
    supplier_contracts: list = []
    demander_contracts: list = []
    # 跳过 validate_create_full / validate_plan_party_contracts
    # 尝试段校验;失败则只落主表+products,不写段
    if multi_stop and payload.load is not None:
        try:
            load_body, delivery_bodies = validate_plan_stops_write(
                db, tenant_no, payload.load, payload.deliveries, payload.products,
            )
            qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
        except HTTPException:
            load_body, delivery_bodies = None, []
            multi_stop = False
else:
    # 保持现有 multi_stop / validate_create_full 逻辑
    ...

创建 PlPlan:

follow_status=resolve_follow_status_on_save(
    save_mode=save_mode, current=None, is_create=True
),

create_date 继续 _resolve_create_date(payload.create_date)。
段写入仍:if multi_stop and load_body is not None: replace_plan_stops(...)。
允许 products 为空。

  • Step 3: 改造 update_plan

cancelled 检查之后:

save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
is_draft = save_mode == "draft"
if is_draft:
    assert_can_save_as_draft(plan.follow_status)
    validate_draft_min_fields(
        belongs_company=payload.belongs_company if payload.belongs_company is not None else plan.belongs_company,
        create_date=payload.create_date if payload.create_date is not None else plan.create_date,
    )

model_dump 的 exclude 增加 "save_mode"。

草稿时跳过强制 validate_plan_party_contracts;段写入同 create:try validate_plan_stops_write,失败则 不调用 replace_plan_stops(保留原段)。

db.commit() 前:

plan.follow_status = resolve_follow_status_on_save(
    save_mode=save_mode,
    current=plan.follow_status,
    is_create=False,
)
  • Step 4: 导入检查
cd backend && source venv/bin/activate && python -c "from app.api.pl import plan; print('ok')"
  • Step 5: Commit
git add backend/app/api/pl/plan.py
git commit -m "$(cat <<'EOF'
feat(backend): support saveMode draft/formal on plan create-full and update

Draft skips business validators and writes follow_status drafting; formal promotes drafting to pending_arrange.
EOF
)"

Task 4: wait-notice / delete / force-follow-status 对齐草稿

Files:

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

  • Step 1: mark_plan_wait_notice

if plan.follow_status not in ("pending_arrange", "drafting"):
    raise HTTPException(status_code=400, detail="仅待安排或草稿中状态可标记为等通知")
  • Step 2: delete_plan
if plan.follow_status not in ("pending_arrange", "wait_notice", "drafting"):
    raise HTTPException(status_code=400, detail="只能删除待安排、草稿中或等通知且未安排运输的计划")
  • Step 3: ALLOWED_FORCE_FOLLOW_STATUSES
ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
    {"drafting", "wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
)

错误文案加上「草稿中」。确认安排成功路径已写 follow_status = "arranged"(pl_plan_service / transport.py),草稿安排后可离开草稿态。

  • Step 4: Commit
git add backend/app/api/pl/plan.py
git commit -m "$(cat <<'EOF'
feat(backend): allow drafting plans for wait-notice, delete, and force status
EOF
)"

Task 5: 前端展示常量与列表 Tab/筛选

Files:

  • Modify: web-admin/src/utils/pl-display.ts

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

  • Verify: web-admin/src/views/pl/plan-cert/index.vue

  • Step 1: 更新 pl-display.ts

export const PL_PLAN_LIST_TABS: { label: string; name: PlPlanListTab }[] = [
  { label: '待安排/草稿', name: 'pending_arrange' }, // name 保留;语义含 drafting
  ...PL_PLAN_TYPE_TABS
]

// 待安排/草稿 Tab 不再返回 followStatus,改由 searchForm 控制,避免与多选冲突
export function plPlanListTabToFilters(tab: PlPlanListTab): {
  planType: string
  followStatus?: string
} {
  if (tab === 'pending_arrange') {
    return { planType: '' }
  }
  return { planType: plPlanTypeTabToFilter(tab) }
}

export const PL_PLAN_FOLLOW_STATUS_OPTIONS = [
  { label: '草稿中', value: 'drafting' },
  { label: '等通知', value: 'wait_notice' },
  { label: '待安排', value: 'pending_arrange' },
  { label: '已安排', value: 'arranged' },
  { label: '已完成', value: 'completed' },
  { label: '已取消', value: 'cancelled' }
] as const

// PL_PLAN_DEFAULT_FOLLOW_STATUSES 仍排除 cancelled,将自动含 drafting

Tag/Label 增加 drafting: 'info' / '草稿中';保留旧 draft → '待安排' 兼容,勿改成草稿中。

  • Step 2: 更新 plan/index.vue
  1. 删除跟进状态 select 的 :disabled="activePlanTypeTab === 'pending_arrange'"
  2. 初始与重置:followStatus: ['pending_arrange', 'drafting']
  3. Tab 切换:
if (nextTab === 'pending_arrange') {
  searchForm.followStatus = ['pending_arrange', 'drafting']
} else if (prevTab === 'pending_arrange') {
  searchForm.followStatus = [...PL_PLAN_DEFAULT_FOLLOW_STATUSES]
}
  1. canWaitNotice:
function canWaitNotice(row: Plan) {
  return (
    (row.followStatus === 'pending_arrange' || row.followStatus === 'drafting') &&
    row.archiveStatus !== 'archived'
  )
}
  1. 强制改状态选项使用 PL_PLAN_FOLLOW_STATUS_OPTIONS。
  2. fetchData / export 继续:followStatus: tabFilters.followStatus || formatFollowStatusParam(searchForm.followStatus)(pending_arrange Tab 时 tabFilters 无 followStatus,走 searchForm)。
  • Step 3: 核对 plan-cert

确认重置使用 PL_PLAN_DEFAULT_FOLLOW_STATUSES 即可自动含 drafting。

  • Step 4: Commit
git add web-admin/src/utils/pl-display.ts web-admin/src/views/pl/plan/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): show drafting in plan list tab and follow-status filters

Rename pending tab to 待安排/草稿 and default-select pending_arrange + drafting.
EOF
)"

Task 6: API 类型 + 弹窗【暂存】

Files:

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

  • Modify: web-admin/src/components/business/pl-plan-form-dialog/index.vue

  • Step 1: 类型

PlanCreateFullPayload 增加:saveMode?: 'draft' | 'formal'

  • Step 2: 弹窗按钮与状态

loadPlanForEdit 记录 loadedFollowStatus;reset/copy 清或带源状态。

const showDraftButton = computed(() => {
  if (!isEdit.value) return true
  return loadedFollowStatus.value === 'drafting' || loadedFollowStatus.value === 'pending_arrange'
})

Footer 在取消与创建/保存之间:

<el-button
  v-if="showDraftButton"
  :loading="submitLoading"
  @click="handleSubmit('draft')"
>
  <el-icon><Document /></el-icon>暂存
</el-button>

导入 Document(@element-plus/icons-vue)。

  • Step 3: handleSubmit('draft')

签名增加 'draft'。draft 分支:

  • 只校验 belongsCompany、createDate 非空;失败 Message + validateField 这两项
  • saveMode: 'draft' 调 createFull / update
  • 成功「暂存成功」、关窗、emit('success');新建用返回的 planId
  • 不跑 validateBusinessRules / 合同 / 安排校验

formal 的 save/安排路径传 saveMode: 'formal' 或不传(后端默认 formal)。

  • Step 4: Commit
git add web-admin/src/api/pl/index.ts web-admin/src/components/business/pl-plan-form-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add plan form draft save button with saveMode drafting
EOF
)"

Task 7: 验收与图谱

  • Step 1: 后端单测
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_draft_save.py -v

Expected: PASS

  • Step 2: 手工验收(spec §7)
  1. 新建仅公司+日期 → 暂存 → 列表「草稿中」出现在「待安排/草稿」
  2. 编辑草稿 → 保存 → 「待安排」
  3. 编辑待安排 → 暂存 → 「草稿中」
  4. 已安排编辑 → 无【暂存】
  5. Tab 默认两项;多选可再筛
  6. 草稿等通知/安排:失败有校验提示;成功离开草稿
  7. 草稿可删(未安排未归档)
  • Step 3: 更新知识图谱
./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
  • Step 4: 若有图谱变更则提交
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after plan draft-save feature
EOF
)"

Spec 覆盖自检

Spec 要求 Task
drafting 状态与文案 1, 5
saveMode draft/formal 2, 3, 6
暂存仅公司+日期 1, 3, 6
正式保存草稿→待安排 1, 3, 6
Tab「待安排/草稿」+ 默认两项 5
多选可用、Tab 不禁用 5
暂存按钮可见性 6
wait-notice / delete / force 含 drafting 4
列表操作与待安排相同 4, 5
不改 archive draft 未改 archive 接口