Преглед изворни кода

fix(miniprogram): refresh detail after withdraw failure

Also add the bx apply withdraw implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong пре 1 месец
родитељ
комит
07f45a1c92

+ 579 - 0
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
+          <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
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()
       }
     }
   })

+ 2 - 2
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()
       }
     }
   })