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

541 lines
17 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:** 在运输计划新增/编辑弹窗支持【暂存】(跟进状态 `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 接口 |