背景

公司每月需要根据供应商发来的商品结算单,向税局申报开具发票。原来的流程大致是这样的:

  1. 财务收到结算单 Excel,明细行通常有 100+ 条
  2. 逐行对照内部商品税目档案,手动填写税局规定的”发票开具项目信息导入模板”
  3. 上传到电子税务局

整个过程纯手工,单份结算单少则半小时,出错了还要重来。更麻烦的是,结算单里的商品编码和税局要求的税收分类编码是两套体系,需要人工翻译,稍不注意就会填错。

这个需求落到我头上,决定用 OA 系统把这个流程自动化掉。


整体方案

1
2
3
4
5
6
7
8
9
上传结算单 xlsx
↓
解析写入 OA 数据库(主表 + 明细表)
↓
关联商品税目维护表(按商品编码匹配)
↓
读取税局模板文件,填充数据
↓
生成文件,上传 OA 附件,返回 fileid

技术栈:Java 8 + 泛微 e-cology(JAX-RS)+ Apache POI


一、结算单 Excel 解析

结算单的格式是固定的:前 5 行是表头信息,第 6 行是列标题,第 7 行起是商品明细,最后一行是合计行(不要)。

文件接收

泛微平台不支持 javax.servlet.http.Part(Servlet 3.0 的 API),需要用平台自带的 weaver.file.FileUpload:

1
2
3
4
FileUpload fileUpload = new FileUpload(request, "utf-8");
int imageFileId = Util.getIntValue(fileUpload.uploadFiles("spdd"));
InputStream is = ImageFileManager.getInputStreamById(imageFileId);
Workbook wb = WorkbookFactory.create(is);

解析主表

表头区域的字段分散在不同单元格,按坐标固定读取:

1
2
3
4
5
6
main.setDdrq(getCellText(sheet, 1, 1));   // R2C2 订单日期
main.setDdbh(getCellText(sheet, 1, 6)); // R2C7 订单编号
main.setXssyf(getCellText(sheet, 2, 1)); // R3C2 销售商
main.setGysjf(getCellText(sheet, 2, 6)); // R3C7 供应商
main.setYhtyxq(getCellText(sheet, 4, 1)); // R5C2 原合同有效期
main.setYhtbh(getCellText(sheet, 4, 6)); // R5C7 原合同编号

公式单元格的坑

解析时遇到一个问题:部分单元格是 VLOOKUP / XLOOKUP 公式,cell.toString() 直接返回公式字符串,比如:

1
VLOOKUP(C10,[2]国服商品税目维护!$B$1:$H$1150,5,FALSE)

这显然不是我们想要的。Excel 文件打开过之后,POI 可以读取公式的缓存计算结果,正确的处理方式是:

1
2
3
4
5
6
7
8
9
10
11
CellType type = cell.getCellType();
if (type == CellType.FORMULA) {
type = cell.getCachedFormulaResultType(); // 取缓存结果类型
}
if (type == CellType.NUMERIC) {
// 读数值
} else if (type == CellType.STRING) {
// 读字符串
} else if (type == CellType.ERROR) {
return null; // XLOOKUP 在某些环境失败,直接当空处理
}

防重

以原合同编号作为唯一键,导入前先查重:

1
2
3
4
checkRs.executeQuery("select id from uf_spjsd where yhtbh = ?", main.getYhtbh());
if (checkRs.next()) {
return resp.busError("原合同编号【" + main.getYhtbh() + "】已存在,请勿重复导入");
}

二、税局模板生成

税局的发票模板格式固定(三行表头 + 数据行),数据列映射关系如下:

税局模板列 数据来源
项目名称 商品税目表.项目(xm)
税收分类编码 商品税目表.税收分类编码
规格型号 结算单明细.规格型号
单位 固定”个”
商品数量 结算单明细.采购数量
商品单价 结算单明细.经销价格
金额 结算单明细.结算金额
税率 固定 0.13

关联逻辑:结算单明细表通过商品编码(spbm)LEFT JOIN 商品税目维护表,商品税目维护表是财务平时自己维护的,存放了每类商品对应的税局项目名称和税收分类编码。

读取模板 + 填充数据

模板文件存在 OA 附件系统,通过 docId 读取,保留原模板的前 3 行(填表说明 + 字段限制 + 列标题),从第 4 行(index=3)开始写数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
try (InputStream in = new FileInputStream(tempFile);
Workbook wb = WorkbookFactory.create(in);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

Sheet sheet = wb.getSheetAt(0);
int rowIndex = 3; // 数据从第 4 行开始
while (rs.next()) {
Row row = sheet.createRow(rowIndex++);
setCellString(row, 0, rs.getString("xm")); // 项目名称
setCellString(row, 1, rs.getString("sphfwssflbm")); // 税收分类编码
setCellString(row, 2, rs.getString("gzxh")); // 规格型号
setCellString(row, 3, "个"); // 单位
row.createCell(4).setCellValue(Double.parseDouble(rs.getString("cgsl")));
row.createCell(5).setCellValue(Double.parseDouble(rs.getString("jxjghsy")));
row.createCell(6).setCellValue(Double.parseDouble(rs.getString("jsje")));
row.createCell(7).setCellValue(0.13);
}

wb.write(baos);
return baos.toByteArray();
}

生成后上传到 OA 附件

泛微上传文件需要把字节数组包装成 multipart 请求:

1
2
3
4
String fileName = String.format("发票开具项目信息导入模板-%s-%s.xlsx", xssyf, yhtbh);
HttpServletRequest wrapped = SimpleMultipartRequestWrapper.wrap(var1, fileBytes, fileName, fileCt);
Object result = UploadFile2Doc.upload(wrapped, var2);
// 返回 fileid,前端可直接下载

三、接口设计

接口 说明
POST /api/invoice/import/importSpjsd 上传解析结算单 xlsx
POST /api/invoice/spjsd/list 分页查询历史结算单
GET /api/invoice/spjsd/detail?statementId=xxx 查询指定结算单明细
POST /api/invoice/spjsd/generate 生成税局模板,返回 fileid

/generate 接口接受 statementId(结算单 ID)和 docId(模板文件的 OA 附件 ID)两个参数,模板文件和生成逻辑解耦,税局更新模板格式时只需替换模板文件,不需要改代码。


四、一些细节

商品税目未维护的处理

LEFT JOIN 而不是 INNER JOIN,这样即使某个商品编码在税目表里没有记录,对应的明细行也会出现在生成的模板里,只是项目名称和税收编码为空。财务看到空行可以手动补填,而不是整个文件生成失败。

税率和单位的扩展性

目前税率固定 0.13,单位固定”个”,已记录为技术债。后续可以在商品税目维护表里增加税率和单位字段,生成时按商品读取,而不是全局固定值。

模板版本问题

税局偶尔会更新发票模板格式。把模板文件存在 OA 附件里、通过 docId 传入的设计,可以让财务自己更换模板文件,而不需要找开发