每个月报销发票太头疼?我花两周写了个自动匹配工具

0 阅读10分钟

项目介绍:餐补发票报销管理系统

源码地址: gitee.com/lee-good/ex…
技术栈: Vue 3 + TypeScript + Vite + Pinia + NestJS + Prisma + SQLite
角色: 独立开发(全栈)

项目背景

这是我独立开发的一个发票报销管理工具,起因是公司里每个月报销发票太麻烦——纸质发票容易丢、电子发票散落在各个邮箱和微信里、手动填 Excel 容易错、领导审批流程慢。我想做一个能自动识别发票信息、统一管理、一键生成报销单的工具。

项目从 2024 年底开始,断断续续做了两个月,现在已经是团队内部每天都在用的工具。

核心功能

1. 发票管理

系统支持两种发票录入方式:

自动识别上传:支持 XML 和 PDF 格式的电子发票,上传后自动解析发票代码、发票号码、销方名称、含税金额、开票日期等关键字段。

手动录入:对于无法自动识别的发票(比如纸质发票拍照),提供手动录入表单。

发票状态流转:

  • UNUSED(未使用):刚录入,尚未关联报销单
  • LINKED(已关联):已加入某张报销单
  • USED(已使用):报销单已提交,发票正式归档
  • EXPIRED(已过期):超过系统配置的发票过期月数,自动标记

发票列表支持按日期范围筛选、按状态过滤,顶部统计卡片实时显示总金额、各状态发票数量和金额。

2. 报销单管理

按月创建报销单,每张报销单包含:

  • 目标金额:预设的月度报销预算
  • 实际金额:已关联发票的合计金额
  • 金额差:实际与目标的差额(正数表示超支)
  • 关联发票:从发票池中选择未使用的发票关联到报销单

报销单状态流转:

DRAFT(创建) → PENDING(待提交) → SUBMITTED(已提交) → COMPLETED(完成)
         ↑___________________________________________|
                    (支持撤销回 DRAFT)

3. 智能匹配(动态规划算法)

这是我最得意的功能。用户设定目标金额后,系统从可用发票中自动选出最优组合,使得总金额最接近且不低于目标金额。

核心算法是0/1 背包问题的变种

// backend/src/expense-claim/expense-claim.service.ts
async match(userId: number, claimId: number) {
  // 将金额转为整数分避免浮点问题
  const amounts = validInvoices.map((inv) => Math.round(inv.total_amount * 100));
  const target = Math.round(targetAmount * 100);
  const maxSum = amounts.reduce((a, b) => a + b, 0);

  // 动态规划:dp[i] = 达到金额i的最小总金额
  const dpSize = target + maxSum + 1;
  const dp = new Array(dpSize).fill(Infinity);
  dp[0] = 0;

  // chosen[i] 记录达到金额i时最后选中的发票索引
  const chosen = new Array(dpSize).fill(-1);

  for (let i = 0; i < amounts.length; i++) {
    for (let j = dpSize - 1; j >= amounts[i]; j--) {
      if (dp[j - amounts[i]] + amounts[i] < dp[j]) {
        dp[j] = dp[j - amounts[i]] + amounts[i];
        chosen[j] = i;
      }
    }
  }

  // 找到 >= target 的最小值
  let bestAmount = -1;
  for (let i = target; i < dpSize; i++) {
    if (dp[i] < Infinity) {
      bestAmount = i;
      break;
    }
  }

  // 回溯找出选中的发票索引
  const selectedIndices = new Set<number>();
  let current = bestAmount;
  while (current > 0 && chosen[current] !== -1) {
    selectedIndices.add(chosen[current]);
    current -= amounts[chosen[current]];
  }
}

为什么用动态规划而不是贪心?因为发票金额不是固定的"物品价值",我们需要的是组合后的总金额刚好覆盖目标,而不是单张发票越大越好。贪心算法可能会选一张大额发票导致严重超支,而 DP 能找到最接近目标的组合。

4. AI 配置管理

系统预留了 AI 能力配置模块,目前接入了 MiniMax 的 Claude 兼容协议 API,用于:

  • 发票信息的智能校验
  • 报销规则的智能提示
  • 异常发票的自动标记

配置项包括:协议类型、模型名称、API 地址、激活状态,支持多配置切换。

5. 系统配置

  • 发票过期月数:超过设定月数的发票自动标记为 EXPIRED,防止积压

技术架构

前端

frontend/
├── src/
│   ├── api/                 # Axios 客户端 + 响应拦截器
│   ├── components/          # 公共组件(NeoButton、NeoTable、NeoInput 等)
│   ├── composables/         # 组合式函数(useMessage、useConfirm)
│   ├── views/               # 页面级组件
│   │   ├── Dashboard/       # 仪表盘
│   │   ├── InvoiceList/     # 发票管理
│   │   ├── InvoiceUpload/   # 上传发票
│   │   ├── ClaimList/       # 报销单列表
│   │   ├── ClaimDetail/     # 报销单详情
│   │   └── Settings/        # 系统设置
│   ├── stores/              # Pinia 状态管理
│   │   └── auth.ts          # 认证状态
│   ├── router/              # Vue Router 配置
│   └── main.ts

技术选型理由

技术选择理由
Vue 3 + Composition API逻辑复用方便,TypeScript 支持好
Vite冷启动快,HMR 体验好
Pinia比 Vuex 简洁,Setup Store 写法直观
自研 Neo 组件库新粗野主义(Neo-Brutalism)设计风格,统一的 3px 黑边框 + 硬阴影
Lucide Vue图标库,轻量且风格统一
Day.js日期处理,比 moment 小很多

后端

backend/
├── src/
│   ├── auth/                # JWT 认证 + Passport
│   ├── invoice/             # 发票管理(上传、解析、CRUD)
│   ├── expense-claim/       # 报销单模块(含智能匹配算法)
│   ├── admin/               # AI/系统配置
│   └── prisma/              # Prisma 服务
├── prisma/
│   ├── schema.prisma        # 数据库模型
│   └── seed.ts              # 种子数据
└── uploads/                 # 上传的发票文件

技术选型理由

技术选择理由
NestJS模块化架构,装饰器语法优雅,适合中大型项目
Prisma类型安全的 ORM,迁移方便
SQLite单机部署简单,不需要额外数据库服务
JWT + Passport无状态认证,前后端分离标准方案
Archiver报销单打包下载(发票 PDF + XML 归档)

数据库模型

model User {
  id            Int      @id @default(autoincrement())
  username      String   @unique
  password_hash String
  role          String   @default("USER")
  invoices      Invoice[]
  expense_claims ExpenseClaim[]
}

model Invoice {
  id             Int     @id @default(autoincrement())
  user_id        Int
  invoice_code   String
  invoice_number String
  amount         Float
  tax_amount     Float
  total_amount   Float
  invoice_date   DateTime
  seller_name    String
  seller_tax_no  String
  file_path      String
  status         String  @default("UNUSED")
  user           User    @relation(fields: [user_id], references: [id])
  expense_claim_items ExpenseClaimItem[]
}

model ExpenseClaim {
  id            Int      @id @default(autoincrement())
  user_id       Int
  month         String
  target_amount Float
  actual_total  Float    @default(0)
  over_amount   Float    @default(0)
  status        String   @default("DRAFT")
  items         ExpenseClaimItem[]
}

model ExpenseClaimItem {
  id         Int     @id @default(autoincrement())
  claim_id   Int
  invoice_id Int
  claim      ExpenseClaim @relation(fields: [claim_id], references: [id])
  invoice    Invoice      @relation(fields: [invoice_id], references: [id])
}

关键实现细节

1. 响应拦截器:下划线转驼峰

后端用 snake_case,前端用 camelCase,我在 Axios 响应拦截器里做了自动转换:

// frontend/src/api/index.ts
function convertKeysToCamel(obj: any): any {
  if (obj === null || obj === undefined) return obj;
  if (Array.isArray(obj)) return obj.map(convertKeysToCamel);
  if (typeof obj === "object") {
    const aliasMap: Record<string, string> = {
      actual_total: "actualAmount",
      over_amount: "excessAmount",
      invoice_code: "invoiceCode",
      invoice_number: "invoiceNumber",
      // ... 其他字段映射
    };

    return Object.keys(obj).reduce((result, key) => {
      const camelKey = snakeToCamel(key);
      const aliasKey = aliasMap[key] || camelKey;
      result[aliasKey] = convertKeysToCamel(obj[key]);
      return result;
    }, {} as any);
  }
  return obj;
}

request.interceptors.response.use(
  (response: AxiosResponse) => {
    return convertKeysToCamel(response.data);
  }
);

这样前端消费 API 时完全不用关心后端的命名规范,直接 claim.actualAmount 就行。

2. 金额精度处理

发票金额涉及财务计算,精度不能丢:

// 将金额转为整数分避免浮点问题
const amounts = validInvoices.map((inv) => Math.round(inv.total_amount * 100));
const target = Math.round(targetAmount * 100);

所有金额计算在"分"的维度上进行,最后展示时再除以 100。

3. 自研 Neo 组件库

项目没有引入 Element Plus 或 Ant Design,而是自研了一套新粗野主义风格的组件:

<!-- NeoButton -->
<button class="neo-btn neo-btn--accent">
  上传发票
</button>

<style>
.neo-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-weight: 700;
  border: 3px solid #1a1a1a;
  border-radius: 16px;
  box-shadow: 4px 4px 0px #1a1a1a;
  transition: all 0.15s ease;
}

.neo-btn:hover {
  transform: translate(2px, 2px);
  box-shadow: 2px 2px 0px #1a1a1a;
}
</style>

特点:

  • 3px 纯黑边框
  • 硬阴影(不模糊)
  • 高对比度配色(主色 #a4e853 荧光绿)
  • 悬停时按钮"按下"的位移动效

4. 发票 XML 解析

电子发票的 XML 结构不统一,不同地区、不同版本的格式有差异:

// 支持多种 XML 命名空间
const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: '@_'
});

const parsed = parser.parse(xml);

// 尝试多种路径获取字段
const code = getField(parsed, ['Invoice.Code', '发票代码', '@_发票代码']);
const number = getField(parsed, ['Invoice.Number', '发票号码', '@_发票号码']);

5. 状态管理的精简设计

没有滥用 Pinia,只有认证状态用全局 Store,其他状态在组件内用 ref/reactive 管理:

// stores/auth.ts - 唯一全局 Store
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null);
  const token = ref<string>(localStorage.getItem('token') || '');
  const isLoggedIn = computed(() => !!token.value);
  const isAdmin = computed(() => user.value?.role === 'ADMIN');

  async function login(username: string, password: string) {
    const data = await loginApi({ username, password });
    token.value = data.accessToken;
    user.value = data.user;
    localStorage.setItem('token', data.accessToken);
    localStorage.setItem('user', JSON.stringify(data.user));
  }

  function logout() {
    token.value = '';
    user.value = null;
    localStorage.removeItem('token');
    localStorage.removeItem('user');
  }

  return { user, token, isLoggedIn, isAdmin, login, logout };
});

遇到的挑战

1. 发票格式兼容性

电子发票有 PDF 版式文件、XML 数据文件、OFD 格式等多种形态。第一阶段我只支持了 XML 和 PDF,OFD 格式因为解析库不成熟暂时放弃。

解决方案:优先支持最常见的 XML 格式(税务局标准版式),PDF 提取文本做 fallback。

2. 大文件上传

有些 PDF 发票扫描件 10MB+,直接上传会超时。

解决方案

  • 前端用 Axios 的 onUploadProgress 显示进度条
  • 后端用 Multer 分片接收
  • Nginx 配置 client_max_body_size 50M

3. 数据安全

发票包含敏感信息(税号、金额、交易对手)。

解决方案

  • JWT Token 过期机制(7 天)
  • 发票文件存储在私有目录,通过 API 鉴权访问
  • 数据库敏感字段加密存储

4. 动态规划算法的性能

发票数量多的时候,DP 数组会很大。

解决方案

  • 金额转"分"后,DP 数组大小是 target + maxSum
  • 实际场景中发票数量通常不超过 50 张,金额不超过几千元,性能完全够用
  • 如果未来发票量增大,可以考虑贪心 + DP 的混合策略

项目截图

(以下为系统主要页面截图,展示新粗野主义设计风格)

登录页

登录页采用简洁的左右分栏布局,左侧展示系统品牌信息,右侧为登录表单。新粗野主义风格的 3px 黑色边框和硬阴影贯穿整个设计。

image.png

仪表盘

仪表盘展示四张统计卡片:本月发票数、已报销金额、待处理报销单、可用发票数。卡片采用白色背景 + 3px 黑边框 + 4px 硬阴影,悬停时有位移动效。

image.png

发票管理

发票列表页展示发票代码、发票号码、销方名称、含税金额、开票日期、状态等信息。顶部统计栏显示总金额、各状态发票数量和金额。支持按日期范围和状态筛选。

image.png

报销单管理

报销单列表页展示月份、目标金额、实际金额、金额差、状态等信息。支持创建新的报销单和查看详情。

image.png

上传发票

上传页面支持拖拽上传 XML 和 PDF 格式的电子发票,上传后自动解析发票信息。

image.png

系统设置

系统设置页包含 AI 配置管理和系统参数配置,支持配置发票过期月数等参数。

image.png

  1. 动态规划算法解决实际业务问题:不是炫技,是真的能算出最优发票组合
  2. 自研 Neo 组件库:新粗野主义设计风格,从 0 写了 Button、Table、Input、Modal 等 10+ 组件
  3. 响应拦截器自动转换命名规范:前后端字段命名不统一的问题一次性解决
  4. 金额精度处理:整数分存储,避免 JavaScript 浮点数陷阱
  5. 模块化 NestJS 架构:发票、报销单、认证、配置四个模块完全解耦

后续规划

  1. OCR 识别:接入多模态 AI,支持拍照发票的自动识别
  2. 数据导出:支持导出 Excel、PDF 报销单
  3. 移动端适配:目前只有桌面端,计划用响应式布局适配手机

总结

这个项目是我"vibe coding"风格的典型代表——从一个真实痛点出发,快速验证 MVP,然后逐步迭代。技术选型不求最新,只求最合适:Vue 3 + Pinia 做状态管理够用了,NestJS 的模块化让后端代码很清晰,SQLite 单机部署零成本。