From bb1b2b13757bc11dfc7c45923a4bebd0e9426cc5 Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 25 Jul 2026 18:50:54 +0800 Subject: [PATCH] feat(bx): add invoice wallet DDL, menu seeds, and design docs Co-authored-by: Cursor --- .../migration_20260725_bx_invoice_wallet.sql | 117 +++ backend/scripts/seed_sys_menu.sql | 2 + .../2026-07-25-invoice-wallet-reimburse.md | 742 ++++++++++++++++++ ...6-07-25-invoice-wallet-reimburse-design.md | 295 +++++++ 4 files changed, 1156 insertions(+) create mode 100644 backend/scripts/migration_20260725_bx_invoice_wallet.sql create mode 100644 docs/superpowers/plans/2026-07-25-invoice-wallet-reimburse.md create mode 100644 docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md diff --git a/backend/scripts/migration_20260725_bx_invoice_wallet.sql b/backend/scripts/migration_20260725_bx_invoice_wallet.sql new file mode 100644 index 000000000..34077f738 --- /dev/null +++ b/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'); diff --git a/backend/scripts/seed_sys_menu.sql b/backend/scripts/seed_sys_menu.sql index f68b63ac9..5697be073 100644 --- a/backend/scripts/seed_sys_menu.sql +++ b/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); diff --git a/docs/superpowers/plans/2026-07-25-invoice-wallet-reimburse.md b/docs/superpowers/plans/2026-07-25-invoice-wallet-reimburse.md new file mode 100644 index 000000000..2cd242fd0 --- /dev/null +++ b/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 但前端先不暴露按钮。 diff --git a/docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md b/docs/superpowers/specs/2026-07-25-invoice-wallet-reimburse-design.md new file mode 100644 index 000000000..58b590dc2 --- /dev/null +++ b/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` 隔离。 + +--- + +## 六、异常与验收 + +### 异常 + +| 场景 | 行为 | +|------|------| +| 余额 < 申请金额 | 不可提交 | +| 并发超用 | 后单失败,无半扣 | +| 删除导致余额为负 | 不可删 | +| 重复退回 | 不二次加回 | +| 越权 | 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` 约定对齐。