Files
shanxia2.0/docs/superpowers/plans/2026-09-14-bx-apply-withdraw.md
T
wuzilongandCursor 07f45a1c92 fix(miniprogram): refresh detail after withdraw failure
Also add the bx apply withdraw implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 10:07:44 +08:00

16 KiB
Raw Blame History

报销 / 备用金申请「撤回」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「验收标准」逐条勾选:

  1. Web 报销:审批中 → 撤回 → 待提交 → 编辑再提交
  2. 非申请人按钮禁用;直调接口 400
  3. 报销撤回后发票额度回升
  4. Web 备用金对称
  5. 小程序两边详情可撤
  6. 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)。