feat(bx): add invoice wallet DDL, menu seeds, and design docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 18:50:54 +08:00
1 parent 715ccbd637
commit bb1b2b1375
4 files changed
+1156

No files matched your search

@@ -0,0 +1,742 @@
# 报销发票夹额度 — 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:** 为每人建立发票夹额度账本;管理员/员工可上传发票;报销提交扣余额、驳回/撤回退回;申请单改为可选从发票夹关联发票。
**Architecture:** 新建 `bx_invoice` + `bx_invoice_ledger` + `bx_reimburse_invoice`;`InvoiceLedgerService` 作为额度唯一写入入口;报销 `submit`/`reject`/`withdraw` 在同一事务内调用扣/退;前端新增「发票夹管理」「我的发票夹」,改造报销申请选票区。
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + TypeScript;MySQL 5.7;pytest;MinIO via 现有 `bx_attachment`
**Spec:** `docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md`
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| Create | `backend/scripts/migration_20260725_bx_invoice_wallet.sql` | DDL 三表 + 索引 + 菜单 |
| Modify | `backend/scripts/seed_sys_menu.sql` | 种子菜单与迁移一致 |
| Modify | `backend/app/models/bx_models.py` | `BxInvoice` / `BxInvoiceLedger` / `BxReimburseInvoice` / `BxInvoiceWalletSummary` |
| Create | `backend/app/schemas/bx/invoice.py` | 请求/响应 Schema |
| Create | `backend/app/services/bx/invoice_ledger.py` | 余额计算、入账/冲减/占用/退回、summary 刷新 |
| Create | `backend/app/services/bx/invoice.py` | 发票上传/删除/列表 |
| Create | `backend/app/api/bx/invoice.py` | REST:invoices / wallets / ledgers |
| Modify | `backend/app/api/bx/__init__.py` | 注册 router |
| Modify | `backend/app/services/bx/reimburse.py` | submit 扣额度;reject/withdraw 退额度;关联发票 |
| Modify | `backend/app/api/bx/reimburse.py` | submit 校验不足抛 400;新增 withdraw |
| Modify | `backend/app/schemas/bx/reimburse.py` | create/update/submit 支持 `invoiceIds` |
| Create | `backend/tests/test_bx_invoice_ledger.py` | 额度公式与幂等退回单测 |
| Modify | `web-admin/src/api/bx/index.ts` | `invoiceApi` / `invoiceWalletApi` |
| Create | `web-admin/src/views/bx/invoice-wallet/mine/index.vue` | 我的发票夹 |
| Create | `web-admin/src/views/bx/invoice-wallet/admin/index.vue` | 发票夹管理 |
| Create | `web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue` | 从发票夹多选 |
| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 关联发票 + 余额校验提示 |
| Modify | `web-admin/src/views/bx/reimburse/approve/index.vue` | 详情展示关联发票 |
| Modify | `web-admin/src/router/modules/sqxp.ts` | 两菜单路由 |
| Modify | `web-admin/src/utils/router/menu-icon.ts` | 图标 |
| Modify | `docs/business-inventory/03-报销业务明细.md` | 补发票夹说明(实现后) |
**可选缓存表:** `bx_invoice_wallet_summary`(按人一行 `total_amount`/`used_amount`/`balance`/`version`),流水写入后同事务 upsert;列表读 summary,校验仍可再算流水。
---
## 部署顺序
```
1. 执行 migration_20260725_bx_invoice_wallet.sql(表 + 菜单)
2. 部署 backend
3. 部署 web-admin
4. 给角色勾选 bx:invoiceWallet:admin / bx:invoiceWallet:mine
5. 按验收清单走查
```
---
### Task 1: DDL 迁移脚本
**Files:**
- Create: `backend/scripts/migration_20260725_bx_invoice_wallet.sql`
- Modify: `backend/scripts/seed_sys_menu.sql`
- [ ] **Step 1: 编写建表 + 菜单 SQL**
```sql
-- bx_invoice
CREATE TABLE IF NOT EXISTS `bx_invoice` (
`id` VARCHAR(64) NOT NULL COMMENT '主键UUID',
`owner_user_id` VARCHAR(64) NOT NULL COMMENT '所属员工ID',
`owner_user_name` VARCHAR(64) NOT NULL COMMENT '所属员工姓名',
`amount` DECIMAL(14,2) NOT NULL COMMENT '入账金额',
`upload_by` VARCHAR(64) DEFAULT NULL COMMENT '上传操作人ID',
`upload_by_name` VARCHAR(64) DEFAULT NULL COMMENT '上传操作人姓名',
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
`order_num` INT DEFAULT NULL,
`remark` VARCHAR(512) DEFAULT NULL,
`create_by` VARCHAR(64) DEFAULT NULL,
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_by` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME NOT NULL,
`del_flag` SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bx_invoice_owner` (`tenant_no`,`owner_user_id`,`del_flag`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票档案';
-- bx_invoice_ledger
CREATE TABLE IF NOT EXISTS `bx_invoice_ledger` (
`id` VARCHAR(64) NOT NULL,
`owner_user_id` VARCHAR(64) NOT NULL,
`entry_type` VARCHAR(32) NOT NULL COMMENT 'credit_upload|debit_delete|debit_reimburse|credit_release',
`amount` DECIMAL(14,2) NOT NULL COMMENT '正数,方向由 entry_type 决定',
`invoice_id` VARCHAR(64) DEFAULT NULL,
`reimburse_id` VARCHAR(64) DEFAULT NULL,
`ref_ledger_id` VARCHAR(64) DEFAULT NULL COMMENT '退回指向原占用流水',
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
`order_num` INT DEFAULT NULL,
`remark` VARCHAR(512) DEFAULT NULL,
`create_by` VARCHAR(64) DEFAULT NULL,
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_by` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
`del_flag` SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bx_ledger_owner` (`tenant_no`,`owner_user_id`,`del_flag`,`create_time`),
KEY `idx_bx_ledger_reimburse` (`tenant_no`,`reimburse_id`,`entry_type`,`del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票额度流水';
-- bx_reimburse_invoice
CREATE TABLE IF NOT EXISTS `bx_reimburse_invoice` (
`id` VARCHAR(64) NOT NULL,
`reimburse_id` VARCHAR(64) NOT NULL,
`invoice_id` VARCHAR(64) NOT NULL,
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
`order_num` INT DEFAULT NULL,
`remark` VARCHAR(512) DEFAULT NULL,
`create_by` VARCHAR(64) DEFAULT NULL,
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_by` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
`del_flag` SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bx_ri_reimburse` (`tenant_no`,`reimburse_id`,`del_flag`),
KEY `idx_bx_ri_invoice` (`tenant_no`,`invoice_id`,`del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销关联发票';
-- bx_invoice_wallet_summary(缓存)
CREATE TABLE IF NOT EXISTS `bx_invoice_wallet_summary` (
`id` VARCHAR(64) NOT NULL,
`owner_user_id` VARCHAR(64) NOT NULL,
`owner_user_name` VARCHAR(64) NOT NULL DEFAULT '',
`total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
`used_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
`balance` DECIMAL(14,2) NOT NULL DEFAULT 0,
`invoice_count` INT NOT NULL DEFAULT 0,
`version` INT NOT NULL DEFAULT 0,
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
`order_num` INT DEFAULT NULL,
`remark` VARCHAR(512) DEFAULT NULL,
`create_by` VARCHAR(64) DEFAULT NULL,
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL,
`update_by` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME NOT NULL,
`del_flag` SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_bx_wallet_owner` (`tenant_no`,`owner_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票夹额度缓存';
-- 菜单:挂在报销管理 BX...00001 下;ID 用 BX00000000000000000000000000020 / 21
-- perms: bx:invoiceWallet:admin / bx:invoiceWallet:mine
-- path/component: invoice-wallet/admin|mine → /bx/invoice-wallet/admin/index 等
```
同步把同等 `INSERT` 追加进 `seed_sys_menu.sql`(放在报销子菜单区,`order_num` 插在「报销」分组旁或「基础配置」后,避免与现有 BX11–19 冲突)。
- [ ] **Step 2: 在本地/测试库执行脚本并确认表存在**
```bash
# 按项目习惯用 mysql 客户端执行
mysql -h... -u... -p... shanxia2.0 < backend/scripts/migration_20260725_bx_invoice_wallet.sql
```
Expected: 四张表创建成功;两菜单可见(或待角色授权)。
- [ ] **Step 3: Commit**
```bash
git add backend/scripts/migration_20260725_bx_invoice_wallet.sql backend/scripts/seed_sys_menu.sql
git commit -m "feat(bx): add invoice wallet DDL and menu seeds"
```
---
### Task 2: ORM 模型
**Files:**
- Modify: `backend/app/models/bx_models.py`
- [ ] **Step 1: 在文件末尾追加四个模型**(字段与 DDL 一一对应,风格对齐现有 `BxAttachment`)
关键约束:
- `BxInvoiceLedger.entry_type` 取值仅:`credit_upload` | `debit_delete` | `debit_reimburse` | `credit_release`
- `BxInvoiceWalletSummary` 唯一键逻辑由 DB unique 保证;应用层按 `(tenant_no, owner_user_id)` 查询
- [ ] **Step 2: 确认 `app.models` 导出路径**(若有 `__init__` 聚合则补 export;本仓库若直接 `from app.models.bx_models import ...` 则无需改)
- [ ] **Step 3: Commit**
```bash
git add backend/app/models/bx_models.py
git commit -m "feat(bx): add invoice wallet ORM models"
```
---
### Task 3: 额度账本服务(TDD)
**Files:**
- Create: `backend/tests/test_bx_invoice_ledger.py`
- Create: `backend/app/services/bx/invoice_ledger.py`
- Create: `backend/app/schemas/bx/invoice.py`(先放 `WalletSummaryOut` 等)
- [ ] **Step 1: 编写失败测试(纯函数汇总)**
```python
"""发票额度流水汇总与退回幂等。"""
from decimal import Decimal
from app.services.bx.invoice_ledger import (
ENTRY_CREDIT_UPLOAD,
ENTRY_DEBIT_DELETE,
ENTRY_DEBIT_REIMBURSE,
ENTRY_CREDIT_RELEASE,
summarize_entries,
)
def test_summarize_upload_and_reimburse():
rows = [
{"entry_type": ENTRY_CREDIT_UPLOAD, "amount": Decimal("1000")},
{"entry_type": ENTRY_DEBIT_REIMBURSE, "amount": Decimal("300")},
]
s = summarize_entries(rows)
assert s["total_amount"] == Decimal("1000")
assert s["used_amount"] == Decimal("300")
assert s["balance"] == Decimal("700")
def test_summarize_delete_and_release():
rows = [
{"entry_type": ENTRY_CREDIT_UPLOAD, "amount": Decimal("500")},
{"entry_type": ENTRY_DEBIT_DELETE, "amount": Decimal("100")},
{"entry_type": ENTRY_DEBIT_REIMBURSE, "amount": Decimal("200")},
{"entry_type": ENTRY_CREDIT_RELEASE, "amount": Decimal("200")},
]
s = summarize_entries(rows)
assert s["total_amount"] == Decimal("400")
assert s["used_amount"] == Decimal("0")
assert s["balance"] == Decimal("400")
```
- [ ] **Step 2: Run 确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_invoice_ledger.py -v
```
Expected: FAIL import / 函数不存在
- [ ] **Step 3: 实现 `summarize_entries` + 常量**
```python
# backend/app/services/bx/invoice_ledger.py
from __future__ import annotations
from decimal import Decimal
from typing import Any, Dict, Iterable, List, Optional
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.bx_models import BxInvoiceLedger, BxInvoiceWalletSummary
ENTRY_CREDIT_UPLOAD = "credit_upload"
ENTRY_DEBIT_DELETE = "debit_delete"
ENTRY_DEBIT_REIMBURSE = "debit_reimburse"
ENTRY_CREDIT_RELEASE = "credit_release"
def summarize_entries(rows: Iterable[Dict[str, Any]]) -> Dict[str, Decimal]:
credit_upload = Decimal("0")
debit_delete = Decimal("0")
debit_reimburse = Decimal("0")
credit_release = Decimal("0")
for r in rows:
amt = Decimal(str(r["amount"]))
t = r["entry_type"]
if t == ENTRY_CREDIT_UPLOAD:
credit_upload += amt
elif t == ENTRY_DEBIT_DELETE:
debit_delete += amt
elif t == ENTRY_DEBIT_REIMBURSE:
debit_reimburse += amt
elif t == ENTRY_CREDIT_RELEASE:
credit_release += amt
total = credit_upload - debit_delete
used = debit_reimburse - credit_release
return {
"total_amount": total,
"used_amount": used,
"balance": total - used,
}
```
- [ ] **Step 4: Run 确认纯函数测试通过**
```bash
pytest tests/test_bx_invoice_ledger.py::test_summarize_upload_and_reimburse tests/test_bx_invoice_ledger.py::test_summarize_delete_and_release -v
```
Expected: PASS
- [ ] **Step 5: 在同文件实现 DB 方法(供后续 Task 调用)**
必须提供:
```python
class InvoiceLedgerService:
@staticmethod
def list_entries(db, tenant_no, owner_user_id) -> List[BxInvoiceLedger]: ...
@staticmethod
def calc_summary(db, tenant_no, owner_user_id) -> Dict[str, Decimal]:
"""从流水汇总;返回 total/used/balance"""
@staticmethod
def refresh_summary(db, tenant_no, owner_user_id, owner_user_name, invoice_count, user_id, dept_id) -> BxInvoiceWalletSummary:
"""upsert summary;version += 1"""
@staticmethod
def add_entry(db, *, tenant_no, owner_user_id, entry_type, amount, user, invoice_id=None, reimburse_id=None, ref_ledger_id=None, remark=None) -> BxInvoiceLedger:
"""amount 必须 > 0;写流水"""
@staticmethod
def debit_reimburse(db, *, tenant_no, owner_user_id, amount, reimburse_id, user) -> BxInvoiceLedger:
"""balance < amount → HTTPException 400『发票额度不足(余额 X,本次 Y)』;否则写 debit_reimburse 并 refresh_summary"""
@staticmethod
def release_reimburse(db, *, tenant_no, reimburse_id, user) -> Optional[BxInvoiceLedger]:
"""查找该单 debit_reimburse;若已有 credit_release 则返回 None(幂等);否则写 credit_release"""
@staticmethod
def assert_can_delete_invoice(db, tenant_no, owner_user_id, invoice_amount) -> None:
"""若 balance - invoice_amount < 0 → 400"""
```
`debit_reimburse` / `release_reimburse` / 删除前校验须在调用方事务内使用;对 `BxInvoiceWalletSummary` 行使用 `with_for_update()`(行不存在则先 insert 再 lock)防止并发超用。
- [ ] **Step 6: 补充 release 幂等测试(可用 mock Session 或跳过集成,至少文档化行为;优先用可跑的单元测试包装 `release` 判定逻辑)**
```python
def test_release_idempotent_guard():
# 若已存在 credit_release for same reimburse_id → should_skip_release True
from app.services.bx.invoice_ledger import should_skip_release
assert should_skip_release(has_debit=True, has_release=True) is True
assert should_skip_release(has_debit=True, has_release=False) is False
assert should_skip_release(has_debit=False, has_release=False) is True
```
实现对应小函数并让测试通过。
- [ ] **Step 7: Commit**
```bash
git add backend/app/services/bx/invoice_ledger.py backend/app/schemas/bx/invoice.py backend/tests/test_bx_invoice_ledger.py
git commit -m "feat(bx): invoice ledger balance service with unit tests"
```
---
### Task 4: 发票服务 + API
**Files:**
- Create: `backend/app/services/bx/invoice.py`
- Create: `backend/app/api/bx/invoice.py`
- Modify: `backend/app/api/bx/__init__.py`
- Modify: `backend/app/schemas/bx/invoice.py`
- [ ] **Step 1: Schema**
```python
# 关键类型(camelCase alias)
class InvoiceCreateForm: # 实际用 multipart:amount, remark, targetUserId?
class InvoiceOut: id, ownerUserId, ownerUserName, amount, remark, createTime, fileName?, preview via attachment
class WalletSummaryOut: ownerUserId, ownerUserName, totalAmount, usedAmount, balance, invoiceCount
class LedgerOut: id, entryType, amount, invoiceId, reimburseId, remark, createTime
```
- [ ] **Step 2: `InvoiceService.create`**
流程:
1. 解析 owner(本人或管理员 `target_user_id`)
2. 非管理员不得代传 → 403
3. `amount > 0`
4. 写 `BxInvoice`
5. 调现有上传:`upload_bx_file` + `AttachmentService.create`(`biz_type=invoice`, `biz_id=invoice.id`)
6. `InvoiceLedgerService.add_entry(... credit_upload ...)` + `refresh_summary`
7. `db.commit()`
- [ ] **Step 3: `InvoiceService.delete`(仅管理员)**
1. 权限校验
2. `assert_can_delete_invoice`
3. `add_entry(debit_delete)` + 发票 `del_flag=1` + 附件软删(若有现成方法)
4. `refresh_summary`(`invoice_count` 重计)
- [ ] **Step 4: 列表与摘要 API**
```python
router = APIRouter(tags=["发票夹"])
# GET /invoice-wallets/mine/summary
# GET /invoice-wallets # admin 分页:keyword
# GET /invoices # query userId 可选
# POST /invoices # multipart
# DELETE /invoices/{id}
# GET /invoice-ledgers # query userId
```
在 `__init__.py`:`from app.api.bx import invoice` 并 `include_router(invoice.router)`。
- [ ] **Step 5: 手工或 httpx 冒烟(可选)**
上传一张 → summary.balance 增加;删除 → 减少。
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/bx/invoice.py backend/app/api/bx/invoice.py backend/app/api/bx/__init__.py backend/app/schemas/bx/invoice.py
git commit -m "feat(bx): invoice upload/delete APIs and wallet summary"
```
---
### Task 5: 报销扣退额度 + 关联 + 撤回
**Files:**
- Modify: `backend/app/services/bx/reimburse.py`
- Modify: `backend/app/api/bx/reimburse.py`
- Modify: `backend/app/schemas/bx/reimburse.py`
- [ ] **Step 1: Schema 增加可选 `invoice_ids: Optional[List[str]] = None`**
`ReimburseApplyCreate` / `Update`;详情 `ReimburseApplyOut` 增加 `invoice_ids` 或嵌套 `invoices: List[InvoiceBriefOut]`。
- [ ] **Step 2: 辅助方法 `sync_reimburse_invoices(db, reimburse_id, invoice_ids, tenant_no, user)`**
- 软删旧关联,写入新关联(允许空列表)
- 校验每张票 `owner_user_id == applicant` 且 `del_flag=0`
- **不**改额度
- [ ] **Step 3: 改造 `ReimburseService.submit`**
在改 status 之前:
```python
amount = Decimal(str(apply_obj.amount))
InvoiceLedgerService.debit_reimburse(
db,
tenant_no=tenant_no,
owner_user_id=apply_obj.applicant_id,
amount=amount,
reimburse_id=apply_id,
user=user,
)
# 若 body 带 invoice_ids,在 API 层先 sync 再 submit;或 submit 接收 invoice_ids
```
余额不足时让异常冒泡到 API → 400。
注意:若单据已是 `pending` 重复 submit,须先检查是否已有 `debit_reimburse`,避免双扣(幂等:已占用则跳过扣减仅保证状态)。
- [ ] **Step 4: 改造 `reject`**
在 `status=rejected` 同时:
```python
InvoiceLedgerService.release_reimburse(db, tenant_no=tenant_no, reimburse_id=apply_id, user=approver)
```
- [ ] **Step 5: 新增 `withdraw`**
现网无撤回。新增:
```python
# service
def withdraw(db, apply_id, user) -> Optional[BxReimburseApply]:
# 仅申请人;status == pending;→ draft;release_reimburse;clear current_node
# api
@router.post("/{reimburse_id}/withdraw")
def withdraw_reimburse(...):
```
- [ ] **Step 6: create/update 保存时可 sync 关联(草稿)**
- [ ] **Step 7: Commit**
```bash
git add backend/app/services/bx/reimburse.py backend/app/api/bx/reimburse.py backend/app/schemas/bx/reimburse.py
git commit -m "feat(bx): debit invoice balance on submit; release on reject/withdraw"
```
---
### Task 6: 前端 API
**Files:**
- Modify: `web-admin/src/api/bx/index.ts`
- [ ] **Step 1: 追加**
```typescript
export const invoiceWalletApi = {
mineSummary() {
return bxRequest.get('/invoice-wallets/mine/summary')
},
list(params: { page: number; pageSize: number; keyword?: string }) {
return bxRequest.get('/invoice-wallets', { params })
}
}
export const invoiceApi = {
list(params: { page?: number; pageSize?: number; userId?: BxId }) {
return bxRequest.get('/invoices', { params })
},
upload(formData: FormData) {
return bxRequest.post('/invoices', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
delete(id: BxId) {
return bxRequest.delete(`/invoices/${id}`)
},
ledgers(params: { userId?: BxId; page?: number; pageSize?: number }) {
return bxRequest.get('/invoice-ledgers', { params })
}
}
// reimburseApi 增加:
// withdraw(id)
// create/update body 可带 invoiceIds
```
确认 `bxRequest` 对 multipart 不走错误加密路径(若项目有 exclude,把 `/invoices` POST 加入与 attachment 相同排除逻辑)。
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/bx/index.ts
git commit -m "feat(bx): frontend APIs for invoice wallet"
```
---
### Task 7: 我的发票夹页面
**Files:**
- Create: `web-admin/src/views/bx/invoice-wallet/mine/index.vue`
- [ ] **Step 1: 页面骨架(对齐 Fresh Mint 列表)**
- `panel-top` 标题「我的发票夹」
- 三数字:总额 / 已报销(占用) / 余额(`invoiceWalletApi.mineSummary`)
- 工具栏:上传发票(唯一 `type="primary"`)
- 表格:金额、备注、上传时间、附件预览;无删除
- 下方或 Tab:额度流水(`invoiceApi.ledgers`)
上传弹窗:`label-position="top"`;金额必填;文件必选;调用 `invoiceApi.upload`(FormData:`file`,`amount`,`remark`)。
- [ ] **Step 2: 在 `App.vue` `panelTitleBarTextRules` 注册「我的发票夹」「发票夹管理」若需要竖条标题**
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/views/bx/invoice-wallet/mine/index.vue
git commit -m "feat(bx): my invoice wallet page"
```
---
### Task 8: 发票夹管理(管理员)
**Files:**
- Create: `web-admin/src/views/bx/invoice-wallet/admin/index.vue`
- [ ] **Step 1: 员工额度列表**
筛选 keyword;列:姓名、总额、已报销、余额、发票张数;行点击或「查看」进抽屉/子页。
- [ ] **Step 2: 员工详情**
发票列表 + 上传(FormData 带 `targetUserId`)+ 删除(确认框;失败展示后端「无法删除」文案)+ 流水。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/views/bx/invoice-wallet/admin/index.vue
git commit -m "feat(bx): admin invoice wallet management page"
```
---
### Task 9: 报销申请改选票 + 余额门槛
**Files:**
- Create: `web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue`
- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
- Modify: `web-admin/src/views/bx/reimburse/approve/index.vue`
- [ ] **Step 1: `BxInvoiceSelectDialog`**
多选本人发票列表;确认返回 `InvoiceOut[]`。
- [ ] **Step 2: 改造申请弹窗**
替换原 `BxAttachmentUpload`(`biz-type=reimburse_apply`)区块为:
- 文案「关联发票(可选)」
- 展示 `mineSummary.balance` 与明细合计
- 按钮「从发票夹选择」打开 Dialog
- 已选标签可移除
- **不要**本地上传发票文件
保存 create/update 传 `invoiceIds`。
提交前前端可先比余额(后端仍强校验):
```typescript
if (Number(balance) < Number(detailTotalAmount)) {
ElMessage.warning(`发票额度不足(余额 ${balance},本次 ${detailTotalAmount})`)
return
}
```
详情区只读展示关联发票(可复用附件只读或新列表)。
可选:审批中增加「撤回」按钮调 `reimburseApi.withdraw`(仅 pending + 本人)。
- [ ] **Step 3: 审批详情页展示关联发票**
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue \
web-admin/src/views/bx/reimburse/apply/index.vue \
web-admin/src/views/bx/reimburse/approve/index.vue
git commit -m "feat(bx): select invoices from wallet on reimburse apply"
```
---
### Task 10: 路由、图标、菜单对齐
**Files:**
- Modify: `web-admin/src/router/modules/sqxp.ts`
- Modify: `web-admin/src/utils/router/menu-icon.ts`
- [ ] **Step 1: 在报销模块 children 增加**
```typescript
{
id: 'BX00000000000000000000000000020',
name: 'BxInvoiceWalletAdmin',
path: 'invoice-wallet-admin',
component: '/bx/invoice-wallet/admin/index',
meta: { title: '发票夹管理', keepAlive: true, menuCode: 'bx:invoiceWallet:admin' }
},
{
id: 'BX00000000000000000000000000021',
name: 'BxInvoiceWalletMine',
path: 'invoice-wallet-mine',
component: '/bx/invoice-wallet/mine/index',
meta: { title: '我的发票夹', keepAlive: true, menuCode: 'bx:invoiceWallet:mine' }
}
```
(具体挂在 `BxReimburse` 或独立分组,与 SQL `parent_id` 一致。)
- [ ] **Step 2: menu-icon 映射**
```typescript
'bx:invoiceWallet:admin': 'ri:folder-shared-line',
'bx:invoiceWallet:mine': 'ri:wallet-3-line',
```
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/router/modules/sqxp.ts web-admin/src/utils/router/menu-icon.ts
git commit -m "feat(bx): routes and icons for invoice wallet menus"
```
---
### Task 11: 文档与图谱收尾
**Files:**
- Modify: `docs/business-inventory/03-报销业务明细.md`
- Spec 已存在,确认状态为已批准
- [ ] **Step 1: 业务清单增加「发票夹」小节**(表、入口、与报销扣减关系)
- [ ] **Step 2: 跑图谱更新**
```bash
cd /Users/zlwu/work/00code/shanxia2.0 && ./scripts/update-graph.sh all
# 或分别 backend / frontend
```
- [ ] **Step 3: Commit**
```bash
git add docs/business-inventory/03-报销业务明细.md docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md graphify-out web-admin/graphify-out backend/graphify-out
git commit -m "docs(bx): document invoice wallet and refresh graphify"
```
---
## 验收清单(实现后打勾)
- [ ] 管理员代员工上传 → 该人余额增加
- [ ] 管理员删除(余额够冲)→ 总额/余额减少;不够冲 → 明确失败
- [ ] 员工自助上传 → 本人余额增加,可见流水
- [ ] 报销可不选票;可多选;同一张票可挂多单
- [ ] 余额不足不可提交;足够则提交后余额减少
- [ ] 驳回后退回;撤回后退回;同一单不重复加回
- [ ] 申请单无本地发票上传,仅发票夹选择
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| DDL 三表+summary | T1 |
| 流水类型与公式 | T3 |
| 上传/删除/管理端/我的夹 | T4,T7,T8 |
| 提交扣、驳回/撤回退 | T5 |
| 可选关联、可多挂 | T5,T9 |
| 菜单路由 | T1,T10 |
| 申请 UI 改选票 | T9 |
| 不做 OCR/核销等 | 无任务(刻意) |
**说明:** 现网原无「撤回」接口,本计划 **新增** `withdraw` 以满足 Spec D3;若产品只要驳回退回,实现时可保留 withdraw 但前端先不暴露按钮。
@@ -0,0 +1,295 @@
# 报销 · 发票夹额度与报销扣减 — 设计文档
> **日期**: 2026-07-25
> **状态**: 已批准(待实现)
> **仓库**: shanxia2.0(`backend/` + `web-admin/`)
> **界面模式**: P01 标准列表 + 弹窗表单;报销申请改造现有页
> **前置**:
> - `docs/business-inventory/03-报销业务明细.md`
> - 现网:`web-admin/src/views/bx/reimburse/apply/index.vue`、`backend/app/api/bx/reimburse.py`、`bx_attachment`
## 背景与问题
### 现状
报销申请单在表单内通过 `BxAttachmentUpload`(`biz_type=reimburse_apply`)**本地上传发票附件**。员工每次报销携带大量发票文件,流程重、难对账。
### 客户诉求
1. 每人有一个**发票夹**:有发票总额、已报销总额、余额。
2. 管理员可给任意员工发票夹上传发票并增加总额;也可删除发票并扣减总额。
3. 员工可在「我的发票夹」自助上传并加总额。
4. 报销申请**不再本地上传发票**,改为可选从发票夹选择关联;提交时从**发票余额**扣除;余额不足则提醒且不可申请。
5. 提交即扣;驳回/撤回则退回额度。
### 与现网差距
| 项 | 现网 | 目标 |
|----|------|------|
| 发票存放 | 挂在报销单附件 | 独立发票夹(人维度) |
| 额度 | 无 | 总额 / 已报销(占用) / 余额 |
| 报销提交门槛 | 无余额校验 | 余额 ≥ 申请金额 |
| 申请单发票区 | 本地上传文件 | 从发票夹多选关联(可选) |
| 扣/退时机 | 无 | 提交扣;驳回/撤回退 |
---
## 已确认决策
| # | 决策 | 说明 |
|---|------|------|
| D1 | **方案 2:额度账本 + 发票档案** | 流水为权威;可缓存三字段加速展示 |
| D2 | **两入口 + 改报销申请** | 发票夹管理(管理员)、我的发票夹(员工)、报销申请改造 |
| D3 | **提交扣、驳回/撤回退** | 草稿不扣;一单占用退回幂等 |
| D4 | **选票可选** | 可不选;硬门槛仅余额;选票纯引用 |
| D5 | **同一张票可挂多单** | 关联不参与额度公式 |
| D6 | **员工可自助上传;管理员可代操作** | 删除发票:本期仅管理员 |
| D7 | **删除发票冲减总额** | 冲减后余额将为负则禁止删除 |
| D8 | **「已报销」= 有效占用** | 含审批中未退回,非「财务已打款合计」 |
| D9 | **本期不做** | OCR/验真、按票核销、打款后再调额度、员工自删、已支付单作废退额度 |
---
## 一、模块与菜单
挂在「报销管理」下:
```
报销管理 /bx
├── …原有(费用项目、备用金、报销、视图等)…
├── 发票夹管理 /bx/invoice-wallet/admin bx:invoiceWallet:admin
├── 我的发票夹 /bx/invoice-wallet/mine bx:invoiceWallet:mine
└── 报销申请 /bx/reimburse/apply (交互改造,权限沿用)
```
| 入口 | 角色 | 能力 |
|------|------|------|
| 发票夹管理 | 管理员 | 全员额度列表;代上传;删除发票;查看某人发票与流水 |
| 我的发票夹 | 全员 | 本人三数字、发票列表、自助上传、本人流水 |
| 报销申请 | 全员 | 余额校验与扣减;可选关联本人发票 |
---
## 二、数据模型
遵循 `database-conventions`:`id` VARCHAR(64) UUID,公共字段 `tenant_no` … `del_flag`。
### 2.1 `bx_invoice`(发票档案)
| 字段 | 说明 |
|------|------|
| `owner_user_id` / `owner_user_name` | 发票所属员工 |
| `amount` | 票面入账金额(> 0) |
| 文件 | 复用 `bx_attachment`(建议 `biz_type=invoice`,`biz_id=发票id`),存储走现有 MinIO 上传 |
| `remark` | 备注 |
| `upload_by` | 实际上传操作人(管理员代传时与 owner 不同) |
| 公共字段 | 标准一套 |
软删除:业务「删除发票」优先走额度冲减流水 + `del_flag=1`,不以静默改金额代替。
### 2.2 `bx_invoice_ledger`(额度流水,权威)
| 字段 | 说明 |
|------|------|
| `owner_user_id` | 额度归属人 |
| `entry_type` | 见下表 |
| `amount` | 正数;方向由类型表达 |
| `invoice_id` | 可选,上传/删除关联 |
| `reimburse_id` | 可选,占用/退回关联 |
| `ref_ledger_id` | 可选,退回指向原占用流水,便于幂等 |
| `remark` | 说明 |
| 公共字段 | 标准一套 |
**`entry_type` 定稿:**
| 类型 | 含义 | 对余额 |
|------|------|--------|
| `credit_upload` | 上传入账 | + |
| `debit_delete` | 删除发票冲减 | − |
| `debit_reimburse` | 报销提交占用 | − |
| `credit_release` | 驳回/撤回退回 | + |
**余额(权威算法):**
```
balance = Σ(credit_upload) − Σ(debit_delete) − Σ(debit_reimburse) + Σ(credit_release)
```
(查询均排除 `del_flag=1` 的流水;若流水本身不做软删,则以是否存在为准。)
**已报销(占用) 展示:**
```
used = Σ(debit_reimburse) − Σ(credit_release)
```
**总额展示:**
```
total = Σ(credit_upload) − Σ(debit_delete)
```
可增加缓存表或 `bx_invoice_wallet_summary`(按人一行三字段 + 版本号),每次写流水后同事务更新;**对账与校验以流水为准**。
### 2.3 `bx_reimburse_invoice`(报销 ↔ 发票,可选)
| 字段 | 说明 |
|------|------|
| `reimburse_id` | 报销单 |
| `invoice_id` | 发票 |
| 公共字段 | 标准一套 |
- 多对多;同一 `invoice_id` 可出现在多张报销单。
- **不参与**余额计算。
- 随报销草稿保存可写/改;提交时以最终列表为准。
### 2.4 现有表改动
- `bx_reimburse_apply`:可不加字段;占用金额以该单 `amount` + 对应 `debit_reimburse` 为准。
- 若需快速展示「是否已占用额度」,可加可选标记字段,非必须。
---
## 三、额度生命周期与并发
### 3.1 上传
1. 写 `bx_invoice` + 附件。
2. 写 `credit_upload`(amount = 票面金额)。
3. 更新缓存三字段。
管理员代传:`owner_user_id` = 目标员工,`upload_by` = 当前管理员。
### 3.2 删除发票(仅管理员)
1. 校验:删除后 `balance` 是否 ≥ 0(即冲减金额后不会导致余额为负)。
2. 写 `debit_delete`(amount = 该票入账金额)。
3. 发票 `del_flag=1`(及附件按现网策略处理)。
4. 更新缓存。
失败提示:该发票金额已部分/全部被报销占用,无法删除。
### 3.3 报销草稿
- 不写占用流水。
- UI 可弱提示余额与合计对比。
- 可保存可选 `invoiceIds`。
### 3.4 提交(事务)
1. 计算 `applyAmount`(明细合计,与主表金额一致)。
2. 锁定该用户额度(行锁 summary 或等价串行)。
3. 若 `balance < applyAmount` → 失败,明确提示余额与本次金额。
4. 写 `debit_reimburse`(关联 `reimburse_id`)。
5. 同步关联表 `bx_reimburse_invoice`(可为空)。
6. 更新缓存;执行现有提交审批逻辑。
7. 任一步失败整单回滚。
### 3.5 驳回 / 撤回
1. 查找该 `reimburse_id` 是否已有有效 `debit_reimburse` 且尚无对应 `credit_release`。
2. 若有:写 `credit_release`(金额 = 原占用额,`ref_ledger_id` 指向原 debit)。
3. 若已退过:幂等成功或明确「已退回」,**禁止二次加回**。
4. 更新缓存。
**本期范围外:** 已支付/已发放单的作废退额度。
### 3.6 并发
同一 `owner_user_id` 的额度变更(上传/删除/提交/退回)须在事务内串行化,避免两单同时提交超用。
---
## 四、页面交互
### 4.1 发票夹管理
- 列表:员工、总额、已报销、余额、发票张数;姓名等筛选。
- 详情:三数字卡片 + 发票列表(金额、上传人、时间、预览/下载)+ 删除。
- 「上传发票」:目标员工 + 文件 + 金额(必填)+ 备注。
- 可查看该员工额度流水(建议提供)。
### 4.2 我的发票夹
- 本人三数字 + 上传 + 发票列表 + 本人流水。
- 无切换他人、无删除。
### 4.3 报销申请(改造)
- 保留费用明细、备注等。
- 原「附件上传」改为 **「关联发票(可选)」**:
- 展示当前余额;明细合计变化时对比。
- 「从发票夹选择」多选本人未删发票。
- 已选以卡片/标签展示,可移除。
- **不在此本地上传发票文件**(引导去发票夹)。
- 提交:余额不足拦截;足够则扣额度并提交。
- 详情 / 审批页:只读展示关联发票 + 本单占用说明。
UI 规范:Fresh Mint(`docs/web-admin-ui-spec-fresh-mint.md`);列表对齐 Demo 标准列表页。
---
## 五、API 要点
前缀建议:`/api/invoice-wallets`、`/api/invoices`、`/api/invoice-ledgers`;报销沿用 `/api/reimburses`。
| 能力 | 示意 | 说明 |
|------|------|------|
| 我的摘要 | `GET .../invoice-wallets/mine/summary` | total / used / balance / invoiceCount |
| 管理端列表 | `GET .../invoice-wallets` | 分页筛选员工额度 |
| 发票列表 | `GET .../invoices` | 管理端 `userId`;我的=本人 |
| 上传 | `POST .../invoices` | 文件+金额;可选 `targetUserId` |
| 删除 | `DELETE .../invoices/{id}` | 余额将为负 → 400 |
| 流水 | `GET .../invoice-ledgers` | 按人 |
| 报销提交 | 现有 submit 增强 | 事务内扣额度 + 可选 invoiceIds |
| 驳回/撤回 | 现有增强 | 幂等退回 |
权限:非管理员不得删票、不得代传他人、不得查他人夹(除管理端权限)。
租户:全部按当前用户 `tenant_no` 隔离。
---
## 六、异常与验收
### 异常
| 场景 | 行为 |
|------|------|
| 余额 &lt; 申请金额 | 不可提交 |
| 并发超用 | 后单失败,无半扣 |
| 删除导致余额为负 | 不可删 |
| 重复退回 | 不二次加回 |
| 越权 | 403 |
### 验收
1. 管理员上传 → 该人总额/余额增加;合法删除 → 总额/余额减少。
2. 员工自助上传 → 本人余额增加,可见流水。
3. 报销可不选票;同一张票可关联多单。
4. 余额不足不可提交;足够则提交后余额减少。
5. 驳回/撤回后余额加回且不重复。
6. 申请单无本地发票文件上传入口(改选发票夹)。
### 明确不做(本期)
OCR/验真、按票核销、打款后再调额度、员工自删发票、已支付单作废退额度。
---
## 七、实现顺序建议
1. DDL + ORM/Schema + 流水服务(入账/冲减/占用/退回 + 余额查询)
2. 发票上传/删除/列表 API + 管理端 / 我的夹前端
3. 改造报销 submit/reject/withdraw + 关联表
4. 报销申请 UI 改选发票;详情/审批只读关联
5. 菜单权限 + 联调验收
---
## 八、风险与注意
- **口径沟通**:界面「已报销」实为「已占用额度」,含审批中;避免财务理解为「已打款」。
- **历史单**:上线前已存在的报销附件不自动迁入发票夹;额度从 0 起由上传累积(若需历史迁移另开变更)。
- **附件存储**:与现有 `bx_attachment` / MinIO 对齐,避免第二套上传协议。
- **字段一致性**:CRUD/`_row_to_dict`/前端 form 按工程 `check_field_consistency` 约定对齐。