feat(bx): add invoice wallet DDL, menu seeds, and design docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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` 隔离。
|
||||
|
||||
---
|
||||
|
||||
## 六、异常与验收
|
||||
|
||||
### 异常
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 余额 < 申请金额 | 不可提交 |
|
||||
| 并发超用 | 后单失败,无半扣 |
|
||||
| 删除导致余额为负 | 不可删 |
|
||||
| 重复退回 | 不二次加回 |
|
||||
| 越权 | 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` 约定对齐。
|
||||
Reference in new issue
Block a user