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