财务发票系统设计与实现:开票、导入、OCR识别全流程
前言
发票管理是企业财务系统的核心模块之一,涉及开票申请、发票开具、发票导入、OCR识别、发票验真、报销管理等多个环节。本文将详细介绍如何设计和实现一套完整的财务发票系统,涵盖系统架构、核心功能、代码实现等内容。
一、发票系统概述
1.1 发票类型
+------------------------------------------------------------------+
| 发票类型分类 |
+------------------------------------------------------------------+
| |
| 按发票形式分类: |
| +------------------+------------------------------------------+ |
| | 增值税专用发票 | 可抵扣进项税,适用于一般纳税人 | |
| +------------------+------------------------------------------+ |
| | 增值税普通发票 | 不可抵扣,适用于小规模纳税人和个人消费 | |
| +------------------+------------------------------------------+ |
| | 增值税电子发票 | 电子版普通发票,具有同等法律效力 | |
| +------------------+------------------------------------------+ |
| | 增值税电子专票 | 电子版专用发票,可抵扣 | |
| +------------------+------------------------------------------+ |
| | 全电发票 | 全面数字化电子发票(数电票) | |
| +------------------+------------------------------------------+ |
| |
| 其他票据类型: |
| +------------------+------------------------------------------+ |
| | 机动车销售发票 | 购买机动车时开具 | |
| +------------------+------------------------------------------+ |
| | 通行费发票 | 高速公路、桥闸通行费 | |
| +------------------+------------------------------------------+ |
| | 火车票/机票 | 可作为增值税抵扣凭证 | |
| +------------------+------------------------------------------+ |
| | 定额发票 | 固定金额的发票 | |
| +------------------+------------------------------------------+ |
| |
+------------------------------------------------------------------+
1.2 发票核心要素
+------------------------------------------------------------------+
| 增值税发票核心要素 |
+------------------------------------------------------------------+
| |
| +------------------------------------------------------------+ |
| | 增值税电子普通发票 | |
| +------------------------------------------------------------+ |
| | 发票代码: 011001900311 发票号码: 12345678 | |
| | 开票日期: 2024-01-15 校验码: ABCD1234... | |
| +------------------------------------------------------------+ |
| | 购买方信息: | |
| | 名称: XX科技有限公司 | |
| | 纳税人识别号: 91110000MA12345678 | |
| | 地址电话: 北京市朝阳区... 010-12345678 | |
| | 开户行及账号: 工商银行北京分行 1234567890 | |
| +------------------------------------------------------------+ |
| | 商品明细: | |
| | +--------+------+------+------+------+------+------+ | |
| | | 商品名 | 规格 | 单位 | 数量 | 单价 | 金额 | 税率 | | |
| | +--------+------+------+------+------+------+------+ | |
| | | 软件服务| - | 项 | 1 |10000|10000| 6% | | |
| | +--------+------+------+------+------+------+------+ | |
| +------------------------------------------------------------+ |
| | 价税合计(大写): 壹万零陆佰元整 | |
| | 价税合计(小写): ¥10600.00 | |
| | 税额: ¥600.00 | |
| +------------------------------------------------------------+ |
| | 销售方信息: | |
| | 名称: YY软件有限公司 | |
| | 纳税人识别号: 91110000MA98765432 | |
| +------------------------------------------------------------+ |
| | 备注: | |
| | 开票人: 张三 复核: 李四 收款人: 王五 | |
| +------------------------------------------------------------+ |
| |
+------------------------------------------------------------------+
1.3 业务流程
+------------------------------------------------------------------+
| 发票业务全流程 |
+------------------------------------------------------------------+
| |
| 开票流程: |
| +--------+ +--------+ +--------+ +--------+ |
| | 开票 | -> | 审批 | -> | 开票 | -> | 交付 | |
| | 申请 | | 流程 | | 执行 | | 发票 | |
| +--------+ +--------+ +--------+ +--------+ |
| |
| 收票流程: |
| +--------+ +--------+ +--------+ +--------+ |
| | 发票 | -> | OCR | -> | 发票 | -> | 入账 | |
| | 采集 | | 识别 | | 验真 | | 报销 | |
| +--------+ +--------+ +--------+ +--------+ |
| | |
| +-- 手动录入 |
| +-- 扫描上传 |
| +-- 邮件导入 |
| +-- Excel导入 |
| |
+------------------------------------------------------------------+
二、系统架构设计
2.1 整体架构
+------------------------------------------------------------------------+
| 发票系统整体架构 |
+------------------------------------------------------------------------+
| |
| 接入层 |
| +------------------+ +------------------+ +-------------------+ |
| | Web 管理端 | | 移动端 APP | | 开放 API | |
| +------------------+ +------------------+ +-------------------+ |
| | |
| 网关层 v |
| +---------------------------------------------------------------------+
| | API Gateway |
| | (认证、限流、路由、日志) |
| +---------------------------------------------------------------------+
| | |
| 服务层 v |
| +---------------------------------------------------------------------+
| | |
| | +---------------+ +---------------+ +---------------+ |
| | | 开票服务 | | 收票服务 | | 验真服务 | |
| | +---------------+ +---------------+ +---------------+ |
| | |
| | +---------------+ +---------------+ +---------------+ |
| | | OCR服务 | | 报销服务 | | 统计服务 | |
| | +---------------+ +---------------+ +---------------+ |
| | |
| +---------------------------------------------------------------------+
| | |
| 中间件层 v |
| +---------------------------------------------------------------------+
| | +----------+ +----------+ +----------+ +----------+ |
| | | MySQL | | Redis | | RocketMQ | | OSS | |
| | +----------+ +----------+ +----------+ +----------+ |
| +---------------------------------------------------------------------+
| | |
| 外部服务 v |
| +---------------------------------------------------------------------+
| | +---------------+ +---------------+ +---------------+ |
| | | 税控系统 | | 税务验真API | | OCR云服务 | |
| | +---------------+ +---------------+ +---------------+ |
| +---------------------------------------------------------------------+
| |
+------------------------------------------------------------------------+
2.2 核心模块说明
+------------------------------------------------------------------+
| 核心模块职责 |
+------------------------------------------------------------------+
| |
| 开票服务: |
| - 开票申请管理 |
| - 审批流程 |
| - 对接税控设备/电子发票平台 |
| - 发票交付(邮件/短信/下载) |
| |
| 收票服务: |
| - 发票采集(扫描/拍照/导入) |
| - 发票信息管理 |
| - 发票台账 |
| - 抵扣管理 |
| |
| OCR服务: |
| - 发票图片识别 |
| - 结构化数据提取 |
| - 多类型发票支持 |
| |
| 验真服务: |
| - 调用税务局验真接口 |
| - 发票真伪校验 |
| - 查重校验 |
| |
| 报销服务: |
| - 报销单管理 |
| - 发票关联 |
| - 审批流程 |
| |
+------------------------------------------------------------------+
三、数据库设计
3.1 核心表结构
-- 1. 发票主表
CREATE TABLE `invoice` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
`invoice_code` VARCHAR(20) NOT NULL COMMENT '发票代码',
`invoice_no` VARCHAR(20) NOT NULL COMMENT '发票号码',
`invoice_type` VARCHAR(20) NOT NULL COMMENT '发票类型:SPECIAL/NORMAL/ELECTRONIC/FULL_DIGITAL',
`invoice_status` TINYINT NOT NULL DEFAULT 0 COMMENT '发票状态:0-正常,1-已冲红,2-已作废',
`invoice_date` DATE NOT NULL COMMENT '开票日期',
`check_code` VARCHAR(50) COMMENT '校验码(后6位)',
`machine_code` VARCHAR(20) COMMENT '机器编号',
-- 购买方信息
`buyer_name` VARCHAR(200) NOT NULL COMMENT '购买方名称',
`buyer_tax_no` VARCHAR(20) COMMENT '购买方纳税人识别号',
`buyer_address` VARCHAR(200) COMMENT '购买方地址电话',
`buyer_bank` VARCHAR(200) COMMENT '购买方开户行及账号',
-- 销售方信息
`seller_name` VARCHAR(200) NOT NULL COMMENT '销售方名称',
`seller_tax_no` VARCHAR(20) COMMENT '销售方纳税人识别号',
`seller_address` VARCHAR(200) COMMENT '销售方地址电话',
`seller_bank` VARCHAR(200) COMMENT '销售方开户行及账号',
-- 金额信息
`amount` DECIMAL(14,2) NOT NULL COMMENT '不含税金额',
`tax_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '税额',
`total_amount` DECIMAL(14,2) NOT NULL COMMENT '价税合计',
`tax_rate` VARCHAR(20) COMMENT '税率',
-- 其他信息
`remark` VARCHAR(500) COMMENT '备注',
`drawer` VARCHAR(50) COMMENT '开票人',
`reviewer` VARCHAR(50) COMMENT '复核人',
`payee` VARCHAR(50) COMMENT '收款人',
-- 来源与附件
`source_type` VARCHAR(20) NOT NULL COMMENT '来源类型:MANUAL/OCR/IMPORT/EMAIL',
`file_url` VARCHAR(500) COMMENT '发票文件URL',
`pdf_url` VARCHAR(500) COMMENT 'PDF文件URL',
`ofd_url` VARCHAR(500) COMMENT 'OFD文件URL',
-- 验真信息
`verify_status` TINYINT DEFAULT 0 COMMENT '验真状态:0-未验真,1-验真通过,2-验真失败',
`verify_time` DATETIME COMMENT '验真时间',
`verify_result` TEXT COMMENT '验真结果JSON',
-- 业务关联
`company_id` BIGINT COMMENT '所属公司ID',
`dept_id` BIGINT COMMENT '所属部门ID',
`user_id` BIGINT COMMENT '录入人ID',
`reimbursement_id` BIGINT COMMENT '关联报销单ID',
`reimbursement_status` TINYINT DEFAULT 0 COMMENT '报销状态:0-未报销,1-报销中,2-已报销',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY `uk_invoice` (`invoice_code`, `invoice_no`),
KEY `idx_buyer_tax_no` (`buyer_tax_no`),
KEY `idx_seller_tax_no` (`seller_tax_no`),
KEY `idx_invoice_date` (`invoice_date`),
KEY `idx_company_id` (`company_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_reimbursement_status` (`reimbursement_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票主表';
-- 2. 发票明细表
CREATE TABLE `invoice_item` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`invoice_id` BIGINT NOT NULL COMMENT '发票ID',
`line_no` INT NOT NULL COMMENT '行号',
`goods_name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`specification` VARCHAR(100) COMMENT '规格型号',
`unit` VARCHAR(20) COMMENT '单位',
`quantity` DECIMAL(14,4) COMMENT '数量',
`unit_price` DECIMAL(14,6) COMMENT '单价',
`amount` DECIMAL(14,2) NOT NULL COMMENT '金额',
`tax_rate` VARCHAR(20) COMMENT '税率',
`tax_amount` DECIMAL(14,2) COMMENT '税额',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票明细表';
-- 3. 开票申请表
CREATE TABLE `invoice_apply` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`apply_no` VARCHAR(32) NOT NULL COMMENT '申请编号',
`apply_type` TINYINT NOT NULL COMMENT '申请类型:1-开票,2-冲红,3-作废',
`apply_status` TINYINT NOT NULL DEFAULT 0 COMMENT '申请状态:0-待审批,1-审批中,2-已通过,3-已拒绝,4-已开票',
-- 申请信息
`invoice_type` VARCHAR(20) NOT NULL COMMENT '发票类型',
`title_type` TINYINT NOT NULL DEFAULT 1 COMMENT '抬头类型:1-企业,2-个人',
-- 购买方信息
`buyer_name` VARCHAR(200) NOT NULL COMMENT '购买方名称',
`buyer_tax_no` VARCHAR(20) COMMENT '购买方纳税人识别号',
`buyer_address` VARCHAR(200) COMMENT '购买方地址电话',
`buyer_bank` VARCHAR(200) COMMENT '购买方开户行及账号',
`buyer_email` VARCHAR(100) COMMENT '购买方邮箱',
`buyer_phone` VARCHAR(20) COMMENT '购买方电话',
-- 商品信息
`goods_name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`tax_code` VARCHAR(50) COMMENT '税收分类编码',
`amount` DECIMAL(14,2) NOT NULL COMMENT '不含税金额',
`tax_rate` VARCHAR(20) COMMENT '税率',
`tax_amount` DECIMAL(14,2) COMMENT '税额',
`total_amount` DECIMAL(14,2) NOT NULL COMMENT '价税合计',
-- 关联信息
`order_no` VARCHAR(64) COMMENT '关联订单号',
`contract_no` VARCHAR(64) COMMENT '关联合同号',
`remark` VARCHAR(500) COMMENT '备注',
-- 申请人信息
`apply_user_id` BIGINT NOT NULL COMMENT '申请人ID',
`apply_user_name` VARCHAR(50) COMMENT '申请人姓名',
`apply_dept_id` BIGINT COMMENT '申请部门ID',
`apply_time` DATETIME NOT NULL COMMENT '申请时间',
-- 审批信息
`approve_user_id` BIGINT COMMENT '审批人ID',
`approve_user_name` VARCHAR(50) COMMENT '审批人姓名',
`approve_time` DATETIME COMMENT '审批时间',
`approve_remark` VARCHAR(500) COMMENT '审批意见',
-- 开票结果
`invoice_id` BIGINT COMMENT '生成的发票ID',
`invoice_code` VARCHAR(20) COMMENT '发票代码',
`invoice_no` VARCHAR(20) COMMENT '发票号码',
`invoice_time` DATETIME COMMENT '开票时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_apply_no` (`apply_no`),
KEY `idx_apply_status` (`apply_status`),
KEY `idx_apply_user_id` (`apply_user_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开票申请表';
-- 4. 发票抬头表(常用抬头管理)
CREATE TABLE `invoice_title` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`company_id` BIGINT NOT NULL COMMENT '所属公司ID',
`title_type` TINYINT NOT NULL DEFAULT 1 COMMENT '抬头类型:1-企业,2-个人',
`title_name` VARCHAR(200) NOT NULL COMMENT '抬头名称',
`tax_no` VARCHAR(20) COMMENT '纳税人识别号',
`address` VARCHAR(200) COMMENT '地址',
`phone` VARCHAR(50) COMMENT '电话',
`bank_name` VARCHAR(100) COMMENT '开户银行',
`bank_account` VARCHAR(50) COMMENT '银行账号',
`is_default` TINYINT DEFAULT 0 COMMENT '是否默认:0-否,1-是',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY `idx_company_id` (`company_id`),
KEY `idx_tax_no` (`tax_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票抬头表';
-- 5. 发票验真记录表
CREATE TABLE `invoice_verify_log` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`invoice_id` BIGINT NOT NULL COMMENT '发票ID',
`invoice_code` VARCHAR(20) NOT NULL COMMENT '发票代码',
`invoice_no` VARCHAR(20) NOT NULL COMMENT '发票号码',
`verify_type` VARCHAR(20) COMMENT '验真类型:TAX/THIRD_PARTY',
`verify_status` TINYINT NOT NULL COMMENT '验真状态:1-成功,2-失败',
`verify_message` VARCHAR(500) COMMENT '验真消息',
`request_data` TEXT COMMENT '请求数据',
`response_data` TEXT COMMENT '响应数据',
`verify_time` DATETIME NOT NULL COMMENT '验真时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票验真记录表';
-- 6. OCR识别记录表
CREATE TABLE `invoice_ocr_log` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`batch_no` VARCHAR(32) COMMENT '批次号',
`file_name` VARCHAR(200) COMMENT '文件名',
`file_url` VARCHAR(500) NOT NULL COMMENT '文件URL',
`file_type` VARCHAR(20) COMMENT '文件类型:IMAGE/PDF/OFD',
`ocr_status` TINYINT NOT NULL DEFAULT 0 COMMENT 'OCR状态:0-待识别,1-识别中,2-识别成功,3-识别失败',
`ocr_result` TEXT COMMENT 'OCR识别结果JSON',
`invoice_id` BIGINT COMMENT '生成的发票ID',
`error_msg` VARCHAR(500) COMMENT '错误信息',
`ocr_time` DATETIME COMMENT 'OCR识别时间',
`cost_time` INT COMMENT '耗时(毫秒)',
`user_id` BIGINT COMMENT '操作用户ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_batch_no` (`batch_no`),
KEY `idx_ocr_status` (`ocr_status`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OCR识别记录表';
-- 7. 报销单表
CREATE TABLE `reimbursement` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`reimburse_no` VARCHAR(32) NOT NULL COMMENT '报销单号',
`reimburse_type` VARCHAR(20) NOT NULL COMMENT '报销类型:TRAVEL/DAILY/PROJECT',
`reimburse_status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-草稿,1-待审批,2-审批中,3-已通过,4-已拒绝,5-已付款',
`title` VARCHAR(200) NOT NULL COMMENT '报销主题',
`total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '报销总金额',
`invoice_count` INT NOT NULL DEFAULT 0 COMMENT '发票数量',
`apply_user_id` BIGINT NOT NULL COMMENT '申请人ID',
`apply_user_name` VARCHAR(50) COMMENT '申请人姓名',
`apply_dept_id` BIGINT COMMENT '申请部门ID',
`apply_time` DATETIME COMMENT '申请时间',
`remark` VARCHAR(500) COMMENT '备注说明',
`attachment_urls` TEXT COMMENT '附件URL列表JSON',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_reimburse_no` (`reimburse_no`),
KEY `idx_apply_user_id` (`apply_user_id`),
KEY `idx_reimburse_status` (`reimburse_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销单表';
-- 8. 报销单发票关联表
CREATE TABLE `reimbursement_invoice` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`reimbursement_id` BIGINT NOT NULL COMMENT '报销单ID',
`invoice_id` BIGINT NOT NULL COMMENT '发票ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_reimburse_invoice` (`reimbursement_id`, `invoice_id`),
KEY `idx_invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销单发票关联表';
-- 9. 导入批次表
CREATE TABLE `invoice_import_batch` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`batch_no` VARCHAR(32) NOT NULL COMMENT '批次号',
`import_type` VARCHAR(20) NOT NULL COMMENT '导入类型:EXCEL/EMAIL/ZIP',
`file_name` VARCHAR(200) COMMENT '文件名',
`file_url` VARCHAR(500) COMMENT '文件URL',
`total_count` INT DEFAULT 0 COMMENT '总记录数',
`success_count` INT DEFAULT 0 COMMENT '成功数',
`fail_count` INT DEFAULT 0 COMMENT '失败数',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待处理,1-处理中,2-已完成,3-失败',
`error_file_url` VARCHAR(500) COMMENT '错误文件URL',
`user_id` BIGINT COMMENT '操作用户ID',
`start_time` DATETIME COMMENT '开始时间',
`end_time` DATETIME COMMENT '结束时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_batch_no` (`batch_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票导入批次表';
3.2 E-R 关系图
+------------------------------------------------------------------+
| E-R 关系图 |
+------------------------------------------------------------------+
| |
| +---------------+ 1:N +----------------+ |
| | invoice_apply |-------------->| invoice | |
| +---------------+ +----------------+ |
| | |
| | 1:N |
| v |
| +----------------+ |
| | invoice_item | |
| +----------------+ |
| | |
| +---------------+ | 1:N |
| | invoice_title | v |
| +---------------+ +-------------------+ |
| | invoice_verify_log| |
| +-------------------+ |
| |
| +----------------+ N:M +----------------+ |
| | reimbursement |<------------>| invoice | |
| +----------------+ +----------------+ |
| | |
| v |
| +------------------------+ |
| | reimbursement_invoice | |
| +------------------------+ |
| |
| +-------------------+ +---------------------+ |
| | invoice_ocr_log | | invoice_import_batch| |
| +-------------------+ +---------------------+ |
| |
+------------------------------------------------------------------+
四、核心代码实现
4.1 基础实体类
/**
* 发票类型枚举
*/
public enum InvoiceType {
SPECIAL("SPECIAL", "增值税专用发票"),
NORMAL("NORMAL", "增值税普通发票"),
ELECTRONIC("ELECTRONIC", "增值税电子普通发票"),
ELECTRONIC_SPECIAL("ELECTRONIC_SPECIAL", "增值税电子专用发票"),
FULL_DIGITAL("FULL_DIGITAL", "全电发票"),
ROLL("ROLL", "增值税普通发票(卷票)"),
VEHICLE("VEHICLE", "机动车销售统一发票"),
TOLL("TOLL", "通行费发票"),
TRAIN("TRAIN", "火车票"),
FLIGHT("FLIGHT", "航空运输电子客票行程单"),
QUOTA("QUOTA", "定额发票");
private final String code;
private final String desc;
InvoiceType(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getCode() { return code; }
public String getDesc() { return desc; }
}
/**
* 发票状态枚举
*/
public enum InvoiceStatus {
NORMAL(0, "正常"),
RED_FLUSHED(1, "已冲红"),
CANCELLED(2, "已作废");
private final int code;
private final String desc;
InvoiceStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() { return code; }
public String getDesc() { return desc; }
}
/**
* 验真状态枚举
*/
public enum VerifyStatus {
NOT_VERIFIED(0, "未验真"),
VERIFIED_SUCCESS(1, "验真通过"),
VERIFIED_FAILED(2, "验真失败");
private final int code;
private final String desc;
VerifyStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() { return code; }
public String getDesc() { return desc; }
}
/**
* 发票实体类
*/
@Data
@TableName("invoice")
public class Invoice {
@TableId(type = IdType.AUTO)
private Long id;
private String invoiceCode;
private String invoiceNo;
private String invoiceType;
private Integer invoiceStatus;
private LocalDate invoiceDate;
private String checkCode;
private String machineCode;
// 购买方信息
private String buyerName;
private String buyerTaxNo;
private String buyerAddress;
private String buyerBank;
// 销售方信息
private String sellerName;
private String sellerTaxNo;
private String sellerAddress;
private String sellerBank;
// 金额信息
private BigDecimal amount;
private BigDecimal taxAmount;
private BigDecimal totalAmount;
private String taxRate;
// 其他信息
private String remark;
private String drawer;
private String reviewer;
private String payee;
// 来源与附件
private String sourceType;
private String fileUrl;
private String pdfUrl;
private String ofdUrl;
// 验真信息
private Integer verifyStatus;
private LocalDateTime verifyTime;
private String verifyResult;
// 业务关联
private Long companyId;
private Long deptId;
private Long userId;
private Long reimbursementId;
private Integer reimbursementStatus;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Integer deleted;
}
/**
* 发票明细实体
*/
@Data
@TableName("invoice_item")
public class InvoiceItem {
@TableId(type = IdType.AUTO)
private Long id;
private Long invoiceId;
private Integer lineNo;
private String goodsName;
private String specification;
private String unit;
private BigDecimal quantity;
private BigDecimal unitPrice;
private BigDecimal amount;
private String taxRate;
private BigDecimal taxAmount;
private LocalDateTime createTime;
}
/**
* 发票DTO
*/
@Data
public class InvoiceDTO {
private String invoiceCode;
private String invoiceNo;
private String invoiceType;
private LocalDate invoiceDate;
private String checkCode;
private String buyerName;
private String buyerTaxNo;
private String buyerAddress;
private String buyerBank;
private String sellerName;
private String sellerTaxNo;
private String sellerAddress;
private String sellerBank;
private BigDecimal amount;
private BigDecimal taxAmount;
private BigDecimal totalAmount;
private String taxRate;
private String remark;
private String drawer;
private String reviewer;
private String payee;
private List<InvoiceItemDTO> items;
}
/**
* 发票明细DTO
*/
@Data
public class InvoiceItemDTO {
private Integer lineNo;
private String goodsName;
private String specification;
private String unit;
private BigDecimal quantity;
private BigDecimal unitPrice;
private BigDecimal amount;
private String taxRate;
private BigDecimal taxAmount;
}
4.2 OCR 识别服务
/**
* OCR识别服务接口
*/
public interface InvoiceOcrService {
/**
* 识别发票图片
*/
OcrResult recognize(String imageUrl);
/**
* 识别发票图片(Base64)
*/
OcrResult recognizeBase64(String base64Image);
/**
* 批量识别
*/
List<OcrResult> batchRecognize(List<String> imageUrls);
}
/**
* OCR识别结果
*/
@Data
public class OcrResult {
private boolean success;
private String message;
private String invoiceType;
private InvoiceDTO invoiceData;
private String rawJson;
private Long costTime;
}
/**
* 百度OCR实现
*/
@Slf4j
@Service
public class BaiduOcrServiceImpl implements InvoiceOcrService {
@Value("${baidu.ocr.app-id}")
private String appId;
@Value("${baidu.ocr.api-key}")
private String apiKey;
@Value("${baidu.ocr.secret-key}")
private String secretKey;
private AipOcr client;
@PostConstruct
public void init() {
client = new AipOcr(appId, apiKey, secretKey);
client.setConnectionTimeoutInMillis(5000);
client.setSocketTimeoutInMillis(60000);
}
@Override
public OcrResult recognize(String imageUrl) {
long startTime = System.currentTimeMillis();
OcrResult result = new OcrResult();
try {
// 调用百度OCR增值税发票识别接口
HashMap<String, String> options = new HashMap<>();
options.put("type", "auto"); // 自动识别发票类型
JSONObject response = client.vatInvoice(imageUrl, options);
log.info("百度OCR响应: {}", response.toString(2));
result.setRawJson(response.toString());
if (response.has("error_code")) {
result.setSuccess(false);
result.setMessage(response.optString("error_msg", "识别失败"));
return result;
}
// 解析识别结果
InvoiceDTO invoiceDTO = parseOcrResponse(response);
result.setSuccess(true);
result.setInvoiceData(invoiceDTO);
result.setInvoiceType(invoiceDTO.getInvoiceType());
} catch (Exception e) {
log.error("OCR识别异常", e);
result.setSuccess(false);
result.setMessage("OCR识别异常: " + e.getMessage());
}
result.setCostTime(System.currentTimeMillis() - startTime);
return result;
}
@Override
public OcrResult recognizeBase64(String base64Image) {
long startTime = System.currentTimeMillis();
OcrResult result = new OcrResult();
try {
byte[] imageBytes = Base64.getDecoder().decode(base64Image);
HashMap<String, String> options = new HashMap<>();
JSONObject response = client.vatInvoice(imageBytes, options);
result.setRawJson(response.toString());
if (response.has("error_code")) {
result.setSuccess(false);
result.setMessage(response.optString("error_msg", "识别失败"));
return result;
}
InvoiceDTO invoiceDTO = parseOcrResponse(response);
result.setSuccess(true);
result.setInvoiceData(invoiceDTO);
result.setInvoiceType(invoiceDTO.getInvoiceType());
} catch (Exception e) {
log.error("OCR识别异常", e);
result.setSuccess(false);
result.setMessage("OCR识别异常: " + e.getMessage());
}
result.setCostTime(System.currentTimeMillis() - startTime);
return result;
}
@Override
public List<OcrResult> batchRecognize(List<String> imageUrls) {
return imageUrls.parallelStream()
.map(this::recognize)
.collect(Collectors.toList());
}
/**
* 解析OCR响应
*/
private InvoiceDTO parseOcrResponse(JSONObject response) {
InvoiceDTO dto = new InvoiceDTO();
JSONObject wordsResult = response.optJSONObject("words_result");
if (wordsResult == null) {
return dto;
}
// 基本信息
dto.setInvoiceCode(getFieldValue(wordsResult, "InvoiceCode"));
dto.setInvoiceNo(getFieldValue(wordsResult, "InvoiceNum"));
dto.setInvoiceType(parseInvoiceType(response.optString("InvoiceType")));
String dateStr = getFieldValue(wordsResult, "InvoiceDate");
if (StringUtils.isNotBlank(dateStr)) {
dto.setInvoiceDate(parseDate(dateStr));
}
dto.setCheckCode(getFieldValue(wordsResult, "CheckCode"));
// 购买方信息
dto.setBuyerName(getFieldValue(wordsResult, "PurchaserName"));
dto.setBuyerTaxNo(getFieldValue(wordsResult, "PurchaserRegisterNum"));
dto.setBuyerAddress(getFieldValue(wordsResult, "PurchaserAddress"));
dto.setBuyerBank(getFieldValue(wordsResult, "PurchaserBank"));
// 销售方信息
dto.setSellerName(getFieldValue(wordsResult, "SellerName"));
dto.setSellerTaxNo(getFieldValue(wordsResult, "SellerRegisterNum"));
dto.setSellerAddress(getFieldValue(wordsResult, "SellerAddress"));
dto.setSellerBank(getFieldValue(wordsResult, "SellerBank"));
// 金额信息
dto.setAmount(parseBigDecimal(getFieldValue(wordsResult, "TotalAmount")));
dto.setTaxAmount(parseBigDecimal(getFieldValue(wordsResult, "TotalTax")));
dto.setTotalAmount(parseBigDecimal(getFieldValue(wordsResult, "AmountInFiguers")));
// 其他信息
dto.setRemark(getFieldValue(wordsResult, "Remarks"));
dto.setDrawer(getFieldValue(wordsResult, "NoteDrawer"));
dto.setReviewer(getFieldValue(wordsResult, "Checker"));
dto.setPayee(getFieldValue(wordsResult, "Payee"));
// 解析商品明细
dto.setItems(parseInvoiceItems(wordsResult));
return dto;
}
private String getFieldValue(JSONObject wordsResult, String key) {
JSONObject field = wordsResult.optJSONObject(key);
if (field != null) {
return field.optString("word", "");
}
return "";
}
private List<InvoiceItemDTO> parseInvoiceItems(JSONObject wordsResult) {
List<InvoiceItemDTO> items = new ArrayList<>();
JSONArray commodityInfos = wordsResult.optJSONArray("CommodityName");
if (commodityInfos == null) {
return items;
}
for (int i = 0; i < commodityInfos.length(); i++) {
JSONObject commodity = commodityInfos.optJSONObject(i);
if (commodity == null) continue;
InvoiceItemDTO item = new InvoiceItemDTO();
item.setLineNo(i + 1);
item.setGoodsName(commodity.optString("word", ""));
// 其他字段需要从对应数组获取
items.add(item);
}
return items;
}
private String parseInvoiceType(String typeCode) {
// 根据百度OCR返回的类型码转换
switch (typeCode) {
case "vat_invoice":
return InvoiceType.SPECIAL.getCode();
case "normal_invoice":
return InvoiceType.NORMAL.getCode();
case "elec_normal_invoice":
return InvoiceType.ELECTRONIC.getCode();
default:
return InvoiceType.NORMAL.getCode();
}
}
private LocalDate parseDate(String dateStr) {
try {
// 处理各种日期格式
dateStr = dateStr.replaceAll("[年月]", "-").replace("日", "");
return LocalDate.parse(dateStr);
} catch (Exception e) {
return null;
}
}
private BigDecimal parseBigDecimal(String value) {
if (StringUtils.isBlank(value)) {
return BigDecimal.ZERO;
}
try {
value = value.replace("¥", "").replace("¥", "")
.replace(",", "").replace(" ", "");
return new BigDecimal(value);
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
}
/**
* 阿里云OCR实现
*/
@Slf4j
@Service
public class AliyunOcrServiceImpl implements InvoiceOcrService {
@Value("${aliyun.ocr.access-key-id}")
private String accessKeyId;
@Value("${aliyun.ocr.access-key-secret}")
private String accessKeySecret;
@Value("${aliyun.ocr.endpoint}")
private String endpoint;
private Client client;
@PostConstruct
public void init() throws Exception {
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret)
.setEndpoint(endpoint);
client = new Client(config);
}
@Override
public OcrResult recognize(String imageUrl) {
long startTime = System.currentTimeMillis();
OcrResult result = new OcrResult();
try {
RecognizeInvoiceRequest request = new RecognizeInvoiceRequest();
request.setUrl(imageUrl);
RecognizeInvoiceResponse response = client.recognizeInvoice(request);
RecognizeInvoiceResponseBody body = response.getBody();
result.setRawJson(JSON.toJSONString(body));
if (body.getCode() != null && !"200".equals(body.getCode())) {
result.setSuccess(false);
result.setMessage(body.getMessage());
return result;
}
// 解析识别结果
InvoiceDTO invoiceDTO = parseAliyunResponse(body.getData());
result.setSuccess(true);
result.setInvoiceData(invoiceDTO);
result.setInvoiceType(invoiceDTO.getInvoiceType());
} catch (Exception e) {
log.error("阿里云OCR识别异常", e);
result.setSuccess(false);
result.setMessage("OCR识别异常: " + e.getMessage());
}
result.setCostTime(System.currentTimeMillis() - startTime);
return result;
}
@Override
public OcrResult recognizeBase64(String base64Image) {
// 阿里云OCR支持Base64
long startTime = System.currentTimeMillis();
OcrResult result = new OcrResult();
try {
RecognizeInvoiceRequest request = new RecognizeInvoiceRequest();
request.setBody(new ByteArrayInputStream(
Base64.getDecoder().decode(base64Image)));
RecognizeInvoiceResponse response = client.recognizeInvoice(request);
// ... 处理响应
} catch (Exception e) {
log.error("阿里云OCR识别异常", e);
result.setSuccess(false);
result.setMessage("OCR识别异常: " + e.getMessage());
}
result.setCostTime(System.currentTimeMillis() - startTime);
return result;
}
@Override
public List<OcrResult> batchRecognize(List<String> imageUrls) {
return imageUrls.parallelStream()
.map(this::recognize)
.collect(Collectors.toList());
}
private InvoiceDTO parseAliyunResponse(String data) {
// 解析阿里云OCR响应数据
JSONObject jsonData = JSON.parseObject(data);
InvoiceDTO dto = new InvoiceDTO();
// 根据阿里云返回格式解析
dto.setInvoiceCode(jsonData.getString("invoiceCode"));
dto.setInvoiceNo(jsonData.getString("invoiceNo"));
// ... 其他字段
return dto;
}
}
/**
* OCR服务门面(策略模式)
*/
@Slf4j
@Service
public class OcrServiceFacade {
@Autowired
private BaiduOcrServiceImpl baiduOcrService;
@Autowired
private AliyunOcrServiceImpl aliyunOcrService;
@Value("${ocr.provider:baidu}")
private String ocrProvider;
/**
* 识别发票
*/
public OcrResult recognize(String imageUrl) {
InvoiceOcrService ocrService = getOcrService();
return ocrService.recognize(imageUrl);
}
/**
* 识别发票(Base64)
*/
public OcrResult recognizeBase64(String base64Image) {
InvoiceOcrService ocrService = getOcrService();
return ocrService.recognizeBase64(base64Image);
}
/**
* 识别失败时自动切换备用OCR
*/
public OcrResult recognizeWithFallback(String imageUrl) {
// 先使用主OCR
OcrResult result = getOcrService().recognize(imageUrl);
// 失败时使用备用OCR
if (!result.isSuccess()) {
log.warn("主OCR识别失败,尝试备用OCR");
InvoiceOcrService fallbackService = getFallbackOcrService();
result = fallbackService.recognize(imageUrl);
}
return result;
}
private InvoiceOcrService getOcrService() {
if ("aliyun".equalsIgnoreCase(ocrProvider)) {
return aliyunOcrService;
}
return baiduOcrService;
}
private InvoiceOcrService getFallbackOcrService() {
if ("aliyun".equalsIgnoreCase(ocrProvider)) {
return baiduOcrService;
}
return aliyunOcrService;
}
}
4.3 发票验真服务
/**
* 发票验真服务接口
*/
public interface InvoiceVerifyService {
/**
* 验真发票
*/
VerifyResult verify(InvoiceVerifyRequest request);
/**
* 批量验真
*/
List<VerifyResult> batchVerify(List<InvoiceVerifyRequest> requests);
}
/**
* 验真请求
*/
@Data
public class InvoiceVerifyRequest {
private String invoiceCode; // 发票代码
private String invoiceNo; // 发票号码
private String invoiceDate; // 开票日期 yyyy-MM-dd
private String checkCode; // 校验码后6位(普票必填)
private String amount; // 不含税金额(专票必填)
private String invoiceType; // 发票类型
}
/**
* 验真结果
*/
@Data
public class VerifyResult {
private boolean success; // 是否成功
private String message; // 消息
private boolean valid; // 发票是否有效
private String invoiceStatus; // 发票状态
private InvoiceDTO invoiceData; // 发票详情(验真成功时返回)
private String rawResponse; // 原始响应
}
/**
* 税务局验真服务实现
*/
@Slf4j
@Service
public class TaxVerifyServiceImpl implements InvoiceVerifyService {
@Value("${tax.verify.api-url}")
private String apiUrl;
@Value("${tax.verify.app-key}")
private String appKey;
@Value("${tax.verify.app-secret}")
private String appSecret;
@Autowired
private RestTemplate restTemplate;
@Override
public VerifyResult verify(InvoiceVerifyRequest request) {
VerifyResult result = new VerifyResult();
try {
// 构建请求参数
Map<String, Object> params = buildRequestParams(request);
// 生成签名
String sign = generateSign(params);
params.put("sign", sign);
// 调用验真接口
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, headers);
ResponseEntity<String> response = restTemplate.exchange(
apiUrl, HttpMethod.POST, entity, String.class);
result.setRawResponse(response.getBody());
// 解析响应
JSONObject jsonResponse = JSON.parseObject(response.getBody());
String code = jsonResponse.getString("code");
if ("0000".equals(code)) {
result.setSuccess(true);
result.setValid(true);
// 解析发票详情
JSONObject data = jsonResponse.getJSONObject("data");
InvoiceDTO invoiceDTO = parseVerifyResponse(data);
result.setInvoiceData(invoiceDTO);
// 检查发票状态
String status = data.getString("invoiceStatus");
result.setInvoiceStatus(status);
if (!"正常".equals(status)) {
result.setMessage("发票状态异常: " + status);
}
} else {
result.setSuccess(false);
result.setValid(false);
result.setMessage(jsonResponse.getString("message"));
}
} catch (Exception e) {
log.error("发票验真异常", e);
result.setSuccess(false);
result.setMessage("验真服务异常: " + e.getMessage());
}
return result;
}
@Override
public List<VerifyResult> batchVerify(List<InvoiceVerifyRequest> requests) {
// 限流:每秒最多10次
return requests.stream()
.map(this::verifyWithRateLimit)
.collect(Collectors.toList());
}
private VerifyResult verifyWithRateLimit(InvoiceVerifyRequest request) {
try {
Thread.sleep(100); // 简单限流
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return verify(request);
}
private Map<String, Object> buildRequestParams(InvoiceVerifyRequest request) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("appKey", appKey);
params.put("timestamp", System.currentTimeMillis());
params.put("invoiceCode", request.getInvoiceCode());
params.put("invoiceNo", request.getInvoiceNo());
params.put("invoiceDate", request.getInvoiceDate());
if (StringUtils.isNotBlank(request.getCheckCode())) {
params.put("checkCode", request.getCheckCode());
}
if (StringUtils.isNotBlank(request.getAmount())) {
params.put("amount", request.getAmount());
}
return params;
}
private String generateSign(Map<String, Object> params) {
// 按key排序拼接
StringBuilder sb = new StringBuilder();
params.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> sb.append(entry.getKey())
.append("=")
.append(entry.getValue())
.append("&"));
sb.append("appSecret=").append(appSecret);
// MD5签名
return DigestUtils.md5Hex(sb.toString()).toUpperCase();
}
private InvoiceDTO parseVerifyResponse(JSONObject data) {
InvoiceDTO dto = new InvoiceDTO();
dto.setInvoiceCode(data.getString("invoiceCode"));
dto.setInvoiceNo(data.getString("invoiceNo"));
dto.setBuyerName(data.getString("buyerName"));
dto.setBuyerTaxNo(data.getString("buyerTaxNo"));
dto.setSellerName(data.getString("sellerName"));
dto.setSellerTaxNo(data.getString("sellerTaxNo"));
dto.setAmount(data.getBigDecimal("amount"));
dto.setTaxAmount(data.getBigDecimal("taxAmount"));
dto.setTotalAmount(data.getBigDecimal("totalAmount"));
// ... 其他字段
return dto;
}
}
4.4 发票导入服务
/**
* 发票导入服务
*/
@Slf4j
@Service
public class InvoiceImportService {
@Autowired
private InvoiceMapper invoiceMapper;
@Autowired
private InvoiceImportBatchMapper batchMapper;
@Autowired
private OcrServiceFacade ocrService;
@Autowired
private InvoiceVerifyService verifyService;
@Autowired
private OssService ossService;
/**
* Excel导入发票
*/
@Async
public void importFromExcel(Long batchId, InputStream inputStream) {
InvoiceImportBatch batch = batchMapper.selectById(batchId);
batch.setStatus(1); // 处理中
batch.setStartTime(LocalDateTime.now());
batchMapper.updateById(batch);
List<InvoiceImportResult> results = new ArrayList<>();
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
try {
EasyExcel.read(inputStream, InvoiceExcelDTO.class, new ReadListener<InvoiceExcelDTO>() {
@Override
public void invoke(InvoiceExcelDTO data, AnalysisContext context) {
InvoiceImportResult result = processExcelRow(data);
results.add(result);
if (result.isSuccess()) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("Excel解析完成,共{}条", results.size());
}
}).sheet().doRead();
// 更新批次状态
batch.setStatus(2); // 已完成
batch.setTotalCount(results.size());
batch.setSuccessCount(successCount.get());
batch.setFailCount(failCount.get());
// 如果有失败记录,生成错误文件
if (failCount.get() > 0) {
String errorFileUrl = generateErrorFile(results);
batch.setErrorFileUrl(errorFileUrl);
}
} catch (Exception e) {
log.error("Excel导入异常", e);
batch.setStatus(3); // 失败
}
batch.setEndTime(LocalDateTime.now());
batchMapper.updateById(batch);
}
/**
* 图片批量导入(OCR识别)
*/
@Async
public void importFromImages(Long batchId, List<String> imageUrls) {
InvoiceImportBatch batch = batchMapper.selectById(batchId);
batch.setStatus(1);
batch.setStartTime(LocalDateTime.now());
batch.setTotalCount(imageUrls.size());
batchMapper.updateById(batch);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
for (String imageUrl : imageUrls) {
try {
// OCR识别
OcrResult ocrResult = ocrService.recognizeWithFallback(imageUrl);
if (ocrResult.isSuccess()) {
// 保存发票
Invoice invoice = convertToInvoice(ocrResult.getInvoiceData());
invoice.setSourceType("OCR");
invoice.setFileUrl(imageUrl);
// 查重
if (checkDuplicate(invoice.getInvoiceCode(), invoice.getInvoiceNo())) {
log.warn("发票已存在: {}-{}", invoice.getInvoiceCode(), invoice.getInvoiceNo());
failCount.incrementAndGet();
continue;
}
invoiceMapper.insert(invoice);
successCount.incrementAndGet();
// 记录OCR日志
saveOcrLog(batchId, imageUrl, ocrResult, invoice.getId());
} else {
failCount.incrementAndGet();
saveOcrLog(batchId, imageUrl, ocrResult, null);
}
} catch (Exception e) {
log.error("处理图片失败: {}", imageUrl, e);
failCount.incrementAndGet();
}
}
// 更新批次状态
batch.setStatus(2);
batch.setSuccessCount(successCount.get());
batch.setFailCount(failCount.get());
batch.setEndTime(LocalDateTime.now());
batchMapper.updateById(batch);
}
/**
* 邮件发票导入
*/
@Async
public void importFromEmail(Long batchId, String emailContent, List<String> attachmentUrls) {
InvoiceImportBatch batch = batchMapper.selectById(batchId);
batch.setStatus(1);
batch.setStartTime(LocalDateTime.now());
batchMapper.updateById(batch);
int successCount = 0;
int failCount = 0;
for (String attachmentUrl : attachmentUrls) {
try {
String fileType = getFileType(attachmentUrl);
if ("PDF".equals(fileType) || "OFD".equals(fileType)) {
// PDF/OFD 文件直接解析
InvoiceDTO invoiceDTO = parseElectronicInvoice(attachmentUrl, fileType);
if (invoiceDTO != null) {
Invoice invoice = convertToInvoice(invoiceDTO);
invoice.setSourceType("EMAIL");
invoice.setPdfUrl("PDF".equals(fileType) ? attachmentUrl : null);
invoice.setOfdUrl("OFD".equals(fileType) ? attachmentUrl : null);
if (!checkDuplicate(invoice.getInvoiceCode(), invoice.getInvoiceNo())) {
invoiceMapper.insert(invoice);
successCount++;
}
}
} else if ("IMAGE".equals(fileType)) {
// 图片用OCR识别
OcrResult ocrResult = ocrService.recognize(attachmentUrl);
if (ocrResult.isSuccess()) {
Invoice invoice = convertToInvoice(ocrResult.getInvoiceData());
invoice.setSourceType("EMAIL");
invoice.setFileUrl(attachmentUrl);
if (!checkDuplicate(invoice.getInvoiceCode(), invoice.getInvoiceNo())) {
invoiceMapper.insert(invoice);
successCount++;
}
} else {
failCount++;
}
}
} catch (Exception e) {
log.error("处理邮件附件失败: {}", attachmentUrl, e);
failCount++;
}
}
batch.setStatus(2);
batch.setTotalCount(attachmentUrls.size());
batch.setSuccessCount(successCount);
batch.setFailCount(failCount);
batch.setEndTime(LocalDateTime.now());
batchMapper.updateById(batch);
}
/**
* 处理Excel行数据
*/
private InvoiceImportResult processExcelRow(InvoiceExcelDTO data) {
InvoiceImportResult result = new InvoiceImportResult();
result.setRowData(data);
try {
// 数据校验
List<String> errors = validateExcelData(data);
if (!errors.isEmpty()) {
result.setSuccess(false);
result.setErrorMessage(String.join("; ", errors));
return result;
}
// 查重
if (checkDuplicate(data.getInvoiceCode(), data.getInvoiceNo())) {
result.setSuccess(false);
result.setErrorMessage("发票已存在");
return result;
}
// 转换并保存
Invoice invoice = convertExcelToInvoice(data);
invoice.setSourceType("IMPORT");
invoiceMapper.insert(invoice);
result.setSuccess(true);
result.setInvoiceId(invoice.getId());
} catch (Exception e) {
log.error("处理Excel行数据失败", e);
result.setSuccess(false);
result.setErrorMessage("处理失败: " + e.getMessage());
}
return result;
}
/**
* 校验Excel数据
*/
private List<String> validateExcelData(InvoiceExcelDTO data) {
List<String> errors = new ArrayList<>();
if (StringUtils.isBlank(data.getInvoiceCode())) {
errors.add("发票代码不能为空");
}
if (StringUtils.isBlank(data.getInvoiceNo())) {
errors.add("发票号码不能为空");
}
if (data.getInvoiceDate() == null) {
errors.add("开票日期不能为空");
}
if (data.getTotalAmount() == null || data.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
errors.add("金额无效");
}
return errors;
}
/**
* 查重
*/
private boolean checkDuplicate(String invoiceCode, String invoiceNo) {
return invoiceMapper.selectByCodeAndNo(invoiceCode, invoiceNo) != null;
}
private Invoice convertToInvoice(InvoiceDTO dto) {
Invoice invoice = new Invoice();
BeanUtils.copyProperties(dto, invoice);
invoice.setInvoiceStatus(InvoiceStatus.NORMAL.getCode());
invoice.setVerifyStatus(VerifyStatus.NOT_VERIFIED.getCode());
invoice.setReimbursementStatus(0);
return invoice;
}
private Invoice convertExcelToInvoice(InvoiceExcelDTO dto) {
Invoice invoice = new Invoice();
invoice.setInvoiceCode(dto.getInvoiceCode());
invoice.setInvoiceNo(dto.getInvoiceNo());
invoice.setInvoiceType(dto.getInvoiceType());
invoice.setInvoiceDate(dto.getInvoiceDate());
invoice.setCheckCode(dto.getCheckCode());
invoice.setBuyerName(dto.getBuyerName());
invoice.setBuyerTaxNo(dto.getBuyerTaxNo());
invoice.setSellerName(dto.getSellerName());
invoice.setSellerTaxNo(dto.getSellerTaxNo());
invoice.setAmount(dto.getAmount());
invoice.setTaxAmount(dto.getTaxAmount());
invoice.setTotalAmount(dto.getTotalAmount());
invoice.setInvoiceStatus(InvoiceStatus.NORMAL.getCode());
invoice.setVerifyStatus(VerifyStatus.NOT_VERIFIED.getCode());
return invoice;
}
private void saveOcrLog(Long batchId, String imageUrl, OcrResult result, Long invoiceId) {
// 保存OCR识别日志
}
private String generateErrorFile(List<InvoiceImportResult> results) {
// 生成错误文件并上传OSS
return null;
}
private String getFileType(String url) {
String lowerUrl = url.toLowerCase();
if (lowerUrl.endsWith(".pdf")) return "PDF";
if (lowerUrl.endsWith(".ofd")) return "OFD";
if (lowerUrl.matches(".*\\.(jpg|jpeg|png|gif|bmp)$")) return "IMAGE";
return "UNKNOWN";
}
private InvoiceDTO parseElectronicInvoice(String fileUrl, String fileType) {
// 解析电子发票文件
return null;
}
/**
* Excel导入DTO
*/
@Data
public static class InvoiceExcelDTO {
@ExcelProperty("发票代码")
private String invoiceCode;
@ExcelProperty("发票号码")
private String invoiceNo;
@ExcelProperty("发票类型")
private String invoiceType;
@ExcelProperty("开票日期")
private LocalDate invoiceDate;
@ExcelProperty("校验码")
private String checkCode;
@ExcelProperty("购买方名称")
private String buyerName;
@ExcelProperty("购买方税号")
private String buyerTaxNo;
@ExcelProperty("销售方名称")
private String sellerName;
@ExcelProperty("销售方税号")
private String sellerTaxNo;
@ExcelProperty("不含税金额")
private BigDecimal amount;
@ExcelProperty("税额")
private BigDecimal taxAmount;
@ExcelProperty("价税合计")
private BigDecimal totalAmount;
}
@Data
public static class InvoiceImportResult {
private InvoiceExcelDTO rowData;
private boolean success;
private String errorMessage;
private Long invoiceId;
}
}
4.5 开票服务
/**
* 开票服务
*/
@Slf4j
@Service
public class InvoiceIssueService {
@Autowired
private InvoiceApplyMapper applyMapper;
@Autowired
private InvoiceMapper invoiceMapper;
@Autowired
private InvoiceItemMapper itemMapper;
@Autowired
private TaxControlService taxControlService;
@Autowired
private NotificationService notificationService;
/**
* 创建开票申请
*/
@Transactional
public InvoiceApply createApply(InvoiceApplyDTO dto, Long userId) {
// 校验购买方信息
validateBuyerInfo(dto);
InvoiceApply apply = new InvoiceApply();
apply.setApplyNo(generateApplyNo());
apply.setApplyType(1); // 开票
apply.setApplyStatus(0); // 待审批
// 设置发票信息
apply.setInvoiceType(dto.getInvoiceType());
apply.setTitleType(dto.getTitleType());
apply.setBuyerName(dto.getBuyerName());
apply.setBuyerTaxNo(dto.getBuyerTaxNo());
apply.setBuyerAddress(dto.getBuyerAddress());
apply.setBuyerBank(dto.getBuyerBank());
apply.setBuyerEmail(dto.getBuyerEmail());
apply.setBuyerPhone(dto.getBuyerPhone());
// 设置商品信息
apply.setGoodsName(dto.getGoodsName());
apply.setTaxCode(dto.getTaxCode());
apply.setAmount(dto.getAmount());
apply.setTaxRate(dto.getTaxRate());
// 计算税额
BigDecimal taxRate = new BigDecimal(dto.getTaxRate().replace("%", ""))
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
BigDecimal taxAmount = dto.getAmount().multiply(taxRate)
.setScale(2, RoundingMode.HALF_UP);
apply.setTaxAmount(taxAmount);
apply.setTotalAmount(dto.getAmount().add(taxAmount));
apply.setOrderNo(dto.getOrderNo());
apply.setContractNo(dto.getContractNo());
apply.setRemark(dto.getRemark());
// 申请人信息
apply.setApplyUserId(userId);
apply.setApplyTime(LocalDateTime.now());
applyMapper.insert(apply);
return apply;
}
/**
* 审批开票申请
*/
@Transactional
public void approveApply(Long applyId, boolean approved, String remark, Long approveUserId) {
InvoiceApply apply = applyMapper.selectById(applyId);
if (apply == null) {
throw new RuntimeException("申请不存在");
}
if (apply.getApplyStatus() != 0 && apply.getApplyStatus() != 1) {
throw new RuntimeException("申请状态不允许审批");
}
apply.setApproveUserId(approveUserId);
apply.setApproveTime(LocalDateTime.now());
apply.setApproveRemark(remark);
if (approved) {
apply.setApplyStatus(2); // 已通过
// 自动开票
issueInvoice(apply);
} else {
apply.setApplyStatus(3); // 已拒绝
}
applyMapper.updateById(apply);
}
/**
* 执行开票
*/
@Transactional
public Invoice issueInvoice(InvoiceApply apply) {
log.info("开始开票,申请编号: {}", apply.getApplyNo());
try {
// 1. 调用税控系统开票
TaxInvoiceRequest taxRequest = buildTaxRequest(apply);
TaxInvoiceResponse taxResponse = taxControlService.issueInvoice(taxRequest);
if (!taxResponse.isSuccess()) {
throw new RuntimeException("税控开票失败: " + taxResponse.getMessage());
}
// 2. 保存发票信息
Invoice invoice = new Invoice();
invoice.setInvoiceCode(taxResponse.getInvoiceCode());
invoice.setInvoiceNo(taxResponse.getInvoiceNo());
invoice.setInvoiceType(apply.getInvoiceType());
invoice.setInvoiceStatus(InvoiceStatus.NORMAL.getCode());
invoice.setInvoiceDate(LocalDate.now());
invoice.setCheckCode(taxResponse.getCheckCode());
invoice.setBuyerName(apply.getBuyerName());
invoice.setBuyerTaxNo(apply.getBuyerTaxNo());
invoice.setBuyerAddress(apply.getBuyerAddress());
invoice.setBuyerBank(apply.getBuyerBank());
// 销售方信息从配置获取
invoice.setSellerName(getSellerName());
invoice.setSellerTaxNo(getSellerTaxNo());
invoice.setSellerAddress(getSellerAddress());
invoice.setSellerBank(getSellerBank());
invoice.setAmount(apply.getAmount());
invoice.setTaxAmount(apply.getTaxAmount());
invoice.setTotalAmount(apply.getTotalAmount());
invoice.setTaxRate(apply.getTaxRate());
invoice.setRemark(apply.getRemark());
invoice.setSourceType("ISSUE");
invoice.setPdfUrl(taxResponse.getPdfUrl());
invoice.setVerifyStatus(VerifyStatus.VERIFIED_SUCCESS.getCode());
invoiceMapper.insert(invoice);
// 3. 保存发票明细
InvoiceItem item = new InvoiceItem();
item.setInvoiceId(invoice.getId());
item.setLineNo(1);
item.setGoodsName(apply.getGoodsName());
item.setAmount(apply.getAmount());
item.setTaxRate(apply.getTaxRate());
item.setTaxAmount(apply.getTaxAmount());
itemMapper.insert(item);
// 4. 更新申请状态
apply.setApplyStatus(4); // 已开票
apply.setInvoiceId(invoice.getId());
apply.setInvoiceCode(invoice.getInvoiceCode());
apply.setInvoiceNo(invoice.getInvoiceNo());
apply.setInvoiceTime(LocalDateTime.now());
applyMapper.updateById(apply);
// 5. 发送发票通知
sendInvoiceNotification(apply, invoice);
log.info("开票成功,发票代码: {}, 发票号码: {}", invoice.getInvoiceCode(), invoice.getInvoiceNo());
return invoice;
} catch (Exception e) {
log.error("开票失败", e);
throw new RuntimeException("开票失败: " + e.getMessage());
}
}
/**
* 发票冲红
*/
@Transactional
public Invoice redFlush(Long invoiceId, String reason) {
Invoice originalInvoice = invoiceMapper.selectById(invoiceId);
if (originalInvoice == null) {
throw new RuntimeException("原发票不存在");
}
if (originalInvoice.getInvoiceStatus() != InvoiceStatus.NORMAL.getCode()) {
throw new RuntimeException("发票状态不允许冲红");
}
// 调用税控系统开红票
TaxRedFlushRequest request = new TaxRedFlushRequest();
request.setOriginalInvoiceCode(originalInvoice.getInvoiceCode());
request.setOriginalInvoiceNo(originalInvoice.getInvoiceNo());
request.setReason(reason);
TaxInvoiceResponse response = taxControlService.redFlush(request);
if (!response.isSuccess()) {
throw new RuntimeException("冲红失败: " + response.getMessage());
}
// 更新原发票状态
originalInvoice.setInvoiceStatus(InvoiceStatus.RED_FLUSHED.getCode());
invoiceMapper.updateById(originalInvoice);
// 保存红字发票
Invoice redInvoice = new Invoice();
BeanUtils.copyProperties(originalInvoice, redInvoice);
redInvoice.setId(null);
redInvoice.setInvoiceCode(response.getInvoiceCode());
redInvoice.setInvoiceNo(response.getInvoiceNo());
redInvoice.setAmount(originalInvoice.getAmount().negate());
redInvoice.setTaxAmount(originalInvoice.getTaxAmount().negate());
redInvoice.setTotalAmount(originalInvoice.getTotalAmount().negate());
redInvoice.setRemark("冲红原因: " + reason);
redInvoice.setInvoiceDate(LocalDate.now());
invoiceMapper.insert(redInvoice);
return redInvoice;
}
private void validateBuyerInfo(InvoiceApplyDTO dto) {
if (dto.getTitleType() == 1) { // 企业
if (StringUtils.isBlank(dto.getBuyerTaxNo())) {
throw new RuntimeException("企业抬头必须填写纳税人识别号");
}
}
}
private String generateApplyNo() {
return "INV" + System.currentTimeMillis() +
String.format("%04d", new Random().nextInt(10000));
}
private TaxInvoiceRequest buildTaxRequest(InvoiceApply apply) {
TaxInvoiceRequest request = new TaxInvoiceRequest();
// 构建税控请求
return request;
}
private void sendInvoiceNotification(InvoiceApply apply, Invoice invoice) {
// 发送邮件
if (StringUtils.isNotBlank(apply.getBuyerEmail())) {
notificationService.sendInvoiceEmail(
apply.getBuyerEmail(),
invoice.getInvoiceCode(),
invoice.getInvoiceNo(),
invoice.getPdfUrl()
);
}
// 发送短信
if (StringUtils.isNotBlank(apply.getBuyerPhone())) {
notificationService.sendInvoiceSms(
apply.getBuyerPhone(),
invoice.getInvoiceCode(),
invoice.getInvoiceNo()
);
}
}
private String getSellerName() {
return "XX科技有限公司";
}
private String getSellerTaxNo() {
return "91110000MA12345678";
}
private String getSellerAddress() {
return "北京市朝阳区XX路XX号";
}
private String getSellerBank() {
return "工商银行北京分行 1234567890";
}
}
/**
* 开票申请DTO
*/
@Data
public class InvoiceApplyDTO {
private String invoiceType;
private Integer titleType;
private String buyerName;
private String buyerTaxNo;
private String buyerAddress;
private String buyerBank;
private String buyerEmail;
private String buyerPhone;
private String goodsName;
private String taxCode;
private BigDecimal amount;
private String taxRate;
private String orderNo;
private String contractNo;
private String remark;
}
4.6 报销服务
/**
* 报销服务
*/
@Slf4j
@Service
public class ReimbursementService {
@Autowired
private ReimbursementMapper reimbursementMapper;
@Autowired
private ReimbursementInvoiceMapper reimbursementInvoiceMapper;
@Autowired
private InvoiceMapper invoiceMapper;
@Autowired
private InvoiceVerifyService verifyService;
/**
* 创建报销单
*/
@Transactional
public Reimbursement createReimbursement(ReimbursementDTO dto, Long userId) {
// 校验发票
List<Invoice> invoices = validateAndGetInvoices(dto.getInvoiceIds());
// 计算总金额
BigDecimal totalAmount = invoices.stream()
.map(Invoice::getTotalAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 创建报销单
Reimbursement reimbursement = new Reimbursement();
reimbursement.setReimburseNo(generateReimburseNo());
reimbursement.setReimburseType(dto.getReimburseType());
reimbursement.setReimburseStatus(0); // 草稿
reimbursement.setTitle(dto.getTitle());
reimbursement.setTotalAmount(totalAmount);
reimbursement.setInvoiceCount(invoices.size());
reimbursement.setApplyUserId(userId);
reimbursement.setRemark(dto.getRemark());
if (dto.getAttachmentUrls() != null) {
reimbursement.setAttachmentUrls(JSON.toJSONString(dto.getAttachmentUrls()));
}
reimbursementMapper.insert(reimbursement);
// 关联发票
for (Long invoiceId : dto.getInvoiceIds()) {
ReimbursementInvoice ri = new ReimbursementInvoice();
ri.setReimbursementId(reimbursement.getId());
ri.setInvoiceId(invoiceId);
reimbursementInvoiceMapper.insert(ri);
// 更新发票报销状态
Invoice invoice = invoiceMapper.selectById(invoiceId);
invoice.setReimbursementId(reimbursement.getId());
invoice.setReimbursementStatus(1); // 报销中
invoiceMapper.updateById(invoice);
}
return reimbursement;
}
/**
* 提交报销单
*/
@Transactional
public void submitReimbursement(Long reimbursementId, Long userId) {
Reimbursement reimbursement = reimbursementMapper.selectById(reimbursementId);
if (reimbursement == null) {
throw new RuntimeException("报销单不存在");
}
if (reimbursement.getReimburseStatus() != 0) {
throw new RuntimeException("报销单状态不允许提交");
}
// 验真所有关联发票
List<Long> invoiceIds = reimbursementInvoiceMapper.selectInvoiceIds(reimbursementId);
for (Long invoiceId : invoiceIds) {
Invoice invoice = invoiceMapper.selectById(invoiceId);
if (invoice.getVerifyStatus() != VerifyStatus.VERIFIED_SUCCESS.getCode()) {
// 执行验真
InvoiceVerifyRequest request = buildVerifyRequest(invoice);
VerifyResult result = verifyService.verify(request);
invoice.setVerifyStatus(result.isValid()
? VerifyStatus.VERIFIED_SUCCESS.getCode()
: VerifyStatus.VERIFIED_FAILED.getCode());
invoice.setVerifyTime(LocalDateTime.now());
invoice.setVerifyResult(result.getRawResponse());
invoiceMapper.updateById(invoice);
if (!result.isValid()) {
throw new RuntimeException("发票验真失败: " + invoice.getInvoiceNo());
}
}
}
// 更新报销单状态
reimbursement.setReimburseStatus(1); // 待审批
reimbursement.setApplyTime(LocalDateTime.now());
reimbursementMapper.updateById(reimbursement);
}
/**
* 审批报销单
*/
@Transactional
public void approveReimbursement(Long reimbursementId, boolean approved,
String remark, Long approveUserId) {
Reimbursement reimbursement = reimbursementMapper.selectById(reimbursementId);
if (reimbursement == null) {
throw new RuntimeException("报销单不存在");
}
if (approved) {
reimbursement.setReimburseStatus(3); // 已通过
// 更新发票报销状态
List<Long> invoiceIds = reimbursementInvoiceMapper.selectInvoiceIds(reimbursementId);
for (Long invoiceId : invoiceIds) {
Invoice invoice = invoiceMapper.selectById(invoiceId);
invoice.setReimbursementStatus(2); // 已报销
invoiceMapper.updateById(invoice);
}
} else {
reimbursement.setReimburseStatus(4); // 已拒绝
// 释放发票
List<Long> invoiceIds = reimbursementInvoiceMapper.selectInvoiceIds(reimbursementId);
for (Long invoiceId : invoiceIds) {
Invoice invoice = invoiceMapper.selectById(invoiceId);
invoice.setReimbursementId(null);
invoice.setReimbursementStatus(0); // 未报销
invoiceMapper.updateById(invoice);
}
}
reimbursementMapper.updateById(reimbursement);
}
/**
* 校验并获取发票
*/
private List<Invoice> validateAndGetInvoices(List<Long> invoiceIds) {
List<Invoice> invoices = new ArrayList<>();
for (Long invoiceId : invoiceIds) {
Invoice invoice = invoiceMapper.selectById(invoiceId);
if (invoice == null) {
throw new RuntimeException("发票不存在: " + invoiceId);
}
// 检查发票状态
if (invoice.getInvoiceStatus() != InvoiceStatus.NORMAL.getCode()) {
throw new RuntimeException("发票状态异常: " + invoice.getInvoiceNo());
}
// 检查是否已报销
if (invoice.getReimbursementStatus() == 2) {
throw new RuntimeException("发票已报销: " + invoice.getInvoiceNo());
}
// 检查是否已关联其他报销单
if (invoice.getReimbursementId() != null) {
throw new RuntimeException("发票已关联其他报销单: " + invoice.getInvoiceNo());
}
invoices.add(invoice);
}
return invoices;
}
private InvoiceVerifyRequest buildVerifyRequest(Invoice invoice) {
InvoiceVerifyRequest request = new InvoiceVerifyRequest();
request.setInvoiceCode(invoice.getInvoiceCode());
request.setInvoiceNo(invoice.getInvoiceNo());
request.setInvoiceDate(invoice.getInvoiceDate().toString());
request.setCheckCode(invoice.getCheckCode());
request.setAmount(invoice.getAmount().toString());
request.setInvoiceType(invoice.getInvoiceType());
return request;
}
private String generateReimburseNo() {
return "RB" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) +
String.format("%06d", new Random().nextInt(1000000));
}
}
@Data
public class ReimbursementDTO {
private String reimburseType;
private String title;
private List<Long> invoiceIds;
private String remark;
private List<String> attachmentUrls;
}
五、API 接口设计
5.1 发票管理接口
/**
* 发票管理控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/invoice")
public class InvoiceController {
@Autowired
private InvoiceService invoiceService;
@Autowired
private InvoiceImportService importService;
@Autowired
private OcrServiceFacade ocrService;
@Autowired
private InvoiceVerifyService verifyService;
/**
* 发票列表查询
*/
@GetMapping("/list")
public Result<PageInfo<Invoice>> list(InvoiceQueryDTO query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<Invoice> invoices = invoiceService.queryList(query);
return Result.success(new PageInfo<>(invoices));
}
/**
* 发票详情
*/
@GetMapping("/{id}")
public Result<InvoiceDetailVO> detail(@PathVariable Long id) {
InvoiceDetailVO detail = invoiceService.getDetail(id);
return Result.success(detail);
}
/**
* 手动录入发票
*/
@PostMapping("/manual")
public Result<Invoice> manualCreate(@RequestBody @Valid InvoiceDTO dto) {
Invoice invoice = invoiceService.manualCreate(dto, getCurrentUserId());
return Result.success(invoice);
}
/**
* OCR识别发票
*/
@PostMapping("/ocr")
public Result<OcrResult> ocrRecognize(@RequestParam("file") MultipartFile file) {
try {
// 上传文件
String imageUrl = uploadFile(file);
// OCR识别
OcrResult result = ocrService.recognizeWithFallback(imageUrl);
return Result.success(result);
} catch (Exception e) {
log.error("OCR识别失败", e);
return Result.fail("识别失败: " + e.getMessage());
}
}
/**
* OCR识别并保存发票
*/
@PostMapping("/ocr/save")
public Result<Invoice> ocrAndSave(@RequestParam("file") MultipartFile file) {
try {
String imageUrl = uploadFile(file);
OcrResult result = ocrService.recognizeWithFallback(imageUrl);
if (!result.isSuccess()) {
return Result.fail("识别失败: " + result.getMessage());
}
// 保存发票
Invoice invoice = invoiceService.saveFromOcr(result.getInvoiceData(),
imageUrl, getCurrentUserId());
return Result.success(invoice);
} catch (Exception e) {
log.error("OCR保存失败", e);
return Result.fail("保存失败: " + e.getMessage());
}
}
/**
* 批量上传发票图片
*/
@PostMapping("/upload/batch")
public Result<String> batchUpload(@RequestParam("files") List<MultipartFile> files) {
try {
// 上传文件
List<String> imageUrls = new ArrayList<>();
for (MultipartFile file : files) {
String imageUrl = uploadFile(file);
imageUrls.add(imageUrl);
}
// 创建导入批次
String batchNo = importService.createBatch("IMAGE", getCurrentUserId());
// 异步处理
Long batchId = importService.getBatchId(batchNo);
importService.importFromImages(batchId, imageUrls);
return Result.success(batchNo);
} catch (Exception e) {
log.error("批量上传失败", e);
return Result.fail("上传失败: " + e.getMessage());
}
}
/**
* Excel导入发票
*/
@PostMapping("/import/excel")
public Result<String> importExcel(@RequestParam("file") MultipartFile file) {
try {
// 创建导入批次
String batchNo = importService.createBatch("EXCEL", getCurrentUserId());
Long batchId = importService.getBatchId(batchNo);
// 异步导入
importService.importFromExcel(batchId, file.getInputStream());
return Result.success(batchNo);
} catch (Exception e) {
log.error("Excel导入失败", e);
return Result.fail("导入失败: " + e.getMessage());
}
}
/**
* 查询导入进度
*/
@GetMapping("/import/progress/{batchNo}")
public Result<InvoiceImportBatch> importProgress(@PathVariable String batchNo) {
InvoiceImportBatch batch = importService.getBatch(batchNo);
return Result.success(batch);
}
/**
* 发票验真
*/
@PostMapping("/verify")
public Result<VerifyResult> verify(@RequestBody InvoiceVerifyRequest request) {
VerifyResult result = verifyService.verify(request);
return Result.success(result);
}
/**
* 批量验真
*/
@PostMapping("/verify/batch")
public Result<?> batchVerify(@RequestBody List<Long> invoiceIds) {
invoiceService.batchVerify(invoiceIds);
return Result.success();
}
/**
* 删除发票
*/
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable Long id) {
invoiceService.delete(id, getCurrentUserId());
return Result.success();
}
/**
* 导出发票
*/
@GetMapping("/export")
public void export(InvoiceQueryDTO query, HttpServletResponse response) throws Exception {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=invoices.xlsx");
invoiceService.export(query, response.getOutputStream());
}
private String uploadFile(MultipartFile file) throws Exception {
// 上传到OSS
return null;
}
private Long getCurrentUserId() {
return 1L; // 从上下文获取
}
}
/**
* 开票申请控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/invoice/apply")
public class InvoiceApplyController {
@Autowired
private InvoiceIssueService issueService;
/**
* 创建开票申请
*/
@PostMapping
public Result<InvoiceApply> create(@RequestBody @Valid InvoiceApplyDTO dto) {
InvoiceApply apply = issueService.createApply(dto, getCurrentUserId());
return Result.success(apply);
}
/**
* 开票申请列表
*/
@GetMapping("/list")
public Result<PageInfo<InvoiceApply>> list(InvoiceApplyQueryDTO query) {
// ...
return null;
}
/**
* 审批开票申请
*/
@PostMapping("/{id}/approve")
public Result<?> approve(@PathVariable Long id,
@RequestParam boolean approved,
@RequestParam(required = false) String remark) {
issueService.approveApply(id, approved, remark, getCurrentUserId());
return Result.success();
}
/**
* 发票冲红
*/
@PostMapping("/red-flush")
public Result<Invoice> redFlush(@RequestParam Long invoiceId,
@RequestParam String reason) {
Invoice redInvoice = issueService.redFlush(invoiceId, reason);
return Result.success(redInvoice);
}
private Long getCurrentUserId() {
return 1L;
}
}
/**
* 报销控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/reimbursement")
public class ReimbursementController {
@Autowired
private ReimbursementService reimbursementService;
/**
* 创建报销单
*/
@PostMapping
public Result<Reimbursement> create(@RequestBody @Valid ReimbursementDTO dto) {
Reimbursement reimbursement = reimbursementService.createReimbursement(dto, getCurrentUserId());
return Result.success(reimbursement);
}
/**
* 报销单列表
*/
@GetMapping("/list")
public Result<PageInfo<Reimbursement>> list(ReimbursementQueryDTO query) {
// ...
return null;
}
/**
* 提交报销单
*/
@PostMapping("/{id}/submit")
public Result<?> submit(@PathVariable Long id) {
reimbursementService.submitReimbursement(id, getCurrentUserId());
return Result.success();
}
/**
* 审批报销单
*/
@PostMapping("/{id}/approve")
public Result<?> approve(@PathVariable Long id,
@RequestParam boolean approved,
@RequestParam(required = false) String remark) {
reimbursementService.approveReimbursement(id, approved, remark, getCurrentUserId());
return Result.success();
}
private Long getCurrentUserId() {
return 1L;
}
}
六、统计分析
6.1 发票统计服务
/**
* 发票统计服务
*/
@Slf4j
@Service
public class InvoiceStatisticsService {
@Autowired
private InvoiceMapper invoiceMapper;
/**
* 获取发票统计概览
*/
public InvoiceStatisticsVO getStatisticsOverview(Long companyId, LocalDate startDate, LocalDate endDate) {
InvoiceStatisticsVO vo = new InvoiceStatisticsVO();
// 收票统计
vo.setReceiveCount(invoiceMapper.countBySource(companyId, startDate, endDate, null));
vo.setReceiveAmount(invoiceMapper.sumAmountBySource(companyId, startDate, endDate, null));
// 开票统计
vo.setIssueCount(invoiceMapper.countBySource(companyId, startDate, endDate, "ISSUE"));
vo.setIssueAmount(invoiceMapper.sumAmountBySource(companyId, startDate, endDate, "ISSUE"));
// 验真统计
vo.setVerifiedCount(invoiceMapper.countByVerifyStatus(companyId, startDate, endDate,
VerifyStatus.VERIFIED_SUCCESS.getCode()));
vo.setUnverifiedCount(invoiceMapper.countByVerifyStatus(companyId, startDate, endDate,
VerifyStatus.NOT_VERIFIED.getCode()));
// 报销统计
vo.setReimbursedCount(invoiceMapper.countByReimbursementStatus(companyId, startDate, endDate, 2));
vo.setReimbursedAmount(invoiceMapper.sumAmountByReimbursementStatus(companyId, startDate, endDate, 2));
vo.setPendingReimbursementCount(invoiceMapper.countByReimbursementStatus(companyId, startDate, endDate, 0));
// 税额统计
vo.setTotalTaxAmount(invoiceMapper.sumTaxAmount(companyId, startDate, endDate));
// 按类型统计
vo.setTypeStatistics(invoiceMapper.groupByType(companyId, startDate, endDate));
// 按月份趋势
vo.setMonthlyTrend(invoiceMapper.groupByMonth(companyId, startDate, endDate));
return vo;
}
/**
* 进项税额统计(可抵扣)
*/
public BigDecimal getDeductibleTaxAmount(Long companyId, LocalDate startDate, LocalDate endDate) {
// 只统计专用发票的税额
return invoiceMapper.sumTaxAmountByType(companyId, startDate, endDate,
Arrays.asList(InvoiceType.SPECIAL.getCode(), InvoiceType.ELECTRONIC_SPECIAL.getCode()));
}
/**
* 获取即将过期发票(电子发票有效期)
*/
public List<Invoice> getExpiringInvoices(Long companyId, int days) {
LocalDate expiryDate = LocalDate.now().plusDays(days);
return invoiceMapper.selectExpiringInvoices(companyId, expiryDate);
}
@Data
public static class InvoiceStatisticsVO {
private Long receiveCount;
private BigDecimal receiveAmount;
private Long issueCount;
private BigDecimal issueAmount;
private Long verifiedCount;
private Long unverifiedCount;
private Long reimbursedCount;
private BigDecimal reimbursedAmount;
private Long pendingReimbursementCount;
private BigDecimal totalTaxAmount;
private List<TypeStatistics> typeStatistics;
private List<MonthlyTrend> monthlyTrend;
}
@Data
public static class TypeStatistics {
private String invoiceType;
private String invoiceTypeName;
private Long count;
private BigDecimal amount;
private BigDecimal taxAmount;
}
@Data
public static class MonthlyTrend {
private String month;
private Long receiveCount;
private BigDecimal receiveAmount;
private Long issueCount;
private BigDecimal issueAmount;
}
}
七、最佳实践
7.1 发票系统设计要点
+------------------------------------------------------------------+
| 发票系统设计要点 |
+------------------------------------------------------------------+
| |
| 1. 数据准确性 |
| - OCR识别后人工复核 |
| - 多渠道验真(税务局/第三方) |
| - 查重机制防止重复录入 |
| |
| 2. 安全性 |
| - 敏感信息加密存储 |
| - 操作日志完整记录 |
| - 权限控制细粒度管理 |
| |
| 3. 合规性 |
| - 符合税务法规要求 |
| - 电子发票存档要求 |
| - 报销审批流程规范 |
| |
| 4. 易用性 |
| - 多种导入方式(扫描/拍照/上传) |
| - 智能识别减少手动输入 |
| - 批量操作提高效率 |
| |
| 5. 扩展性 |
| - 支持新发票类型 |
| - 对接多个OCR服务 |
| - 灵活的审批流程配置 |
| |
+------------------------------------------------------------------+
7.2 常见问题处理
| 问题 | 原因 | 解决方案 |
|---|---|---|
| OCR识别率低 | 图片质量差/角度偏斜 | 图片预处理/提供拍摄指引 |
| 验真失败 | 发票信息有误/网络问题 | 人工复核/重试机制 |
| 重复报销 | 发票查重不严格 | 代码+号码唯一索引/历史查重 |
| 金额计算错误 | 含税/不含税混淆 | 明确金额类型/自动计算校验 |
| 发票过期 | 未及时报销 | 到期提醒/自动预警 |
7.3 性能优化建议
- OCR并行处理:批量识别时使用多线程
- 缓存常用数据:发票抬头、税收编码等
- 异步处理:导入、验真等耗时操作异步化
- 分表策略:按时间分表存储历史发票
- 索引优化:针对常用查询条件建立索引
八、总结
本文详细介绍了财务发票系统的设计与实现,涵盖以下核心内容:
- 系统架构:微服务架构,模块化设计
- 数据模型:完整的发票、开票、报销数据模型
- OCR识别:对接百度/阿里云OCR,支持多种发票类型
- 发票验真:对接税务局接口,确保发票真实有效
- 多渠道导入:支持Excel、图片、邮件等多种导入方式
- 开票功能:开票申请、审批、开具、交付全流程
- 报销管理:报销单创建、发票关联、审批流程
通过本系统,企业可以实现发票的全生命周期管理,提高财务工作效率,降低合规风险。