Ver Fonte

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>
zlwu há 1 mês atrás
pai
commit
16c657b75e
1 ficheiros alterados com 540 adições e 0 exclusões
  1. 540 0
      docs/superpowers/plans/2026-09-02-pl-plan-draft-save.md

+ 540 - 0
docs/superpowers/plans/2026-09-02-pl-plan-draft-save.md

@@ -0,0 +1,540 @@
+# 运输计划「暂存 / 草稿中」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`:
+
+```python
+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: 运行确认失败**
+
+```bash
+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`)新增:
+
+```python
+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: 运行确认通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_plan_draft_save.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+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` 增加字段**
+
+```python
+save_mode: Optional[str] = Field(
+    default="formal",
+    alias="saveMode",
+    description="保存模式 draft=暂存 formal=正式(默认)",
+)
+```
+
+后续 update 的 `model_dump(exclude=...)` 必须排除 `save_mode`,勿写入 ORM。
+
+- [ ] **Step 3: Commit**
+
+```bash
+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`**
+
+开头:
+
+```python
+save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
+is_draft = save_mode == "draft"
+```
+
+草稿分支:
+
+```python
+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`:
+
+```python
+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 检查之后:
+
+```python
+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()` 前:
+
+```python
+plan.follow_status = resolve_follow_status_on_save(
+    save_mode=save_mode,
+    current=plan.follow_status,
+    is_create=False,
+)
+```
+
+- [ ] **Step 4: 导入检查**
+
+```bash
+cd backend && source venv/bin/activate && python -c "from app.api.pl import plan; print('ok')"
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+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`**
+
+```python
+if plan.follow_status not in ("pending_arrange", "drafting"):
+    raise HTTPException(status_code=400, detail="仅待安排或草稿中状态可标记为等通知")
+```
+
+- [ ] **Step 2: `delete_plan`**
+
+```python
+if plan.follow_status not in ("pending_arrange", "wait_notice", "drafting"):
+    raise HTTPException(status_code=400, detail="只能删除待安排、草稿中或等通知且未安排运输的计划")
+```
+
+- [ ] **Step 3: `ALLOWED_FORCE_FOLLOW_STATUSES`**
+
+```python
+ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
+    {"drafting", "wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
+)
+```
+
+错误文案加上「草稿中」。确认安排成功路径已写 `follow_status = "arranged"`(`pl_plan_service` / `transport.py`),草稿安排后可离开草稿态。
+
+- [ ] **Step 4: Commit**
+
+```bash
+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`**
+
+```typescript
+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 切换:
+
+```typescript
+if (nextTab === 'pending_arrange') {
+  searchForm.followStatus = ['pending_arrange', 'drafting']
+} else if (prevTab === 'pending_arrange') {
+  searchForm.followStatus = [...PL_PLAN_DEFAULT_FOLLOW_STATUSES]
+}
+```
+
+4. `canWaitNotice`:
+
+```typescript
+function canWaitNotice(row: Plan) {
+  return (
+    (row.followStatus === 'pending_arrange' || row.followStatus === 'drafting') &&
+    row.archiveStatus !== 'archived'
+  )
+}
+```
+
+5. 强制改状态选项使用 `PL_PLAN_FOLLOW_STATUS_OPTIONS`。  
+6. `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**
+
+```bash
+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 清或带源状态。
+
+```typescript
+const showDraftButton = computed(() => {
+  if (!isEdit.value) return true
+  return loadedFollowStatus.value === 'drafting' || loadedFollowStatus.value === 'pending_arrange'
+})
+```
+
+Footer 在取消与创建/保存之间:
+
+```vue
+<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**
+
+```bash
+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: 后端单测**
+
+```bash
+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: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh backend
+./scripts/update-graph.sh frontend
+```
+
+- [ ] **Step 4: 若有图谱变更则提交**
+
+```bash
+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 接口 |