Jelajahi Sumber

docs: add plan for allowing negative invoice balance on reimburse submit

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 bulan lalu
induk
melakukan
4f75bc40b9

+ 339 - 0
docs/superpowers/plans/2026-08-12-reimburse-allow-negative-invoice-balance.md

@@ -0,0 +1,339 @@
+# 报销提交允许发票夹余额为负 — 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:** 去掉报销提交时「发票夹余额不足不可提交」的硬门槛;提交仍全额写 `debit_reimburse`,允许余额为负;Web 与 bx-mobile 改为弱提示。
+
+**Architecture:** 后端仅删除 `debit_reimburse` 内余额不足抛错;前端删除提交前拦截与「将被拦截/无法提交」文案,保留余额对照与弱提示。扣/退时机、删票保护、幂等逻辑不变。
+
+**Tech Stack:** FastAPI + SQLAlchemy;Vue 3(web-admin Element Plus / bx-mobile Vant);pytest
+
+**Spec:** `docs/superpowers/specs/2026-08-12-reimburse-allow-negative-invoice-balance-design.md`
+
+---
+
+## 文件结构
+
+| 操作 | 文件 | 职责 |
+|------|------|------|
+| Modify | `backend/app/services/bx/invoice_ledger.py` | `debit_reimburse` 去掉余额不足拦截 |
+| Modify | `backend/tests/test_bx_invoice_ledger.py` | 补充「占用可超总额 → 余额为负」单测 |
+| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 弱提示 + 去掉提交拦截 |
+| Modify | `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue` | 同上 |
+| Modify | `docs/business-inventory/03-报销业务明细.md` | 额度规则与流程图文案 |
+
+**不变:** `assert_can_delete_invoice`、`release_reimburse`、submit 幂等、关联发票可选。
+
+---
+
+### Task 1: 后端 — 允许超额占用
+
+**Files:**
+- Modify: `backend/tests/test_bx_invoice_ledger.py`
+- Modify: `backend/app/services/bx/invoice_ledger.py`(约 L226–232)
+
+- [ ] **Step 1: 写失败/文档化用例 — 余额可为负**
+
+在 `backend/tests/test_bx_invoice_ledger.py` 末尾追加:
+
+```python
+def test_summarize_allows_negative_balance():
+    """提交占用可超过已上传总额,余额可为负(补票后再回升)。"""
+    rows = [
+        {"entry_type": ENTRY_CREDIT_UPLOAD, "amount": Decimal("100")},
+        {"entry_type": ENTRY_DEBIT_REIMBURSE, "amount": Decimal("300")},
+    ]
+    s = summarize_entries(rows)
+    assert s["total_amount"] == Decimal("100")
+    assert s["used_amount"] == Decimal("300")
+    assert s["balance"] == Decimal("-200")
+```
+
+- [ ] **Step 2: 跑单测确认通过(公式本就可为负)**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_bx_invoice_ledger.py::test_summarize_allows_negative_balance -v
+```
+
+Expected: PASS
+
+- [ ] **Step 3: 去掉 `debit_reimburse` 余额不足抛错**
+
+在 `backend/app/services/bx/invoice_ledger.py` 的 `debit_reimburse` 中,删除余额校验与未再使用的局部变量。将:
+
+```python
+        totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
+        balance = totals["balance"]
+        if balance < amt:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"发票额度不足(余额 {balance},本次 {amt})",
+            )
+        entry = InvoiceLedgerService.add_entry(
+```
+
+改为直接:
+
+```python
+        entry = InvoiceLedgerService.add_entry(
+```
+
+保留:`amt <= 0` 校验、`_lock_summary`、`add_entry`、`refresh_summary`。
+
+- [ ] **Step 4: 跑完整 ledger 单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_bx_invoice_ledger.py -v
+```
+
+Expected: 全部 PASS(含既有 3 个 + 新增 1 个)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/bx/invoice_ledger.py backend/tests/test_bx_invoice_ledger.py
+git commit -m "$(cat <<'EOF'
+fix(bx): allow reimburse submit when invoice wallet balance is insufficient
+
+Drop the hard balance check in debit_reimburse so occupancy can exceed uploaded invoices; balance may go negative until later top-ups.
+EOF
+)"
+```
+
+---
+
+### Task 2: Web 报销申请 — 弱提示、不拦截
+
+**Files:**
+- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
+
+- [ ] **Step 1: 改弹窗余额弱提示文案**
+
+将模板中:
+
+```vue
+          <span v-if="Number(walletBalance) < Number(detailTotalAmount)" class="invoice-balance-warn">
+            (余额不足,提交时将被拦截)
+          </span>
+```
+
+改为:
+
+```vue
+          <span v-if="Number(walletBalance) < Number(detailTotalAmount)" class="invoice-balance-warn">
+            (余额不足,提交后余额将为负,请后续补发票)
+          </span>
+```
+
+- [ ] **Step 2: 去掉工具栏批量提交的余额拦截**
+
+在 `handleToolbarSubmit` 中删除整段「按申请人分别校验发票余额」逻辑(约从注释 `// 按申请人分别校验发票余额` 到 `for` 循环结束的 `return`),保留其后的 `loadingMap.submit = true` 与 `reimburseApi.submit` 调用。
+
+删除后,`validTargets` 校验与 skip 提示之后应直接进入:
+
+```typescript
+  loadingMap.submit = true
+  try {
+    const results = await Promise.allSettled(validTargets.map((row) => reimburseApi.submit(row.id)))
+```
+
+若删除后 `refreshWalletBalance` 仅仍被弹窗打开/换申请人使用,保留该函数;不要误删 `refreshWalletBalance` 定义。
+
+- [ ] **Step 3: 去掉行内提交的余额拦截**
+
+将 `handleSubmit` 从:
+
+```typescript
+async function handleSubmit(row: any) {
+  await refreshWalletBalance(String(row.applicantId || currentUserId()))
+  if (Number(walletBalance.value) < Number(row.amount || 0)) {
+    ElMessage.warning(
+      `发票额度不足(余额 ${formatMoneyYuan(walletBalance.value)},本次 ${formatMoneyYuan(row.amount)})`
+    )
+    return
+  }
+  loadingMap[`submit_${row.id}`] = true
+```
+
+改为:
+
+```typescript
+async function handleSubmit(row: any) {
+  loadingMap[`submit_${row.id}`] = true
+```
+
+- [ ] **Step 4: 静态检查 — 无残留拦截文案**
+
+```bash
+rg -n '发票额度不足|提交时将被拦截' web-admin/src/views/bx/reimburse/apply/index.vue
+```
+
+Expected: 无匹配(弱提示「提交后余额将为负」可存在)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/views/bx/reimburse/apply/index.vue
+git commit -m "$(cat <<'EOF'
+fix(web-admin): allow reimburse submit with insufficient invoice balance
+
+Replace blocking tip with a soft warning and remove client-side submit guards.
+EOF
+)"
+```
+
+---
+
+### Task 3: bx-mobile 报销申请 — 对齐 Web
+
+**Files:**
+- Modify: `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue`
+
+- [ ] **Step 1: 改余额区弱提示**
+
+将:
+
+```vue
+            <span v-if="balanceInsufficient" class="invoice-balance-tip--warn">(余额不足,无法提交)</span>
+```
+
+改为:
+
+```vue
+            <span v-if="balanceInsufficient" class="invoice-balance-tip--warn">(余额不足,提交后余额将为负,请后续补发票)</span>
+```
+
+保留 `balanceInsufficient` computed(仅用于展示弱提示)。
+
+- [ ] **Step 2: 保存成功对话框 — 统一草稿文案**
+
+将:
+
+```typescript
+      message: balanceInsufficient.value
+        ? '已保存为草稿。发票余额不足,请先充值发票夹后再提交。'
+        : '报销单已保存为草稿',
+```
+
+改为:
+
+```typescript
+      message: '报销单已保存为草稿',
+```
+
+- [ ] **Step 3: 确认「提交审批」时不再因余额 return**
+
+将 `.then(() => { ... })` 内:
+
+```typescript
+        if (balanceInsufficient.value) {
+          showToast('余额不足,无法提交')
+          return
+        }
+        reimburseApi
+          .submit(id, invoicePayload)
+```
+
+改为直接调用 submit(无余额分支):
+
+```typescript
+        reimburseApi
+          .submit(id, invoicePayload)
+```
+
+- [ ] **Step 4: 静态检查**
+
+```bash
+rg -n '无法提交|请先充值发票夹|余额不足,无法提交' bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
+```
+
+Expected: 无匹配
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
+git commit -m "$(cat <<'EOF'
+fix(bx-mobile): allow reimburse submit with insufficient invoice balance
+
+Align soft tip and submit flow with web-admin; no longer block on wallet balance.
+EOF
+)"
+```
+
+---
+
+### Task 4: 业务清单文档
+
+**Files:**
+- Modify: `docs/business-inventory/03-报销业务明细.md`
+
+- [ ] **Step 1: 更新额度规则段(约 L128)**
+
+将:
+
+```markdown
+**额度规则**:提交时按报销合计扣减本人发票余额;余额不足拒绝提交;审批驳回或本人撤回时退回额度。发票上传在「我的发票夹 / 发票夹管理」,申请单不再本地上传发票。
+```
+
+改为:
+
+```markdown
+**额度规则**:提交时按报销合计扣减本人发票余额(全额占用,余额允许为负;不足不拦截,可后续在发票夹补票回升);审批驳回或本人撤回时退回额度。发票上传在「我的发票夹 / 发票夹管理」,申请单不再本地上传发票。详见 `docs/superpowers/specs/2026-08-12-reimburse-allow-negative-invoice-balance-design.md`。
+```
+
+- [ ] **Step 2: 更新流程图文案(约 L246)**
+
+将:
+
+```text
+    → 提交(余额 ≥ 合计才可;扣减发票余额)
+```
+
+改为:
+
+```text
+    → 提交(全额扣减发票余额,可为负;不足不拦截)
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/business-inventory/03-报销业务明细.md
+git commit -m "$(cat <<'EOF'
+docs(bx): document that reimburse submit may leave invoice balance negative
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 手工验收清单(实现后勾选)
+
+不写自动化 E2E;由实现者或 QA 在本地联调:
+
+- [ ] **Step 1: 准备数据** — 某用户发票夹余额小于拟报销合计(或余额为 0)。
+- [ ] **Step 2: Web** — `/#/bx/reimburse/apply` 新建草稿,合计 > 余额;见弱提示;保存成功;提交审批成功;「我的发票夹」余额变为负或更低;流水有 `debit_reimburse`。
+- [ ] **Step 3: bx-mobile** — 同上路径提交成功,无「无法提交」toast。
+- [ ] **Step 4: 驳回或撤回** — 余额回升。
+- [ ] **Step 5: 删票** — 占用后余额不足时删票仍被拒绝。
+- [ ] **Step 6: 补票** — 上传发票后负余额回升。
+
+(本 Task 无代码 commit;验收通过后可将 design 状态改为「已实现」可选。)
+
+---
+
+## Spec 覆盖自检
+
+| Spec 项 | Task |
+|---------|------|
+| D1 全额占用、余额可为负 | Task 1 |
+| D2 全端一致(backend + web + bx-mobile) | Task 1–3 |
+| D3 弱提示文案 | Task 2–3 |
+| D4 删票保护不变 | Task 1 明确不改 `assert_can_delete_invoice`;Task 5 验收 |
+| D5 非目标 | 计划未包含审批/发放提醒等 |
+| 业务清单同步 | Task 4 |
+| 验收标准 1–7 | Task 5 |
+
+无 TBD/占位符;文件路径与函数名与现网一致。