From c3e1de279399ab41acecc27f29c06a88b2cc05c2 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 29 Sep 2026 12:04:08 +0800 Subject: [PATCH] docs: add implementation plan for vehicle recharge apply withdraw Co-authored-by: Cursor --- ...9-29-pl-vehicle-recharge-apply-withdraw.md | 497 ++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-pl-vehicle-recharge-apply-withdraw.md diff --git a/docs/superpowers/plans/2026-09-29-pl-vehicle-recharge-apply-withdraw.md b/docs/superpowers/plans/2026-09-29-pl-vehicle-recharge-apply-withdraw.md new file mode 100644 index 000000000..abb8a5318 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-pl-vehicle-recharge-apply-withdraw.md @@ -0,0 +1,497 @@ +# ETC/加油充值申请 · 撤回 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:** 为 ETC/加油充值申请增加「审批中 → 草稿」撤回能力,Web 管理端与小程序均可由申请人本人操作,行为对齐报销申请撤回。 + +**Architecture:** 后端在 `pl_vehicle_recharge_service` 新增 `withdraw_apply`,挂 `POST .../vehicle-recharge-apply/{id}/withdraw`。状态 `in_approval` → `draft`,清空 `current_node`,保留审批记录与 `approval_round`。前端两端增加 `canWithdraw` 入口与 API 封装。 + +**Tech Stack:** FastAPI / SQLAlchemy / pytest;Vue 3 + Element Plus(web-admin);uni-app(miniprogram) + +**Spec:** `docs/superpowers/specs/2026-09-29-pl-vehicle-recharge-apply-withdraw-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `backend/app/services/pl_vehicle_recharge_service.py` | `withdraw_apply` 业务逻辑 | +| `backend/app/api/pl/vehicle_recharge.py` | HTTP 路由 | +| `backend/tests/test_pl_vehicle_recharge_workflow.py` | 撤回成功 / 权限 / 状态单测 | +| `web-admin/src/api/pl/index.ts` | `vehicleRechargeApplyApi.withdraw` | +| `web-admin/src/views/pl/vehicle-recharge-apply/index.vue` | 工具栏 + 查看弹窗撤回 | +| `miniprogram/api/pl.js` | `vehicleRechargeApplyApi.withdraw` | +| `miniprogram/pages/pl/vehicle-recharge-apply/list.vue` | 列表卡片撤回按钮 | + +--- + +### Task 1: 后端单测(先失败) + +**Files:** +- Modify: `backend/tests/test_pl_vehicle_recharge_workflow.py` + +- [ ] **Step 1: 在文件末尾追加三个测试** + +```python +def test_withdraw_in_approval_to_draft_keeps_round_and_approvals(monkeypatch): + from app.services import pl_vehicle_recharge_service as svc + + db = _make_session() + _seed_vehicle(db) + wf_id, node_gm, _node_fin = _seed_workflow(db) + db.add( + PlVehicleRechargeApply( + id="a-wd", + apply_no="SXSQWD001", + license_plate="鄂A00001", + recharge_type="etc", + apply_date=date.today(), + applicant_id="u-applicant", + status="in_approval", + wf_id=wf_id, + current_node=node_gm, + approval_round=1, + tenant_no="t1", + create_by="u-applicant", + create_dept_id="d1", + create_time=datetime.now(), + update_time=datetime.now(), + del_flag=0, + ) + ) + db.add( + PlVehicleRechargeApproval( + id="ap1", + apply_id="a-wd", + node_id=node_gm, + node_name="总经理审批", + approval_round=1, + approval_status="approved", + approver_id="u-gm", + approver_name="总经理", + approval_time=datetime.now(), + tenant_no="t1", + create_by="u-gm", + create_dept_id="d1", + create_time=datetime.now(), + update_time=datetime.now(), + del_flag=0, + ) + ) + db.commit() + _patch_scope(monkeypatch, svc) + + out = svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd") + assert out.status == "draft" + assert out.currentNode in (None, "") + row = db.query(PlVehicleRechargeApply).filter_by(id="a-wd").one() + assert row.status == "draft" + assert row.current_node is None + assert row.approval_round == 1 + assert ( + db.query(PlVehicleRechargeApproval) + .filter_by(apply_id="a-wd", del_flag=0) + .count() + == 1 + ) + + +def test_withdraw_rejects_non_applicant(monkeypatch): + from fastapi import HTTPException + from app.services import pl_vehicle_recharge_service as svc + + db = _make_session() + _seed_vehicle(db) + db.add( + PlVehicleRechargeApply( + id="a-wd2", + apply_no="SXSQWD002", + license_plate="鄂A00001", + recharge_type="etc", + apply_date=date.today(), + applicant_id="u-applicant", + status="in_approval", + tenant_no="t1", + create_by="u-applicant", + create_dept_id="d1", + create_time=datetime.now(), + update_time=datetime.now(), + del_flag=0, + ) + ) + db.commit() + _patch_scope(monkeypatch, svc) + + with pytest.raises(HTTPException) as ei: + svc.withdraw_apply(db, "t1", _user("u-other"), "a-wd2") + assert ei.value.status_code == 403 + + +def test_withdraw_rejects_non_in_approval(monkeypatch): + from fastapi import HTTPException + from app.services import pl_vehicle_recharge_service as svc + + db = _make_session() + _seed_vehicle(db) + db.add( + PlVehicleRechargeApply( + id="a-wd3", + apply_no="SXSQWD003", + license_plate="鄂A00001", + recharge_type="etc", + apply_date=date.today(), + applicant_id="u-applicant", + status="draft", + tenant_no="t1", + create_by="u-applicant", + create_dept_id="d1", + create_time=datetime.now(), + update_time=datetime.now(), + del_flag=0, + ) + ) + db.commit() + _patch_scope(monkeypatch, svc) + + with pytest.raises(HTTPException) as ei: + svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd3") + assert ei.value.status_code == 400 +``` + +若 `PlVehicleRechargeApproval` 构造字段与模型不一致,以 `backend/app/models/pl_models.py` 中该类字段为准微调(必填字段补齐即可)。 + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_in_approval_to_draft_keeps_round_and_approvals tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_rejects_non_applicant tests/test_pl_vehicle_recharge_workflow.py::test_withdraw_rejects_non_in_approval -v +``` + +Expected: FAIL(`withdraw_apply` 不存在或 AttributeError) + +- [ ] **Step 3: Commit 失败测试** + +```bash +git add backend/tests/test_pl_vehicle_recharge_workflow.py +git commit -m "$(cat <<'EOF' +test: add failing cases for vehicle recharge apply withdraw + +EOF +)" +``` + +--- + +### Task 2: 实现 `withdraw_apply` + 路由 + +**Files:** +- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(紧挨 `cancel_apply` 之后) +- Modify: `backend/app/api/pl/vehicle_recharge.py`(紧挨 `cancel_apply` 路由之后) + +- [ ] **Step 1: 实现 service** + +在 `cancel_apply` 函数后插入: + +```python +def withdraw_apply( + db: Session, + tenant_no: str, + user: DbUserContext, + apply_id: str, + *, + mine_vehicles: bool = False, +) -> VehicleRechargeApplyRead: + row = _get_apply_row( + db, tenant_no, apply_id, user, mine_vehicles=mine_vehicles + ) + if (row.applicant_id or "").strip() != (user.id or "").strip(): + raise HTTPException(status_code=403, detail="仅申请人可撤回") + if row.status != "in_approval": + raise HTTPException(status_code=400, detail="仅审批中的申请可撤回") + row.status = "draft" + row.current_node = None + row.update_by = user.id + row.update_time = datetime.now() + db.commit() + db.refresh(row) + return apply_to_read(row, db, include_detail=True) +``` + +说明:申请人校验**始终执行**(不依赖 `mine_vehicles`),与设计 D5 一致。`approval_round` / 审批行不改动。 + +- [ ] **Step 2: 挂路由** + +在 `cancel_apply` 路由后增加: + +```python +@apply_router.post("/{id}/withdraw", response_model=VehicleRechargeApplyRead) +def withdraw_apply( + id: str, + mine_vehicles: bool = Query(default=False, alias="mineVehicles"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleRechargeApplyRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.withdraw_apply( + db, tenant_no, user, id, mine_vehicles=mine_vehicles + ) +``` + +- [ ] **Step 3: 跑测确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py -v +``` + +Expected: 全部 PASS(含既有用例 + 三个撤回用例) + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/pl_vehicle_recharge_service.py backend/app/api/pl/vehicle_recharge.py backend/tests/test_pl_vehicle_recharge_workflow.py +git commit -m "$(cat <<'EOF' +feat(api): allow withdraw of in-approval vehicle recharge applies + +Applicants can return an in-approval apply to draft while keeping prior approval rows. +EOF +)" +``` + +--- + +### Task 3: Web API + 列表/查看撤回 + +**Files:** +- Modify: `web-admin/src/api/pl/index.ts` +- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue` + +- [ ] **Step 1: API 增加 withdraw** + +在 `vehicleRechargeApplyApi` 的 `cancel` 旁增加: + +```typescript + withdraw: (id: string) => + request.post(`/transport/vehicle-recharge-apply/${id}/withdraw`), +``` + +- [ ] **Step 2: 页面逻辑** + +1. 从 `@element-plus/icons-vue` 增加导入 `RefreshLeft`。 +2. 增加: + +```typescript +const withdrawLoadingMap = reactive>({}) + +function canWithdraw(row?: VehicleRechargeApply | null) { + if (!row) return false + return ( + row.status === 'in_approval' && + String(row.applicantId || '') === String(currentUserId.value || '') + ) +} + +async function handleWithdraw(row: VehicleRechargeApply) { + if (!canWithdraw(row)) { + ElMessage.warning('仅本人申请且审批中的单据可撤回') + return + } + try { + await ElMessageBox.confirm( + '确定撤回该充值申请?撤回后变为草稿,可修改后再提交。', + '撤回确认', + { + confirmButtonText: '确定撤回', + cancelButtonText: '取消', + type: 'warning' + } + ) + } catch { + return + } + withdrawLoadingMap[row.id] = true + try { + await vehicleRechargeApplyApi.withdraw(row.id) + ElMessage.success('已撤回') + await fetchData() + if (viewVisible.value && viewingRecord.value?.id === row.id) { + // 与页面现有查看刷新方式一致(重新 get 详情) + const detail = await vehicleRechargeApplyApi.get(row.id) + viewingRecord.value = (detail as any)?.data || detail + } + } catch (e: any) { + ElMessage.error(e?.message || '撤回失败') + } finally { + withdrawLoadingMap[row.id] = false + } +} + +function handleToolbarWithdraw() { + if (selectedRows.value.length !== 1) { + ElMessage.warning('请先勾选一条申请') + return + } + handleWithdraw(selectedRows.value[0]) +} +``` + +3. 工具栏「作废」按钮后增加: + +```vue + + 撤回 + +``` + +4. 查看弹窗 `#hero-actions`:在现有 `draft/rejected` 模板旁增加 `v-else-if="canWithdraw(viewingRecord)"` 分支,仅「撤回」按钮(对齐报销)。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/api/pl/index.ts web-admin/src/views/pl/vehicle-recharge-apply/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add withdraw action on vehicle recharge apply + +EOF +)" +``` + +--- + +### Task 4: 小程序 API + 列表撤回 + +**Files:** +- Modify: `miniprogram/api/pl.js` +- Modify: `miniprogram/pages/pl/vehicle-recharge-apply/list.vue` + +- [ ] **Step 1: API** + +在 `vehicleRechargeApplyApi.cancel` 旁增加: + +```javascript + withdraw: (id) => + db.post( + `/transport/vehicle-recharge-apply/${id}/withdraw`, + {}, + { params: { mineVehicles: true } } + ), +``` + +- [ ] **Step 2: 列表** + +1. 增加: + +```javascript +function canWithdraw(item) { + if (!item) return false + if (item.status !== 'in_approval') return false + return String(item.applicantId || '') === currentUserId() +} +``` + +2. 在卡片 `op-actions` 中,保留「复制发群」在最前;`canWrite` 之外改为: + +```vue + + + +``` + +3. 增加 handler: + +```javascript +function handleWithdraw(item) { + uni.showModal({ + title: '撤回确认', + content: '确定撤回该充值申请?撤回后变为草稿,可修改后再提交。', + confirmText: '确定撤回', + success: async (res) => { + if (!res.confirm) return + try { + await vehicleRechargeApplyApi.withdraw(item.id) + uni.showToast({ title: '已撤回', icon: 'success' }) + onRefresh() + } catch (e) { + // toasted by request layer + } + } + }) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add miniprogram/api/pl.js miniprogram/pages/pl/vehicle-recharge-apply/list.vue +git commit -m "$(cat <<'EOF' +feat(mini): add withdraw on ETC vehicle recharge apply list + +EOF +)" +``` + +--- + +### Task 5: 图谱更新 + 回归确认 + +**Files:** 无业务代码;运行脚本 + +- [ ] **Step 1: 后端回归** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_pl_vehicle_recharge_workflow.py tests/test_pl_vehicle_recharge_mine_vehicles.py -v +``` + +Expected: PASS + +- [ ] **Step 2: 更新知识图谱** + +```bash +./scripts/update-graph.sh backend +``` + +- [ ] **Step 3: 手动验收清单(实现者勾选)** + +- Web:本人审批中单可选中 → 撤回 → 状态草稿 → 可编辑再提交 +- Web:非本人审批中 / 待发放:撤回按钮禁用 +- 小程序:同上 +- 作废仍仅草稿/驳回 + +- [ ] **Step 4: Commit 图谱(若有变更)** + +```bash +git add backend/graphify-out graphify-out +git commit -m "$(cat <<'EOF' +chore: update graphify after vehicle recharge withdraw + +EOF +)" +``` + +仅在有实际 diff 时提交。 + +--- + +## Spec coverage check + +| 规格项 | 任务 | +|--------|------| +| D1 审批中→草稿 | Task 2 | +| D2 Web+小程序 | Task 3、4 | +| D3 保留审批记录不切轮次 | Task 1/2 断言 | +| D4 仅 `in_approval` | Task 1/2 | +| D5 仅申请人 | Task 1/2 + 前端 `canWithdraw` | +| D6 独立 withdraw API | Task 2 | +| 待发放不可撤 | Task 1 状态校验 + 前端条件 | +| 验收要点 | Task 5 清单 |