diff --git a/docs/superpowers/plans/2026-09-14-bx-apply-withdraw.md b/docs/superpowers/plans/2026-09-14-bx-apply-withdraw.md new file mode 100644 index 000000000..574716136 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-bx-apply-withdraw.md @@ -0,0 +1,579 @@ +# 报销 / 备用金申请「撤回」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:** 申请人可将「审批中」的报销/备用金申请单撤回为草稿,再编辑并重新提交;覆盖 Web 申请页与小程序详情。 + +**Architecture:** 报销复用已有 `POST /reimburses/{id}/withdraw`;备用金按同规则新增 `AdvanceService.withdraw` + 路由。各端仅暴露单条撤回入口(工具栏选中 1 条 / 详情),前端用 `canWithdraw`(`pending` + `applicantId === 当前用户`)门禁,后端仍硬校验。 + +**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Element Plus(web-admin);uni-app(miniprogram) + +**Spec:** `docs/superpowers/specs/2026-09-14-bx-apply-withdraw-design.md` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `backend/app/services/bx/advance.py` | 新增 `withdraw`:申请人 + pending → draft,清 `current_node` | +| `backend/app/api/bx/advance.py` | 新增 `POST /{advance_id}/withdraw` | +| `backend/tests/test_bx_advance_withdraw.py` | 用 mock 测门禁与状态回写 | +| `web-admin/src/api/bx/index.ts` | `advanceApi.withdraw` | +| `web-admin/src/views/bx/reimburse/apply/index.vue` | 工具栏 + 详情撤回 | +| `web-admin/src/views/bx/advance/apply/index.vue` | 同上(对称) | +| `miniprogram/api/bx.js` | `reimburseApi.withdraw` / `advanceApi.withdraw` | +| `miniprogram/pages/bx/reimburse/detail.vue` | 底栏撤回 | +| `miniprogram/pages/bx/advance/detail.vue` | 底栏撤回 | + +--- + +### Task 1: 备用金 withdraw 后端(TDD) + +**Files:** +- Create: `backend/tests/test_bx_advance_withdraw.py` +- Modify: `backend/app/services/bx/advance.py`(在 `reject` 方法之后、`_approval_record_dict` 之前插入) +- Modify: `backend/app/api/bx/advance.py`(在 `reject_advance` 与 `delete_advance` 之间插入路由) + +- [ ] **Step 1: 写失败用例(mock Service 行为)** + +创建 `backend/tests/test_bx_advance_withdraw.py`: + +```python +"""备用金撤回:仅申请人且审批中可回草稿。""" +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from app.services.bx.advance import AdvanceService + + +def _user(uid="u1"): + return SimpleNamespace(id=uid, name="张三", dept_id="d1") + + +def _apply(**kwargs): + base = { + "id": "a1", + "applicant_id": "u1", + "status": "pending", + "current_node": "一级审批", + "update_by": None, + } + base.update(kwargs) + return SimpleNamespace(**base) + + +@patch.object(AdvanceService, "_tenant_no", return_value="T1") +@patch.object(AdvanceService, "get_by_id") +def test_withdraw_pending_by_applicant(mock_get, _tenant): + apply_obj = _apply() + mock_get.return_value = apply_obj + db = MagicMock() + out = AdvanceService.withdraw(db, "a1", _user("u1")) + assert out is apply_obj + assert apply_obj.status == "draft" + assert apply_obj.current_node is None + assert apply_obj.update_by == "u1" + db.flush.assert_called_once() + + +@patch.object(AdvanceService, "_tenant_no", return_value="T1") +@patch.object(AdvanceService, "get_by_id") +def test_withdraw_rejects_non_applicant(mock_get, _tenant): + mock_get.return_value = _apply(applicant_id="u1") + assert AdvanceService.withdraw(MagicMock(), "a1", _user("u2")) is None + + +@patch.object(AdvanceService, "_tenant_no", return_value="T1") +@patch.object(AdvanceService, "get_by_id") +def test_withdraw_rejects_non_pending(mock_get, _tenant): + mock_get.return_value = _apply(status="draft") + assert AdvanceService.withdraw(MagicMock(), "a1", _user("u1")) is None + + +@patch.object(AdvanceService, "_tenant_no", return_value="T1") +@patch.object(AdvanceService, "get_by_id") +def test_withdraw_missing_returns_none(mock_get, _tenant): + mock_get.return_value = None + assert AdvanceService.withdraw(MagicMock(), "a1", _user("u1")) is None +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_bx_advance_withdraw.py -v +``` + +Expected: FAIL(`AdvanceService` 无 `withdraw` 属性) + +- [ ] **Step 3: 实现 `AdvanceService.withdraw`** + +在 `backend/app/services/bx/advance.py` 的 `reject` 方法结束后插入(对齐 `ReimburseService.withdraw`,无发票释放): + +```python + @staticmethod + def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxAdvanceApply]: + tenant_no = AdvanceService._tenant_no(db, user) + apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user) + if not apply_obj: + return None + if apply_obj.applicant_id != user.id: + return None + if apply_obj.status != "pending": + return None + apply_obj.status = "draft" + apply_obj.current_node = None + apply_obj.update_by = user.id + db.flush() + return apply_obj +``` + +- [ ] **Step 4: 注册 API 路由** + +在 `backend/app/api/bx/advance.py` 的 `reject_advance` 与 `delete_advance` 之间插入: + +```python +@router.post("/{advance_id}/withdraw", response_model=ApiResponse[AdvanceApplyOut]) +def withdraw_advance( + advance_id: str, + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + advance = AdvanceService.withdraw(db, advance_id, user) + if not advance: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="备用金申请不存在、非本人申请或当前状态不可撤回", + ) + db.commit() + db.refresh(advance) + return ApiResponse(data=AdvanceApplyOut.model_validate(advance)) +``` + +对照报销路由:`backend/app/api/bx/reimburse.py` 中 `withdraw_reimburse`(约 766–781 行)。 + +- [ ] **Step 5: 跑测确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_bx_advance_withdraw.py -v +``` + +Expected: 4 passed + +- [ ] **Step 6: Commit** + +```bash +git add backend/tests/test_bx_advance_withdraw.py backend/app/services/bx/advance.py backend/app/api/bx/advance.py +git commit -m "$(cat <<'EOF' +feat(bx): add advance apply withdraw API + +EOF +)" +``` + +--- + +### Task 2: Web / 小程序 API 客户端 + +**Files:** +- Modify: `web-admin/src/api/bx/index.ts`(`advanceApi`,在 `submit` 后) +- Modify: `miniprogram/api/bx.js`(`advanceApi` 与 `reimburseApi`) + +- [ ] **Step 1: Web `advanceApi.withdraw`** + +在 `web-admin/src/api/bx/index.ts` 的 `advanceApi.submit` 后增加: + +```ts + withdraw(id: BxId) { + return bxRequest.post(`/advances/${id}/withdraw`) + }, +``` + +(`reimburseApi.withdraw` 已存在,勿重复。) + +- [ ] **Step 2: 小程序 API** + +在 `miniprogram/api/bx.js`: + +`advanceApi` 增加: + +```js + withdraw: (id) => bxRequest({ url: `/advances/${id}/withdraw`, method: 'POST' }), +``` + +`reimburseApi` 增加: + +```js + withdraw: (id) => bxRequest({ url: `/reimburses/${id}/withdraw`, method: 'POST' }), +``` + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/api/bx/index.ts miniprogram/api/bx.js +git commit -m "$(cat <<'EOF' +feat(bx): wire advance/reimburse withdraw API clients + +EOF +)" +``` + +--- + +### Task 3: Web 报销申请页撤回入口 + +**Files:** +- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue` + +- [ ] **Step 1: 导入图标** + +在 `@element-plus/icons-vue` 导入列表增加 `RefreshLeft`。 + +- [ ] **Step 2: 工具栏按钮** + +在「提交审批」与「查看审批流」之间插入: + +```vue + + 撤回 + +``` + +- [ ] **Step 3: 详情弹窗 hero 操作** + +在现有 `v-if="isDraftEditable(viewData)"` 的 `dlg-view-hero-actions` **之后**增加(互斥展示): + +```vue +
+ + 撤回 + +
+``` + +- [ ] **Step 4: 脚本逻辑** + +在 `isDraftEditable` 附近增加(复用已有 `currentUserId`): + +```ts +function canWithdraw(row?: any) { + if (!row) return false + return ( + row.status === 'pending' && + String(row.applicantId || '') === currentUserId() + ) +} + +async function handleToolbarWithdraw() { + if (selectedRows.value.length !== 1) { + ElMessage.warning('请先勾选一条报销单') + return + } + const row = selectedRows.value[0] + if (!canWithdraw(row)) { + ElMessage.warning('仅本人申请且审批中的报销单可撤回') + return + } + await handleWithdraw(row) +} + +async function handleViewWithdraw() { + if (!viewData.value?.id) return + await handleWithdraw(viewData.value) + try { + const res = await reimburseApi.getById(viewData.value.id) + viewData.value = res.data || res + } catch { + dialogs.view = false + } +} + +async function handleWithdraw(row: any) { + try { + await ElMessageBox.confirm( + '确定撤回该报销单?撤回后将退回发票额度,单据变为待提交,可修改后再提交。', + '撤回确认', + { + confirmButtonText: '确定撤回', + cancelButtonText: '取消', + type: 'warning' + } + ) + loadingMap.withdraw = true + loadingMap[`withdraw_${row.id}`] = true + await reimburseApi.withdraw(row.id) + ElMessage.success('已撤回') + await refreshList() + } catch (e: any) { + if (e !== 'cancel') { + ElMessage.error(e?.message || '撤回失败') + await refreshList() + } + } finally { + loadingMap.withdraw = false + loadingMap[`withdraw_${row.id}`] = false + } +} +``` + +确认 `loadingMap` 为可动态赋值的 reactive 对象(页面已有 `loadingMap.submit` 等用法)。 + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/views/bx/reimburse/apply/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add withdraw on reimburse apply page + +EOF +)" +``` + +--- + +### Task 4: Web 备用金申请页撤回入口 + +**Files:** +- Modify: `web-admin/src/views/bx/advance/apply/index.vue` + +- [ ] **Step 1: 导入** + +增加: + +```ts +import { RefreshLeft } from '@element-plus/icons-vue' // 并入现有 icons 解构 +import { useUserStore } from '@/store/modules/user' +``` + +增加: + +```ts +const userStore = useUserStore() + +function currentUserId() { + return String(userStore.info?.userId || userStore.info?.id || '') +} +``` + +- [ ] **Step 2: 工具栏 + 详情** + +与 Task 3 对称:工具栏「撤回」按钮(位置同报销);详情 `v-else-if="canWithdraw(viewData)"` 区块。 + +确认文案改为: + +```ts +'确定撤回该备用金申请?撤回后单据变为待提交,可修改后再提交。' +``` + +API 调用:`advanceApi.withdraw(row.id)`。 + +`canWithdraw` / `handleToolbarWithdraw` / `handleViewWithdraw` / `handleWithdraw` 逻辑同 Task 3(把 `reimburseApi` 换成 `advanceApi`,提示文案用备用金)。 + +列表行字段为 camelCase `applicantId`(`AdvanceApplyOut` 使用 `to_camel`)。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/bx/advance/apply/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add withdraw on advance apply page + +EOF +)" +``` + +--- + +### Task 5: 小程序报销详情撤回 + +**Files:** +- Modify: `miniprogram/pages/bx/reimburse/detail.vue` + +- [ ] **Step 1: 用户与可撤回判断** + +```js +import { useUserStore } from '@/stores/user' + +const userStore = useUserStore() + +function currentUserId() { + const u = userStore.userInfo || {} + return String(u.id || u.userId || '') +} + +const canWithdraw = computed(() => { + const d = detail.value + if (!d) return false + return d.status === 'pending' && String(d.applicantId || '') === currentUserId() +}) +``` + +- [ ] **Step 2: 扩展 `showActions`** + +```js +const showActions = computed(() => { + if (!detail.value) return false + const status = detail.value.status + return status === 'draft' || status === 'rejected' || canWithdraw.value +}) +``` + +- [ ] **Step 3: 底栏模板** + +在 `rejected` 模板之后增加: + +```vue + +``` + +`actionLoading` 增加 `withdraw: false`。 + +- [ ] **Step 4: `handleWithdraw`** + +```js +async function handleWithdraw() { + if (!detail.value?.id) return + const { confirm } = await new Promise((resolve) => { + uni.showModal({ + title: '撤回确认', + content: '确定撤回该报销单?撤回后将退回发票额度。', + success: resolve, + fail: () => resolve({ confirm: false }) + }) + }) + if (!confirm) return + actionLoading.withdraw = true + try { + await reimburseApi.withdraw(detail.value.id) + uni.showToast({ title: '已撤回', icon: 'success' }) + await loadDetail() + } catch (e) { + uni.showToast({ title: e?.message || '撤回失败', icon: 'none' }) + } finally { + actionLoading.withdraw = false + } +} +``` + +(若页面已有封装 `showModal` 工具,优先复用;否则用上式。) + +参考:`bak/bx-mobile/src/views/reimburse/ReimburseDetailPage.vue` 的 `handleWithdraw`。 + +- [ ] **Step 5: Commit** + +```bash +git add miniprogram/pages/bx/reimburse/detail.vue +git commit -m "$(cat <<'EOF' +feat(miniprogram): add reimburse detail withdraw + +EOF +)" +``` + +--- + +### Task 6: 小程序备用金详情撤回 + +**Files:** +- Modify: `miniprogram/pages/bx/advance/detail.vue` + +- [ ] **Step 1–4:** 与 Task 5 对称: + +- `useUserStore` + `canWithdraw` + 扩展 `showActions` +- 底栏「撤回」按钮 +- `handleWithdraw` 调用 `advanceApi.withdraw` +- 确认文案:`确定撤回该备用金申请?撤回后可修改再提交。` + +- [ ] **Step 5: Commit** + +```bash +git add miniprogram/pages/bx/advance/detail.vue +git commit -m "$(cat <<'EOF' +feat(miniprogram): add advance detail withdraw + +EOF +)" +``` + +--- + +### Task 7: 图谱更新与手工验收核对 + +**Files:** 无代码逻辑变更(图谱脚本) + +- [x] **Step 1: 更新知识图谱** + +```bash +./scripts/update-graph.sh all +``` + +若脚本仅支持分端: + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +``` + +- [x] **Step 2: 对照验收清单(手工)** + +按 spec「验收标准」逐条勾选: + +1. Web 报销:审批中 → 撤回 → 待提交 → 编辑再提交 +2. 非申请人按钮禁用;直调接口 400 +3. 报销撤回后发票额度回升 +4. Web 备用金对称 +5. 小程序两边详情可撤 +6. draft / rejected / approved 不可撤 + +- [x] **Step 3: Commit 图谱(若有变更)** + +```bash +git add backend/graphify-out web-admin/graphify-out graphify-out 2>/dev/null || true +git status +# 若有图谱文件变更再 commit +git commit -m "$(cat <<'EOF' +chore: update graphify after bx apply withdraw + +EOF +)" +``` + +--- + +## Spec coverage self-check + +| Spec 要求 | Task | +|-----------|------| +| 报销复用既有 withdraw | Task 3、5(API 已有) | +| 备用金新增 withdraw + 路由 | Task 1 | +| 仅申请人 + pending | Task 1 测试 + 各端 `canWithdraw` | +| 回 draft、清 current_node | Task 1 | +| 报销释放发票额度 | 既有 Service;Task 3/5 文案提示 | +| Web 工具栏单条 + 详情 | Task 3、4 | +| 小程序详情底栏 | Task 5、6 | +| 不做批量/原因/列表撤回 | 未列入任何 Task | +| API 客户端 | Task 2 | + +## 执行说明 + +- 每 Task 独立可测;先后端再 Web 再小程序。 +- 提交信息用 Conventional Commits;勿提交 `backend/app.pid`。 +- 改完代码后按 AGENTS.md 更新 graphify(Task 7)。 diff --git a/miniprogram/pages/bx/advance/detail.vue b/miniprogram/pages/bx/advance/detail.vue index 3290cd056..4dd6f2af9 100644 --- a/miniprogram/pages/bx/advance/detail.vue +++ b/miniprogram/pages/bx/advance/detail.vue @@ -371,11 +371,11 @@ function handleWithdraw() { try { await advanceApi.withdraw(detail.value.id) uni.showToast({ title: '已撤回', icon: 'success' }) - await loadDetail() } catch (e) { - // toasted + // toasted by bxRequest } finally { actionLoading.withdraw = false + await loadDetail() } } }) diff --git a/miniprogram/pages/bx/reimburse/detail.vue b/miniprogram/pages/bx/reimburse/detail.vue index e65913922..715ca8aac 100644 --- a/miniprogram/pages/bx/reimburse/detail.vue +++ b/miniprogram/pages/bx/reimburse/detail.vue @@ -415,11 +415,11 @@ function handleWithdraw() { try { await reimburseApi.withdraw(detail.value.id) uni.showToast({ title: '已撤回', icon: 'success' }) - await loadDetail() } catch (e) { - // toasted + // toasted by bxRequest } finally { actionLoading.withdraw = false + await loadDetail() } } })