Also add the bx apply withdraw implementation plan. Co-authored-by: Cursor <cursoragent@cursor.com>
16 KiB
报销 / 备用金申请「撤回」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:
"""备用金撤回:仅申请人且审批中可回草稿。"""
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: 跑测确认失败
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,无发票释放):
@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 之间插入:
@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: 跑测确认通过
cd backend && source venv/bin/activate && pytest tests/test_bx_advance_withdraw.py -v
Expected: 4 passed
- Step 6: Commit
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 后增加:
withdraw(id: BxId) {
return bxRequest.post(`/advances/${id}/withdraw`)
},
(reimburseApi.withdraw 已存在,勿重复。)
- Step 2: 小程序 API
在 miniprogram/api/bx.js:
advanceApi 增加:
withdraw: (id) => bxRequest({ url: `/advances/${id}/withdraw`, method: 'POST' }),
reimburseApi 增加:
withdraw: (id) => bxRequest({ url: `/reimburses/${id}/withdraw`, method: 'POST' }),
- Step 3: Commit
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: 工具栏按钮
在「提交审批」与「查看审批流」之间插入:
<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 之后增加(互斥展示):
<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):
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
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: 导入
增加:
import { RefreshLeft } from '@element-plus/icons-vue' // 并入现有 icons 解构
import { useUserStore } from '@/store/modules/user'
增加:
const userStore = useUserStore()
function currentUserId() {
return String(userStore.info?.userId || userStore.info?.id || '')
}
- Step 2: 工具栏 + 详情
与 Task 3 对称:工具栏「撤回」按钮(位置同报销);详情 v-else-if="canWithdraw(viewData)" 区块。
确认文案改为:
'确定撤回该备用金申请?撤回后单据变为待提交,可修改后再提交。'
API 调用:advanceApi.withdraw(row.id)。
canWithdraw / handleToolbarWithdraw / handleViewWithdraw / handleWithdraw 逻辑同 Task 3(把 reimburseApi 换成 advanceApi,提示文案用备用金)。
列表行字段为 camelCase applicantId(AdvanceApplyOut 使用 to_camel)。
- Step 3: Commit
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: 用户与可撤回判断
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
const showActions = computed(() => {
if (!detail.value) return false
const status = detail.value.status
return status === 'draft' || status === 'rejected' || canWithdraw.value
})
- Step 3: 底栏模板
在 rejected 模板之后增加:
<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
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
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
git add miniprogram/pages/bx/advance/detail.vue
git commit -m "$(cat <<'EOF'
feat(miniprogram): add advance detail withdraw
EOF
)"
Task 7: 图谱更新与手工验收核对
Files: 无代码逻辑变更(图谱脚本)
- Step 1: 更新知识图谱
./scripts/update-graph.sh all
若脚本仅支持分端:
./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
- Step 2: 对照验收清单(手工)
按 spec「验收标准」逐条勾选:
- Web 报销:审批中 → 撤回 → 待提交 → 编辑再提交
- 非申请人按钮禁用;直调接口 400
- 报销撤回后发票额度回升
- Web 备用金对称
- 小程序两边详情可撤
- draft / rejected / approved 不可撤
- Step 3: Commit 图谱(若有变更)
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)。