Преглед на файлове

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu преди 3 месеца
родител
ревизия
bb1b2b1375

+ 117 - 0
backend/scripts/migration_20260725_bx_invoice_wallet.sql

@@ -0,0 +1,117 @@
+-- ============================================================
+-- 报销 · 发票夹额度:建表 + 菜单(报销分组下方)
+-- 可重复执行(CREATE IF NOT EXISTS;菜单 NOT EXISTS 防护)
+-- ============================================================
+
+-- 1) 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='发票档案';
+
+-- 2) 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='发票额度流水';
+
+-- 3) bx_reimburse_invoice 报销关联发票(M:N,可选)
+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='报销关联发票';
+
+-- 4) 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='发票夹额度缓存';
+
+-- 5) 菜单:挂在「报销」分组 BX...00010 下,order_num 4/5
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'BX00000000000000000000000000020', '发票夹管理', 'BX00000000000000000000000000010', 'C',
+  'invoice-wallet-admin', '/bx/invoice-wallet/admin/index', 'bx:invoiceWallet:admin',
+  1, '1', '1', 'shanxia2.0', 4, '管理员代传/删除发票;全员额度列表',
+  NULL, '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'BX00000000000000000000000000020');
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'BX00000000000000000000000000021', '我的发票夹', 'BX00000000000000000000000000010', 'C',
+  'invoice-wallet-mine', '/bx/invoice-wallet/mine/index', 'bx:invoiceWallet:mine',
+  1, '1', '1', 'shanxia2.0', 5, '员工自助上传与查看本人发票夹',
+  NULL, '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'BX00000000000000000000000000021');

+ 2 - 0
backend/scripts/seed_sys_menu.sql

@@ -90,6 +90,8 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000005', '工作流配置(报销单)', 'BX00000000000000000000000000002', 'C', 'workflow-reimburse', '/bx/workflow/reimburse/index', 'bx:workflowReimburse:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000009', '备用金发放', 'BX00000000000000000000000000006', 'C', 'issue', '/bx/advance/issue/index', 'bx:advanceIssue:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000013', '支付发放冲抵', 'BX00000000000000000000000000010', 'C', 'payment', '/bx/payment/index', 'bx:payment:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000020', '发票夹管理', 'BX00000000000000000000000000010', 'C', 'invoice-wallet-admin', '/bx/invoice-wallet/admin/index', 'bx:invoiceWallet:admin', 1, '1', '1', 'shanxia2.0', 4, '管理员代传/删除发票;全员额度列表', NULL, '', '2026-07-25 18:00:00', NULL, '2026-07-25 18:00:00', 0);
+INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000021', '我的发票夹', 'BX00000000000000000000000000010', 'C', 'invoice-wallet-mine', '/bx/invoice-wallet/mine/index', 'bx:invoiceWallet:mine', 1, '1', '1', 'shanxia2.0', 5, '员工自助上传与查看本人发票夹', NULL, '', '2026-07-25 18:00:00', NULL, '2026-07-25 18:00:00', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000017', '全部申请单(总经理)', 'BX00000000000000000000000000014', 'C', 'all', '/bx/view/all/index', 'bx:viewAll:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000001', '计划新增', 'DB00000000000000000000000000003', 'F', '', NULL, 'db:plan:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000005', '出入库新增', 'DB00000000000000000000000000004', 'F', '', NULL, 'db:inventory:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);

+ 742 - 0
docs/superpowers/plans/2026-07-25-invoice-wallet-reimburse.md

@@ -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 但前端先不暴露按钮。

+ 295 - 0
docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md

@@ -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` 约定对齐。