Prechádzať zdrojové kódy

docs: add design for bx apply withdraw (reimburse + advance)

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 mesiac pred
rodič
commit
ac713070ab

+ 147 - 0
docs/superpowers/specs/2026-09-14-bx-apply-withdraw-design.md

@@ -0,0 +1,147 @@
+# 报销 / 备用金申请「撤回」能力设计
+
+**日期**:2026-09-14  
+**状态**:已确认待实现  
+**范围**:Web 管理端报销申请 / 备用金申请 + 小程序报销 / 备用金详情
+
+## 背景
+
+【报销申请】中状态为「审批中」的单据当前不可编辑、不可再次提交。用户希望:**自己作为申请人发起的审批中单据,可自行撤回 → 回草稿 → 修改 → 再提交**。
+
+报销后端已具备撤回接口与发票额度释放逻辑;Web 申请页与小程序详情未暴露入口。备用金无对称的 withdraw API。
+
+## 决策摘要
+
+| 项 | 决策 |
+|----|------|
+| 端范围 | Web 报销申请 + Web 备用金申请 + 小程序(报销/备用金详情) |
+| 权限 | 仅申请人(`applicant_id`)可撤回 |
+| 时机 | 只要状态为 `pending`(审批中)即可,不区分已过几级审批 |
+| 交互 | 仅单条:Web 工具栏(选中 1 条)+ 详情;小程序主要在详情底栏 |
+| 撤回原因 | 不要求 |
+| 批量撤回 | 不做 |
+| 方案 | 复用现有报销 withdraw,补齐备用金后端 + 各端 UI |
+
+## 行为与状态
+
+### 可撤回条件(同时满足)
+
+1. `status === 'pending'`
+2. 当前登录用户 id === 单据 `applicant_id`(报销人 / 借款人)
+
+代申请场景下:填写人若不是申请人,**不能**撤回(与现有报销后端一致)。
+
+### 撤回结果
+
+1. 状态:`pending` → `draft`(待提交)
+2. 清空 `current_node`
+3. 报销:调用既有 `InvoiceLedgerService.release_reimburse`,退回发票占用额度
+4. 备用金:无发票额度逻辑,仅回草稿并清节点
+5. **保留**既有审批历史记录(便于审计;再次提交后进入新一轮)
+6. 不写入「撤回原因」字段
+
+### 撤回后
+
+按现有草稿逻辑:可编辑、可删除、可再次提交审批。前端沿用 `isDraftEditable`(`draft` / `rejected`)门禁。
+
+## 入口与交互
+
+### Web 管理端
+
+页面:
+
+- `/#/bx/reimburse/apply` → `web-admin/src/views/bx/reimburse/apply/index.vue`
+- `/#/bx/advance/apply` → `web-admin/src/views/bx/advance/apply/index.vue`
+
+| 位置 | 行为 |
+|------|------|
+| 列表工具栏 | 「撤回」按钮;仅当恰好选中 1 条且 `canWithdraw(row)` 时可用 |
+| 详情弹窗 | `canWithdraw(viewData)` 时展示「撤回」;与草稿态的编辑/提交互斥 |
+| 确认框 | 报销:说明撤回后将退回发票额度;备用金:说明撤回后可修改再提交 |
+
+前端判断:
+
+```ts
+function canWithdraw(row?: { status?: string; applicantId?: string }) {
+  if (!row) return false
+  return (
+    row.status === 'pending' &&
+    String(row.applicantId || '') === currentUserId()
+  )
+}
+```
+
+图标建议:`RefreshLeft`(与运费结算/合同变更等撤回一致)。
+
+### 小程序
+
+页面:
+
+- `miniprogram/pages/bx/reimburse/detail.vue`
+- `miniprogram/pages/bx/advance/detail.vue`
+
+| 位置 | 行为 |
+|------|------|
+| 详情底栏 | `pending` 且当前用户为申请人时显示「撤回」;`showActions` 需覆盖 `pending` |
+| 确认 | `uni.showModal`;成功后刷新详情 |
+| 列表页 | 不加撤回按钮 |
+
+参考已有移动端备份实现:`bak/bx-mobile/src/views/reimburse/ReimburseDetailPage.vue`(确认文案含发票额度退回)。
+
+## 后端与接口
+
+### 报销(已有,本期原则上不改业务规则)
+
+- `POST /api/bx/reimburses/{id}/withdraw`
+- Service:`ReimburseService.withdraw`
+- 校验:单据存在(数据权限内)+ `applicant_id == user.id` + `status == pending`
+- 失败:HTTP 400,`报销单不存在、非本人申请或当前状态不可撤回`
+- Web API 已封装:`reimburseApi.withdraw`
+
+### 备用金(本期新增,对齐报销)
+
+- Service:`AdvanceService.withdraw`  
+  - 同上申请人 + `pending` 校验  
+  - `status = draft`,`current_node = None`  
+  - **不**调用发票账本
+- 路由:`POST /api/bx/advances/{id}/withdraw`  
+  - 注册于 `backend/app/api/bx/advance.py`(与 submit/approve 同级)
+- Web:`advanceApi.withdraw(id)`
+- 小程序:`miniprogram/api/bx.js` 中 `advanceApi.withdraw`、`reimburseApi.withdraw`
+
+### 错误与并发
+
+- 前端禁用非可撤单据;后端仍做硬校验(防绕过)
+- 若撤回瞬间审批人已通过末级导致状态非 `pending`:返回 400,前端提示失败并刷新列表/详情
+
+## 明确不做(本期)
+
+- 撤回原因录入
+- 批量撤回
+- 在审批历史中单独插入「撤回」节点(历史原记录保留即可)
+- 列表页(小程序)撤回入口
+- 修改报销既有 withdraw 的权限模型(保持仅申请人)
+
+## 实现落点(文件级)
+
+| 层 | 文件 |
+|----|------|
+| Backend | `backend/app/services/bx/advance.py`(新增 withdraw) |
+| Backend | `backend/app/api/bx/advance.py`(新增路由) |
+| Web API | `web-admin/src/api/bx/index.ts`(advanceApi.withdraw) |
+| Web UI | `web-admin/src/views/bx/reimburse/apply/index.vue` |
+| Web UI | `web-admin/src/views/bx/advance/apply/index.vue` |
+| Mini API | `miniprogram/api/bx.js` |
+| Mini UI | `miniprogram/pages/bx/reimburse/detail.vue` |
+| Mini UI | `miniprogram/pages/bx/advance/detail.vue` |
+
+可选:为 `AdvanceService.withdraw` 补最小单测(权限/状态门禁);报销若已有覆盖可只测备用金。
+
+## 验收标准
+
+1. 申请人在 Web 报销申请页对「审批中」单条可撤回 → 变为待提交 → 可编辑再提交。
+2. 非申请人无法撤回(按钮禁用;直调接口 400)。
+3. 撤回后发票额度回升(报销);备用金无额度副作用。
+4. Web 备用金申请页行为与报销对称。
+5. 小程序报销/备用金详情底栏可撤回,成功后详情变为可提交草稿态。
+6. 已审批通过 / 已驳回 / 草稿 单据不可撤回。