fix(miniprogram): refresh detail after withdraw failure

Also add the bx apply withdraw implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-14 10:07:44 +08:00
1 parent 7a45df9de7
commit 07f45a1c92
3 files changed
+583 -4

No files matched your search

@@ -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
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1 || !canWithdraw(selectedRows[0])"
:loading="loadingMap.withdraw"
@click="handleToolbarWithdraw"
>
<el-icon><RefreshLeft /></el-icon>撤回
</el-button>
```
- [ ] **Step 3: 详情弹窗 hero 操作**
在现有 `v-if="isDraftEditable(viewData)"` 的 `dlg-view-hero-actions` **之后**增加(互斥展示):
```vue
<div v-else-if="canWithdraw(viewData)" class="dlg-view-hero-actions">
<el-button
class="btn-ghost"
:loading="loadingMap[`withdraw_${viewData.id}`]"
@click="handleViewWithdraw"
>
<el-icon><RefreshLeft /></el-icon>撤回
</el-button>
</div>
```
- [ ] **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
<template v-if="canWithdraw">
<button
class="btn btn-ghost"
style="flex: 1"
:disabled="actionLoading.withdraw"
@click="handleWithdraw"
>
撤回
</button>
</template>
```
`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)。
+2 -2
View File
@@ -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()
}
}
})
+2 -2
View File
@@ -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()
}
}
})