工会报销导出以及发票填报修改

This commit is contained in:
2026-09-29 14:16:00 +08:00
parent 5c0f593c08
commit d94a6ed014
18 changed files with 1959 additions and 1073 deletions
@@ -27,6 +27,7 @@ import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceTypeService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseBankHistoryVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -45,6 +46,7 @@ import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.nutz.mvc.annotation.POST;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -65,6 +67,8 @@ public class UnionReimburseApplyController {
@Inject
private UnionReimburseService unionReimburseService;
@Inject
private UnionReimburseBankAccountService unionReimburseBankAccountService;
@Inject
private FlowCommonService flowCommonService;
@Inject
private CondolenceTypeService typeService;
@@ -83,6 +87,7 @@ public class UnionReimburseApplyController {
@At
@ApiOperation("保存申请")
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR)
@SLog(type = "unionReimburse", tag = "工会报销-报销申请", msg = "保存报销申请")
public Result save(@Param("data") UnionReimburse unionReimburse) {
@@ -204,6 +209,31 @@ public class UnionReimburseApplyController {
return Result.success(result);
}
/**
* @param payer 付款人用户ID,不传姓名或工号
* @param reimburseFundSource 当前经费来源编码
* @param clubId 协会经费对应的协会ID,其他来源可为空
* @return Result.data 为账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit
*/
@At
@POST
@ApiOperation("查询付款人常用收款账户")
@SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR)
public Result listBankAccounts(String payer, String reimburseFundSource, String clubId) {
return Result.success(unionReimburseBankAccountService.listAccounts(payer, reimburseFundSource, clubId));
}
/** 无请求参数;仅系统管理员执行。Result.data 为扫描的历史单据数量,重复调用不会新增重复账户。 */
@At
@POST
@ApiOperation("初始化历史收款账户")
@SaCheckLogin
@Aop(TransAop.READ_COMMITTED)
@SLog(type = "unionReimburse", tag = "历史收款账户初始化", msg = "导入历史报销收款账户")
public Result importBankAccountHistory() {
return Result.success(unionReimburseBankAccountService.importHistory());
}
@At
@ApiOperation("校验发票号码是否重复")
@SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR)
@@ -212,8 +242,13 @@ public class UnionReimburseApplyController {
return result == null ? Result.success() : result;
}
/**
* @param fileId 上传接口返回的文件ID,服务端读取对应发票文件
* @param verifySwitch 旧页面兼容参数,任何取值均强制验真
* @return Result,code=0 时 data 为票号、金额、销售方、项目及 verified=true;非零时 msg 为失败原因
*/
@At
@ApiOperation("自动识别发票信息")
@ApiOperation("识别发票并强制验真")
@SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR)
public Result recognizeInvoice(String fileId, @Param("verifySwitch") Boolean verifySwitch) {
return unionReimburseService.recognizeInvoice(fileId, verifySwitch);
@@ -22,6 +22,7 @@ import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseCollectExcelVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -64,6 +65,9 @@ public class UnionReimburseCollectController {
@Inject
private UnionReimburseService unionReimburseService;
@Inject
private UnionReimburseExportService unionReimburseExportService;
@At("/index")
@Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/collect/index.html")
@SaCheckPermission("unionReimburse.collect")
@@ -134,6 +138,20 @@ public class UnionReimburseCollectController {
return unionReimburseService.updateReimburseProject(id, reimburseProject);
}
/**
* 汇总页打印复用审批单 PDF 导出,支持所有申请状态,数据范围由 service 校验。
* @param id 汇总页选中的报销单 ID,不受申请状态限制
* @param response 文件下载响应,返回完整 PDF 附件;失败由统一异常处理返回业务提示
*/
@At
@Ok("void")
@ApiOperation("打印报销审批单PDF")
@SaCheckPermission("unionReimburse.collect")
public void exportPdf(@Param("id") String id, HttpServletResponse response) {
byte[] pdf = unionReimburseExportService.exportCollectPdf(id);
CommonDownloadUtil.download("工会经费报销审批单_" + id + ".pdf", pdf, response);
}
@At
@Ok("void")
@SaCheckPermission(value = {"unionReimburse.collect", "h5.unionReimburse.collect"}, mode = SaMode.OR)
@@ -1,62 +1,39 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.model.Audit;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.MoneyUtil;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.data.PictureRenderData;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.entity.Record;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
/**
* @ClassName UnionReimburseMineController
@@ -73,11 +50,11 @@ import java.util.HashMap;
public class UnionReimburseMineController {
@Inject
private SysOfficeTemplateUtil sysOfficeTemplateUtil;
@Inject
private UnionReimburseService unionReimburseService;
@Inject
private UnionReimburseExportService unionReimburseExportService;
@Inject
private FlowEngine flowEngine;
@Inject
private Dao dao;
@@ -206,258 +183,17 @@ public class UnionReimburseMineController {
}
/**
* @param id 当前登录人的已提交报销单ID
* @param response 返回PDF附件;失败由统一异常处理返回业务提示
*/
@At
@Ok("void")
public void ActExport(String id, HttpServletResponse response, boolean Print) throws Exception {
UnionReimburse unionReimburse = unionReimburseService.fetch(id);
if (unionReimburse == null) {
throw new RuntimeException("报销记录不存在");
}
HashMap<String, Object> docData = new HashMap<>();
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(unionReimburse.getDocumentNo(), BarcodeFormat.QR_CODE, 80, 80);
BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
File tempFile = File.createTempFile("qrcode_", ".png");
javax.imageio.ImageIO.write(bufferedImage, "PNG", tempFile);
PictureRenderData pictureRenderData = com.deepoove.poi.data.Pictures.of(tempFile.getAbsolutePath())
.size(80, 80)
.create();
docData.put("qrcode", pictureRenderData);
tempFile.deleteOnExit();
docData.put("createTime", DateUtil.format(unionReimburse.getCreateTime(), "yyyy-MM-dd HH:mm:ss"));
docData.put("unitName", unionReimburse.getUnitName());
docData.put("userName", unionReimburse.getUserName());
docData.put("loginName", unionReimburse.getLoginName());
docData.put("mobile", unionReimburse.getMobile());
docData.put("paymentNotes", unionReimburse.getPaymentNotes());
docData.put("invoiceNumber", unionReimburse.getInvoiceNumber());
docData.put("notes", unionReimburse.getNotes());
docData.put("realMoney", unionReimburse.getRealMoney());
docData.put("reimburseProject", unionReimburse.getReimburseProject());
docData.put("paymentWay", unionReimburse.getPaymentWay());
//对私支付,付款人信息
docData.put("payerLoginname", unionReimburse.getPayerLoginname());
docData.put("payerName", unionReimburse.getPayerName());
docData.put("bankOfDeposit", unionReimburse.getBankOfDeposit());
docData.put("bankCardNumber", unionReimburse.getBankCardNumber());
// 金额转大写
if (unionReimburse.getRealMoney() != null) {
docData.put("money_big", MoneyUtil.toRMBUpper(String.valueOf(unionReimburse.getRealMoney())));
} else {
docData.put("money_big", "");
}
// 设置报销项目类型标记
String wtAct = "□文体活动 □慰问 □日常活动 □专项活动";
if ("UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□文体活动", "√文体活动");
} else if ("UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□日常活动", "√日常活动");
} else if ("UNION_REIMBURSE_PROJECT_4".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□专项活动", "√专项活动");
} else if ("UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□慰问", "√慰问");
}
docData.put("bxxm", wtAct);
String templateName;
Double realMoney = unionReimburse.getRealMoney() != null ? unionReimburse.getRealMoney() : 0.0;
String paymentWay = unionReimburse.getPaymentWay();
if (("UNION_REIMBURSE_PAYMENT_WAY_2").equals(paymentWay)) { // 对私支付
docData.put("paymentWay", "对私支付");
if (realMoney < 5000) {
templateName = "unionReimburse_act_private_small";
} else {
templateName = "unionReimburse_act_private_big";
}
} else { // 对公支付
docData.put("paymentWay", "对公支付");
if (realMoney < 5000) {
templateName = "unionReimburse_act_public_small";
} else {
templateName = "unionReimburse_act_public_big";
}
}
if (Print) {
exportAsPDF(templateName, docData, unionReimburse, response);
} else {
exportAsWord(templateName, docData, unionReimburse, response);
}
@ApiOperation("导出报销审批单PDF")
@SaCheckPermission("unionReimburse.mine")
public void exportPdf(@Param("id") String id, HttpServletResponse response) {
byte[] pdf = unionReimburseExportService.exportPdf(id);
CommonDownloadUtil.download("工会经费报销审批单_" + id + ".pdf", pdf, response);
}
@At
@Ok("void")
public void ConExport(String id, HttpServletResponse response, boolean Print) throws Exception {
UnionReimburse unionReimburse = unionReimburseService.fetch(id);
if (unionReimburse == null) {
throw new RuntimeException("报销记录不存在");
}
HashMap<String, Object> docData = new HashMap<>();
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(unionReimburse.getDocumentNo(), BarcodeFormat.QR_CODE, 80, 80);
BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
File tempFile = File.createTempFile("qrcode_", ".png");
javax.imageio.ImageIO.write(bufferedImage, "PNG", tempFile);
PictureRenderData pictureRenderData = com.deepoove.poi.data.Pictures.of(tempFile.getAbsolutePath())
.size(80, 80)
.create();
docData.put("qrcode", pictureRenderData);
docData.put("createTime", DateUtil.format(unionReimburse.getCreateTime(), "yyyy-MM-dd HH:mm:ss"));
docData.put("unitName", unionReimburse.getUnitName());
docData.put("userName", unionReimburse.getUserName());
docData.put("loginName", unionReimburse.getLoginName());
docData.put("mobile", unionReimburse.getMobile());
docData.put("paymentNotes", unionReimburse.getPaymentNotes());
docData.put("invoiceNumber", unionReimburse.getInvoiceNumber());
docData.put("notes", unionReimburse.getNotes());
docData.put("realMoney", unionReimburse.getRealMoney());
docData.put("reimburseProject", unionReimburse.getReimburseProject());
docData.put("paymentWay", unionReimburse.getPaymentWay());
//对私支付,付款人信息
docData.put("payerLoginname", unionReimburse.getPayerLoginname());
docData.put("payerName", unionReimburse.getPayerName());
docData.put("bankOfDeposit", unionReimburse.getBankOfDeposit());
docData.put("bankCardNumber", unionReimburse.getBankCardNumber());
//慰问
docData.put("condolenceUserName", unionReimburse.getCondolenceUserName());
docData.put("condolenceLoginName", unionReimburse.getCondolenceLoginName());
docData.put("typeName", unionReimburse.getTypeName());
docData.put("way", unionReimburse.getWay());
docData.put("condolenceTime", DateUtil.format(unionReimburse.getCondolenceTime(), "yyyy-MM-dd"));
docData.put("participants", unionReimburse.getParticipants());
//结婚
docData.put("marryTime", DateUtil.format(unionReimburse.getMarryTime(), "yyyy-MM-dd"));
//生育
docData.put("fertilityTime", DateUtil.format(unionReimburse.getFertilityTime(), "yyyy-MM-dd"));
//生病住院
docData.put("hospitalCausation", unionReimburse.getHospitalCausation());
docData.put("hospital", unionReimburse.getHospital());
docData.put("hospitalCount", unionReimburse.getHospitalCount());
String hospitalTime = DateUtil.format(unionReimburse.getHospitalizationStartTime(), "yyyy-MM-dd") +
" 至 " +
DateUtil.format(unionReimburse.getHospitalizationEndTime(), "yyyy-MM-dd");
docData.put("hospitalTime", hospitalTime);
//去世
docData.put("deathTime", DateUtil.format(unionReimburse.getDeathTime(), "yyyy-MM-dd"));
docData.put("condolenceRelationship", unionReimburse.getCondolenceRelationship());
// 金额转大写
if (unionReimburse.getRealMoney() != null) {
docData.put("money_big", MoneyUtil.toRMBUpper(String.valueOf(unionReimburse.getRealMoney())));
} else {
docData.put("money_big", "");
}
// 设置报销项目类型标记
String wtAct = "□文体活动 □慰问 □日常活动 □专项活动";
if ("UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□文体活动", "√文体活动");
} else if ("UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□日常活动", "√日常活动");
} else if ("UNION_REIMBURSE_PROJECT_4".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□专项活动", "√专项活动");
} else if ("UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject())) {
wtAct = wtAct.replace("□慰问", "√慰问");
}
docData.put("bxxm", wtAct);
String templateName;
Double realMoney = unionReimburse.getRealMoney() != null ? unionReimburse.getRealMoney() : 0.0;
String paymentWay = unionReimburse.getPaymentWay();
if (("UNION_REIMBURSE_PAYMENT_WAY_2").equals(paymentWay)) { // 对私支付
docData.put("paymentWay", "对私支付");
if (("生病住院").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_private_hospital";
} else if (("结婚").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_private_mary";
} else if (("生育").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_private_birth";
} else if (("会员去世").equals(unionReimburse.getTypeName()) || ("直系亲属去世").equals(unionReimburse.getTypeName())) {
if (realMoney < 5000) {
templateName = "unionReimburse_con_private_dead_small";
} else {
templateName = "unionReimburse_con_private_dead_big";
}
} else {//其他
templateName = "unionReimburse_con_private_other";
}
} else { // 对公支付
docData.put("paymentWay", "对公支付");
if (("生病住院").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_public_hospital";
} else if (("结婚").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_public_mary";
} else if (("生育").equals(unionReimburse.getTypeName())) {
templateName = "unionReimburse_con_public_birth";
} else if (("会员去世").equals(unionReimburse.getTypeName()) || ("直系亲属去世").equals(unionReimburse.getTypeName())) {
if (realMoney < 5000) {
templateName = "unionReimburse_con_public_dead_small";
} else {
templateName = "unionReimburse_con_public_dead_big";
}
} else {//其他
templateName = "unionReimburse_con_public_other";
}
}
if (Print) {
exportAsPDF(templateName, docData, unionReimburse, response);
} else {
exportAsWord(templateName, docData, unionReimburse, response);
}
}
// 导出PDF
@At
private void exportAsPDF(String templateName, HashMap<String, Object> docData,
UnionReimburse unionReimburse, HttpServletResponse response) throws Exception {
String fileName = "中国地质大学(武汉)工会经费日常报销_" + unionReimburse.getUserName() + "_" +
DateUtil.format(unionReimburse.getCreateTime(), "yyyyMMdd") + ".pdf";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(templateName))
.render(docData)
.writeAndClose(byteArrayOutputStream);
byte[] bytes = byteArrayOutputStream.toByteArray();
File sourceFile = File.createTempFile("file_convert_origin", ".docx");
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
File pdfFile = File.createTempFile("file_convert", ".pdf");
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
CommonDownloadUtil.download(fileName, IoUtil.readBytes(FileUtil.getInputStream(pdfFile)), response);
// 删除临时文件
FileUtil.del(sourceFile);
FileUtil.del(pdfFile.toPath());
} catch (IOException e) {
log.error("工会报销单据导出PDF失败,id:{},错误信息:{}", unionReimburse.getId(), e.getMessage());
}
}
// 导出Word
@At
private void exportAsWord(String templateName, HashMap<String, Object> docData,
UnionReimburse unionReimburse, HttpServletResponse response) throws Exception {
String fileName = "中国地质大学(武汉)工会经费日常报销_" + unionReimburse.getUserName() + "_" +
DateUtil.format(unionReimburse.getCreateTime(), "yyyyMMdd") + ".docx";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(templateName))
.render(docData)
.writeAndClose(byteArrayOutputStream);
CommonDownloadUtil.download(fileName, byteArrayOutputStream.toByteArray(), response);
} catch (IOException e) {
log.error("工会报销单据导出Word失败,id:{},错误信息:{}", unionReimburse.getId(), e.getMessage());
}
}
}
@@ -279,6 +279,9 @@ public class UnionReimburse extends BaseModel {
@ColDefine(type = ColType.VARCHAR, width = 32)
private String condolenceTypeId;
/** 慰问类型业务编码,仅供申请和详情回显控制字段显示,不映射数据库列。 */
private String condolenceTypeCode;
@Column
@Comment("慰问类型方式")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -0,0 +1,42 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.model;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
import org.nutz.dao.interceptor.annotation.PrevInsert;
/** 付款人常用收款账户;报销单仍独立保留本次支付资料快照。 */
@Data
@EqualsAndHashCode(callSuper = true)
@Table("union_reimburse_bank_account")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@TableIndexes(@Index(name = "uk_reimburse_bank_account", fields = {
"payer", "bankUserName", "bankCardNumber", "bankOfDeposit"
}, unique = true))
public class UnionReimburseBankAccount extends BaseModel {
@Name
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("付款人用户ID")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String payer;
@Column
@Comment("户名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String bankUserName;
@Column
@Comment("银行卡号,保留字符串格式")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String bankCardNumber;
@Column
@Comment("开户行")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String bankOfDeposit;
}
@@ -44,11 +44,26 @@ public class UnionReimburseInvoiceDetail extends BaseModel {
@ColDefine(type = ColType.VARCHAR, width = 100)
private String invoiceNo;
@Column
@Comment("开票日期,格式 yyyy-MM-dd")
@ColDefine(type = ColType.VARCHAR, width = 10)
private String invoiceDate;
@Column
@Comment("发票金额")
@ColDefine(type = ColType.FLOAT, width = 10, precision = 2)
private Double invoiceAmount;
@Column
@Comment("税前金额")
@ColDefine(type = ColType.FLOAT, width = 10, precision = 2)
private Double amountWithoutTax;
@Column
@Comment("合计税额")
@ColDefine(type = ColType.FLOAT, width = 10, precision = 2)
private Double taxAmount;
@Column
@Comment("销售方信息名称")
@ColDefine(type = ColType.VARCHAR, width = 200)
@@ -0,0 +1,23 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseBankAccount;
import org.nutz.lang.util.NutMap;
import java.util.List;
public interface UnionReimburseBankAccountService extends BaseService<UnionReimburseBankAccount> {
/**
* @param payer 付款人用户ID,非姓名或工号
* @param reimburseFundSource 当前经费来源字典编码
* @param clubId 协会经费对应的协会ID,其他来源可为空
* @return 可选账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit
*/
List<NutMap> listAccounts(String payer, String reimburseFundSource, String clubId);
/** @param form 已通过校验的报销单;提取全部对私支付明细,去重保存完整账户。 */
void saveAccounts(UnionReimburse form);
/** @return 扫描的历史报销单数量;仅系统管理员可执行,可重复执行且不覆盖已有账户。 */
int importHistory();
}
@@ -0,0 +1,26 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.service;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import java.util.Map;
/** 工会报销两类审批单共用的导出服务。 */
public interface UnionReimburseExportService {
/**
* @param id 当前登录人已提交的报销单ID,不接受其他人的单据或待提交单据
* @return 完整PDF字节数组,模板或转换失败时抛出业务异常
*/
byte[] exportPdf(String id);
/**
* 汇总页所有状态的报销单均可打印,必须具有汇总权限并符合所属工会数据范围。
* @param id 汇总页选中的报销单 ID,可为待提交或其他申请状态
* @return 与我的报销导出使用相同模板生成的完整 PDF 字节数组,无权限或生成失败时抛出业务异常
*/
byte[] exportCollectPdf(String id);
/**
* @param form 已加载发票明细的报销单,支付明细为空时兼容旧主表字段
* @return 两份模板通用的平铺字段及 payments、invoices 循环行
*/
Map<String, Object> buildExportData(UnionReimburse form);
}
@@ -50,7 +50,10 @@ public interface UnionReimburseService extends BaseService<UnionReimburse> {
Result checkInvoiceDuplicate(String invoiceNo, String reimburseId);
/**
* 识别单张发票文件,并在需要时调用验真接口补齐票面信息。
* 识别单张发票文件并强制验真。
* @param fileId 已上传发票的文件ID
* @param verifySwitch 兼容旧页面参数,不再允许关闭验真
* @return Result.data 包含票号、金额、销售方、项目名称及 verified=true;失败返回非零 code 和原因
*/
Result recognizeInvoice(String fileId, Boolean verifySwitch);
@@ -0,0 +1,128 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseBankAccount;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@IocBean(args = {"refer:dao"})
public class UnionReimburseBankAccountServiceImpl extends BaseServiceImpl<UnionReimburseBankAccount>
implements UnionReimburseBankAccountService {
public UnionReimburseBankAccountServiceImpl(Dao dao) {
super(dao);
}
/** 按当前付款人候选范围校验用户ID,防止直接传入其他人员ID读取账户。 */
@Override
public List<NutMap> listAccounts(String payer, String reimburseFundSource, String clubId) {
if (StrUtil.isBlank(payer)) {
return Collections.emptyList();
}
View_user user = dao().fetch(View_user.class, Cnd.where("id", "=", payer));
if (user == null) {
return Collections.emptyList();
}
if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(reimburseFundSource)) {
if (StrUtil.isBlank(clubId) || dao().count(ClubUser.class,
Cnd.where("clubId", "=", clubId).and("userId", "=", payer)) == 0) {
return Collections.emptyList();
}
} else if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())
&& (StrUtil.isBlank(SecurityUtil.getUnionId())
|| !Objects.equals(user.getUnionId(), SecurityUtil.getUnionId()))) {
return Collections.emptyList();
}
Sql sql = Sqls.create("SELECT id, bankUserName, bankCardNumber, bankOfDeposit "
+ "FROM union_reimburse_bank_account WHERE payer = @payer AND delFlag = 0 "
+ "ORDER BY createdAt DESC, id DESC");
sql.params().set("payer", payer);
return listMap(sql);
}
/** 保存全部对私明细,账户写入与调用方的报销保存共用事务。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveAccounts(UnionReimburse form) {
if (form == null) {
return;
}
if (form.getPaymentDetails() != null && !form.getPaymentDetails().isEmpty()) {
for (JSONObject detail : form.getPaymentDetails()) {
if (detail != null && "UNION_REIMBURSE_PAYMENT_WAY_2".equals(detail.getStr("paymentWay"))) {
saveAccount(detail.getStr("payer"), detail.getStr("bankUserName"),
detail.getStr("bankCardNumber"), detail.getStr("bankOfDeposit"));
}
}
} else if ("UNION_REIMBURSE_PAYMENT_WAY_2".equals(form.getPaymentWay())) {
// 仅无支付明细的旧单据使用主表字段,避免导入已过时的第一条账户副本。
saveAccount(form.getPayer(), form.getBankUserName(), form.getBankCardNumber(), form.getBankOfDeposit());
}
}
/** 过滤不完整历史资料;唯一索引配合原子插入,防止并发报销生成重复账户。 */
@Aop(TransAop.READ_COMMITTED)
protected void saveAccount(String payer, String name, String card, String bank) {
payer = StrUtil.trim(payer);
name = StrUtil.trim(name);
card = StrUtil.trim(card);
bank = StrUtil.trim(bank);
if (StrUtil.hasBlank(payer, name, card, bank) || payer.length() > 100 || name.length() > 100
|| card.length() > 100 || bank.length() > 100 || !card.matches("[1-9]([0-9]{15}|[0-9]{18})")) {
return;
}
Sql sql = Sqls.create("""
INSERT INTO union_reimburse_bank_account
(id, payer, bankUserName, bankCardNumber, bankOfDeposit,
createdBy, createdAt, updatedBy, updatedAt, delFlag)
VALUES (@id, @payer, @name, @card, @bank, @userId, @now, @userId, @now, 0)
ON DUPLICATE KEY UPDATE id = id
""");
sql.params().set("id", UUID.randomUUID().toString().replace("-", ""));
sql.params().set("payer", payer).set("name", name).set("card", card).set("bank", bank);
sql.params().set("userId", SecurityUtil.getUserId()).set("now", System.currentTimeMillis());
dao().execute(sql);
}
/** 管理员一次性初始化;按ID分批读取,避免一次加载全部历史单据。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public int importHistory() {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) {
throw new BaseException("仅系统管理员可初始化历史收款账户");
}
int processed = 0;
String lastId = "";
while (true) {
List<UnionReimburse> records = dao().query(UnionReimburse.class,
Cnd.where("id", ">", lastId).and("delFlag", "=", false).asc("id"), dao().createPager(1, 200));
if (records.isEmpty()) {
return processed;
}
for (UnionReimburse record : records) {
saveAccounts(record);
}
processed += records.size();
lastId = records.get(records.size() - 1).getId();
}
}
}
@@ -0,0 +1,271 @@
package com.budwk.app.zhgh.dayofficework.unionReimburse.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.utils.MoneyUtil;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseInvoiceDetail;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import lombok.extern.slf4j.Slf4j;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@Slf4j
@IocBean
public class UnionReimburseExportServiceImpl implements UnionReimburseExportService {
public static final String ACTIVITY_TEMPLATE = "union_reimburse_activity_pdf";
public static final String CONDOLENCE_TEMPLATE = "union_reimburse_condolence_pdf";
@Inject
private UnionReimburseService unionReimburseService;
@Inject
private SysOfficeTemplateUtil sysOfficeTemplateUtil;
@Inject
private Dao dao;
/** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致。 */
@Override
public byte[] exportPdf(String id) {
return renderDocument(loadExportForm(id, false, false), true);
}
/** 汇总打印支持所有状态,并严格按汇总权限和所属工会范围读取,复用我的报销 PDF 模板。 */
@Override
public byte[] exportCollectPdf(String id) {
return renderDocument(loadExportForm(id, true, true), true);
}
/**
* 只读取申请与发票明细,不修改业务数据。
* @param id 报销单 ID,空值、记录不存在或已删除时拒绝导出
* @param allowCollect 是否允许具有汇总数据权限的用户读取他人单据
* @param collectPrint 是否为汇总页全状态打印;此时必须通过汇总数据权限校验,经办人身份不能绕过工会范围
* @return 已校验权限并补齐发票明细的报销单,原有导出入口仍只允许已提交状态
*/
private UnionReimburse loadExportForm(String id, boolean allowCollect, boolean collectPrint) {
if (StrUtil.isBlank(id)) {
throw new BaseException("请选择需要导出的报销单");
}
UnionReimburse form = unionReimburseService.fetch(id);
if (form == null || Boolean.TRUE.equals(form.getDelFlag())) {
throw new BaseException("报销记录不存在");
}
String userId = SecurityUtil.getUserId();
boolean owner = StrUtil.isNotBlank(userId) && Objects.equals(form.getUserId(), userId);
boolean collectAllowed = allowCollect && StrUtil.isNotBlank(userId)
&& AuthUtil.hasPermission("unionReimburse.collect")
&& (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())
|| (StrUtil.isNotBlank(SecurityUtil.getUnionId())
&& Objects.equals(form.getUnionId(), SecurityUtil.getUnionId())));
if (collectPrint ? !collectAllowed : (!owner && !collectAllowed)) {
throw new BaseException("无权导出此报销单");
}
// 汇总页打印不限制申请状态,我的报销导出仍只允许已提交状态。
if (!collectPrint && !Set.of(2, 3, 4, 5).contains(form.getStateId())) {
throw new BaseException("报销单提交后才能导出");
}
// 已核对导出数据权限,单独补齐发票明细,避免申请页权限误拦截汇总打印。
form.setInvoiceDetails(dao.query(UnionReimburseInvoiceDetail.class,
Cnd.where("reimburseId", "=", id).asc("createdAt")));
return form;
}
/** 两份模板仅保留布局差异,Word/PDF 共用字段组装和循环行配置。 */
private byte[] renderDocument(UnionReimburse form, boolean asPdf) {
String templateCode = "UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())
? CONDOLENCE_TEMPLATE : ACTIVITY_TEMPLATE;
Path docx = null;
Path pdf = null;
try {
docx = Files.createTempFile("union_reimburse_", ".docx");
if (asPdf) pdf = Files.createTempFile("union_reimburse_", ".pdf");
// 活动和慰问模板的支付标记均位于表头,支付明细统一循环下一行。
// 活动模板的发票标记也位于表头,循环下一行;慰问保留原有的当前行配置。
boolean invoiceOnSameRow = CONDOLENCE_TEMPLATE.equals(templateCode);
Configure config = Configure.builder()
.bind("payments", new LoopRowTableRenderPolicy(false))
.bind("invoices", new LoopRowTableRenderPolicy(invoiceOnSameRow)).build();
try (InputStream input = sysOfficeTemplateUtil.getTemplate(templateCode);
XWPFTemplate template = XWPFTemplate.compile(input, config);
OutputStream output = Files.newOutputStream(docx)) {
template.render(buildExportData(form)).write(output);
}
if (!asPdf) {
return Files.readAllBytes(docx);
}
OfficePlusUtil.convert(docx.toString(), pdf.toString());
byte[] bytes = Files.readAllBytes(pdf);
// 公共转换器可能记录异常后返回,下载前验证结果,避免把空文件当成功响应。
if (bytes.length < 5 || bytes[0] != '%' || bytes[1] != 'P' || bytes[2] != 'D'
|| bytes[3] != 'F' || bytes[4] != '-') {
throw new BaseException("PDF生成失败,请检查模板及转换服务");
}
return bytes;
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("报销导出失败,单据ID:{},模板:{}", form.getId(), templateCode, e);
throw new BaseException("报销导出失败,请检查模板配置");
} finally {
// 成功或异常均清理本次转换文件,不影响其他并发导出。
for (Path path : new Path[]{docx, pdf}) {
if (path != null) {
try {
Files.deleteIfExists(path);
} catch (Exception e) {
log.warn("报销导出临时文件清理失败:{}", path, e);
}
}
}
}
}
/** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */
@Override
public Map<String, Object> buildExportData(UnionReimburse form) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("date", date(form.getCreateTime()));
data.put("no", text(form.getDocumentNo()));
String unit = text(form.getUnitName());
data.put("union", text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")"));
data.put("handler", text(form.getUserName()));
data.put("login", text(form.getLoginName()));
data.put("phone", text(form.getMobile()));
String project = form.getReimburseProject();
data.put("project", ("UNION_REIMBURSE_PROJECT_2".equals(project) ? "√" : "□") + "文体活动 "
+ ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 "
+ ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 "
+ ("UNION_REIMBURSE_PROJECT_4".equals(project) ? "√" : "□") + "专项活动");
data.put("activity", text(form.getActivityName()));
data.put("reason", text(form.getPaymentNotes()));
data.put("notes", text(form.getNotes()));
// 附件只有上传文件数量,没有纸质页数,模板明确标注为“附件份数”。
data.put("attachments", form.getFiles() == null ? 0 : form.getFiles().size());
List<JSONObject> details = form.getPaymentDetails();
boolean legacy = details == null || details.isEmpty();
if (legacy) {
JSONObject item = new JSONObject();
item.set("paymentWay", form.getPaymentWay());
item.set("payerLoginname", form.getPayerLoginname());
item.set("payerName", form.getPayerName());
item.set("bankUserName", form.getBankUserName());
item.set("bankCardNumber", form.getBankCardNumber());
item.set("bankOfDeposit", form.getBankOfDeposit());
// 历史记录未保存支付明细时,沿用实际金额、申请金额的优先级;慰问单再回退到慰问金额。
// 仅空值触发回退,已明确填写的零金额不能被其他字段覆盖。
Double legacyMoney = form.getRealMoney() != null ? form.getRealMoney() : form.getMoney();
if (legacyMoney == null && "UNION_REIMBURSE_PROJECT_1".equals(project)) {
legacyMoney = form.getCondolenceMoney();
}
item.set("money", legacyMoney);
details = List.of(item);
}
List<Map<String, Object>> payments = new ArrayList<>();
BigDecimal paymentTotal = BigDecimal.ZERO;
for (JSONObject item : details) {
if (item == null) continue;
boolean privatePayment = "UNION_REIMBURSE_PAYMENT_WAY_2".equals(item.getStr("paymentWay"));
BigDecimal amount = money(item.get("money"));
Map<String, Object> row = new LinkedHashMap<>();
row.put("way", privatePayment ? "对私支付" : "对公支付");
row.put("login", privatePayment ? text(item.getStr("payerLoginname")) : "");
row.put("name", privatePayment ? text(item.getStr("payerName")) : "");
// 开户行仅输出银行名称,不拼接户名或文字前缀;对公支付保持空白。
row.put("bank", privatePayment ? text(item.getStr("bankOfDeposit")) : "");
row.put("card", privatePayment ? text(item.getStr("bankCardNumber")) : "");
row.put("amount", amount.toPlainString());
payments.add(row);
paymentTotal = paymentTotal.add(amount);
}
data.put("payments", payments);
// 两类模板共用支付合计 total 与大写 upper,单条支付使用 payments.amount。
data.put("total", paymentTotal.setScale(2).toPlainString());
data.put("upper", upper(paymentTotal));
List<Map<String, Object>> invoices = new ArrayList<>();
BigDecimal invoiceTotal = BigDecimal.ZERO;
if (form.getInvoiceDetails() != null) {
for (UnionReimburseInvoiceDetail item : form.getInvoiceDetails()) {
if (item == null) continue;
BigDecimal amount = money(item.getInvoiceAmount());
Map<String, Object> row = new LinkedHashMap<>();
row.put("seq", invoices.size() + 1);
row.put("no", text(item.getInvoiceNo()));
row.put("seller", text(item.getSellerName()));
// 发票行分别绑定开票日期、税前金额、税额及价税合计;历史空值保持空白。
row.put("date", text(item.getInvoiceDate()));
row.put("amount", item.getAmountWithoutTax() == null ? "" : money(item.getAmountWithoutTax()).toPlainString());
row.put("tax", item.getTaxAmount() == null ? "" : money(item.getTaxAmount()).toPlainString());
row.put("total", amount.toPlainString());
invoices.add(row);
invoiceTotal = invoiceTotal.add(amount);
}
}
data.put("invoices", invoices);
data.put("invoiceTotal", invoiceTotal.setScale(2).toPlainString());
data.put("invoiceCount", invoices.isEmpty() ? text(form.getInvoiceNumber()) : invoices.size());
// 验真状态没有单独落库,导出模板不可将其固定写成“已验真”。
data.put("person", text(form.getCondolenceUserName()));
data.put("personNo", text(form.getCondolenceLoginName()));
data.put("type", text(form.getTypeName()));
data.put("way", text(form.getWay()));
data.put("visitDate", date(form.getCondolenceTime()));
data.put("participants", text(form.getParticipants()));
data.put("illness", text(form.getHospitalCausation()));
data.put("hospital", text(form.getHospital()));
data.put("times", text(form.getHospitalCount()));
String start = date(form.getHospitalizationStartTime());
String end = date(form.getHospitalizationEndTime());
data.put("hospitalDates", start + (start.isEmpty() || end.isEmpty() ? "" : " 至 ") + end);
data.put("birthDate", date(form.getFertilityTime()));
data.put("marryDate", date(form.getMarryTime()));
data.put("deathDate", date(form.getDeathTime()));
data.put("relation", text(form.getCondolenceRelationship()));
return data;
}
/** 金额统一为两位小数,空值显示0.00,非法历史金额拒绝生成错误单据。 */
private BigDecimal money(Object value) {
if (value == null || StrUtil.isBlank(value.toString())) return BigDecimal.ZERO.setScale(2);
try {
BigDecimal result = new BigDecimal(value.toString()).setScale(2, RoundingMode.HALF_UP);
if (result.signum() < 0) throw new NumberFormatException();
return result;
} catch (NumberFormatException e) {
throw new BaseException("报销记录金额异常,请核对后导出");
}
}
/** 复用项目人民币大写转换,将转换异常统一为导出业务提示。 */
private String upper(BigDecimal value) {
try {
return MoneyUtil.toRMBUpper(value.toPlainString());
} catch (Exception e) {
throw new BaseException("报销金额大写转换失败,请核对金额");
}
}
/** 空字段在审批单中显示为空白。 */
private String text(String value) { return StrUtil.nullToEmpty(value); }
/** 业务日期只显示年月日,未填写的日期保持空白。 */
private String date(Date value) { return value == null ? "" : DateUtil.format(value, "yyyy-MM-dd"); }
}
@@ -16,10 +16,12 @@ import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayMan
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.model.OutlayManageUnion;
import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseInvoiceDetail;
import com.budwk.app.zhgh.dayofficework.unionReimburse.param.UnionReimbursePageForm;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseBankHistoryVO;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
@@ -47,8 +49,8 @@ import com.tencentcloudapi.ocr.v20181119.models.VatInvoice;
import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceItem;
import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceOCRRequest;
import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceOCRResponse;
import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceVerifyRequest;
import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceVerifyResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -80,6 +82,8 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
@Inject
private SysFileService sysFileService;
@Inject
private UnionReimburseBankAccountService unionReimburseBankAccountService;
@Inject
private PropertiesProxy conf;
public UnionReimburseServiceImpl(Dao dao) {
super(dao);
@@ -121,7 +125,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
/**
* 保存、提交及重新提交共用支付信息校验,并在同一事务内保存申请和发票明细。
* @param unionReimburse 申请表单,paymentDetails 为支付方式、金额及对私收款资料组成的明细列表
* @param stateId 1 表示保存草稿,2 表示提交;两种状态均要求支付信息完整
* @param stateId 1 表示保存草稿,2 表示提交;均要求支付信息完整、发票必填且通过服务端验真
* @return Result 成功或失败结果,msg 包含失败明细的行号及原因
*/
@Override
@@ -130,10 +134,20 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse == null) {
return Result.error("报销数据不能为空");
}
// 保存、提交及重提统一要求发票完整,不能通过慰问类型或草稿状态跳过。
Result invoiceResult = validateInvoiceDetails(unionReimburse);
if (invoiceResult != null) {
return invoiceResult;
}
Result paymentResult = validatePaymentDetails(unionReimburse);
if (paymentResult != null) {
return paymentResult;
}
// 保存、提交及重提均在写入前检查支付总额,不能只依赖页面的金额限制。
Result amountResult = validatePaymentAmountLimit(unionReimburse);
if (amountResult != null) {
return amountResult;
}
UnionReimburse oldRecord = null;
if (StrUtil.isNotBlank(unionReimburse.getId())) {
oldRecord = this.fetch(unionReimburse.getId());
@@ -174,11 +188,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
normalizeInvoiceDetails(unionReimburse);
if (needInvoiceDetails(unionReimburse)) {
fillInvoiceSummary(unionReimburse);
} else {
fillCondolenceInvoiceSummary(unionReimburse);
}
fillInvoiceSummary(unionReimburse);
fillDefaultRealMoney(unionReimburse);
if (stateId == 2) {
Result validateResult = validateBeforeSubmit(unionReimburse);
@@ -195,6 +205,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
}
// 校验实际文件与票面内容后才允许写入,不接收前端的验真状态作为凭据。
Result verifyResult = validateInvoiceAuthenticity(unionReimburse);
if (verifyResult != null) {
return verifyResult;
}
if (oldRecord == null) {
unionReimburse.setCreateTime(toSecondPrecision(DateUtil.date()));
unionReimburse.setDocumentNo(generateDocumentNo());
@@ -206,6 +221,8 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
fillDefaultRealMoney(unionReimburse);
this.dao().insertOrUpdate(unionReimburse);
saveInvoiceDetails(unionReimburse.getId(), unionReimburse.getInvoiceDetails());
// 报销保存成功后积累每条对私账户;失败时随本次报销事务一起回滚。
unionReimburseBankAccountService.saveAccounts(unionReimburse);
return Result.success(stateId == 2 ? "提交成功" : "保存成功");
}
@@ -219,6 +236,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return new Date((date.getTime() / 1000) * 1000);
}
/**
* @param id 报销申请 ID,用于读取申请及关联的慰问类型
* @return 申请对象,condolenceTypeCode 为类型业务编码;未找到申请返回 null,类型缺失时编码为空
*/
@Override
public UnionReimburse getApplyForm(String id) {
UnionReimburse unionReimburse = this.fetch(id);
@@ -228,6 +249,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (!canReadApply(unionReimburse)) {
throw new BaseException("无权查看此报销申请");
}
// 兼容旧申请将类型 ID 存在 condolenceType 中的情况;只读取编码,不改动历史关联。
String typeId = StrUtil.blankToDefault(unionReimburse.getCondolenceTypeId(), unionReimburse.getCondolenceType());
CondolenceType type = StrUtil.isBlank(typeId) ? null : dao().fetch(CondolenceType.class, typeId);
unionReimburse.setCondolenceTypeCode(type == null ? null : type.getCode());
List<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query(
UnionReimburseInvoiceDetail.class,
Cnd.where("reimburseId", "=", id).asc("createdAt")
@@ -416,6 +441,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return queryHistoryInvoiceDuplicate(currentInvoiceNo, reimburseId);
}
/**
* @param fileId 已上传发票的文件ID,只读取服务端存储的文件
* @param verifySwitch 兼容旧页面参数,传 false 或不传也必须验真
* @return Result.data 为票号、金额、销售方、项目等字段及 verified=true;识别或验真失败返回非零 code
*/
@Override
public Result recognizeInvoice(String fileId, Boolean verifySwitch) {
if (StrUtil.isBlank(fileId)) {
@@ -431,14 +461,22 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
try {
byte[] fileBytes = sysFileService.download(fileId);
// OCR当前仅读取PDF第一页,拒绝多页文件,避免后续页未核验却随整份附件入库。
if (StrUtil.blankToDefault(sysFile.getName(), "").toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
try (org.apache.pdfbox.pdmodel.PDDocument document = org.apache.pdfbox.Loader.loadPDF(fileBytes)) {
if (document.getNumberOfPages() != 1) {
return Result.error("请将发票拆分为单页PDF后逐张上传验真");
}
}
}
VatInvoiceOCRResponse response = executeOcr(credential, sysFile, fileBytes);
NutMap result = buildOcrResult(response);
String msg = "发票识别成功";
if (Boolean.TRUE.equals(verifySwitch)) {
String verifyMsg = enrichByVerify(credential, result);
msg = StrUtil.isBlank(verifyMsg) ? "发票识别并校验成功" : verifyMsg;
String verifyMsg = enrichByVerify(credential, result);
if (StrUtil.isNotBlank(verifyMsg)) {
return Result.error(verifyMsg);
}
return Result.success(msg, result);
result.setv("verified", true);
return Result.success("发票识别并验真成功", result);
} catch (TencentCloudSDKException e) {
log.error("腾讯云发票识别失败,fileId:{}", fileId, e);
return Result.error("腾讯云发票识别失败:" + e.getMessage());
@@ -631,7 +669,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 活动类报销由发票明细自动汇总金额和发票张数,减少手工输入出错。
* 所有报销类型由必填发票明细自动汇总金额和张数。
*/
private void fillInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D;
@@ -650,29 +688,6 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount));
}
/**
* 慰问类报销允许不填发票;有发票时按发票汇总金额申请,没有发票时按慰问金额申请。
*/
private void fillCondolenceInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D;
int invoiceFileCount = 0;
for (UnionReimburseInvoiceDetail detail : unionReimburse.getInvoiceDetails()) {
if (detail.getInvoiceAmount() != null) {
totalMoney = BigDecimal.valueOf(totalMoney)
.add(BigDecimal.valueOf(detail.getInvoiceAmount()))
.doubleValue();
}
if (Lang.isNotEmpty(detail.getInvoiceFiles())) {
invoiceFileCount++;
}
}
Double applyMoney = invoiceFileCount > 0
? BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()
: unionReimburse.getCondolenceMoney();
unionReimburse.setMoney(applyMoney);
unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount));
}
/**
* 默认实际金额跟随申请金额,后续管理员可在汇总页按最终报销金额调整。
*/
@@ -718,6 +733,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (StrUtil.isBlank(detail.getStr("bankOfDeposit"))) {
return Result.error(prefix + "请填写开户行");
}
// 账户表和报销快照的文本字段长度一致,超长输入应明确拒绝而非丢失账户。
if (detail.getStr("payer").length() > 100 || detail.getStr("bankUserName").length() > 100
|| detail.getStr("bankOfDeposit").length() > 100) {
return Result.error(prefix + "付款人、户名及开户行不能超过100个字符");
}
String card = detail.getStr("bankCardNumber");
if (card == null || !card.matches("[1-9]([0-9]{15}|[0-9]{18})")) {
return Result.error(prefix + "请填写正确的银行卡号");
@@ -727,6 +747,42 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return null;
}
/**
* 支付明细须先通过单行金额校验,再按分比较全部对公、对私金额与发票总额。
* @param form 当前申请,使用 paymentDetails 和 invoiceDetails 重新汇总,不信任前端 money 字段
* @return 超额或发票金额无效时返回 Result 错误,通过返回 null
*/
private Result validatePaymentAmountLimit(UnionReimburse form) {
List<UnionReimburseInvoiceDetail> invoices = form.getInvoiceDetails();
BigDecimal invoiceTotal = BigDecimal.ZERO.setScale(2);
if (invoices != null) {
for (int i = 0; i < invoices.size(); i++) {
UnionReimburseInvoiceDetail detail = invoices.get(i);
if (detail == null) {
continue;
}
Double amount = detail.getInvoiceAmount();
if (amount == null || !Double.isFinite(amount) || amount <= 0) {
return Result.error("第" + (i + 1) + "条发票明细请填写正确的发票金额");
}
BigDecimal invoiceMoney = BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP);
if (invoiceMoney.signum() <= 0) {
return Result.error("第" + (i + 1) + "条发票明细请填写正确的发票金额");
}
invoiceTotal = invoiceTotal.add(invoiceMoney);
}
}
BigDecimal paymentTotal = BigDecimal.ZERO.setScale(2);
for (cn.hutool.json.JSONObject detail : form.getPaymentDetails()) {
paymentTotal = paymentTotal.add(new BigDecimal(detail.get("money").toString()));
}
if (paymentTotal.compareTo(invoiceTotal) > 0) {
return Result.error("支付金额合计不能超过发票总额(支付合计:" + paymentTotal.toPlainString()
+ "元,发票总额:" + invoiceTotal.toPlainString() + "元)");
}
return null;
}
/**
* 提交前补一层后端校验,防止前端绕过校验直接提交。
*/
@@ -747,7 +803,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
// return Result.error("请填写开户行");
// }
if (!needInvoiceDetails(unionReimburse)) {
return validateInvoiceDetails(unionReimburse, false);
return validateInvoiceDetails(unionReimburse);
}
if (StrUtil.isBlank(unionReimburse.getActivityName())) {
return Result.error("请填写活动名称");
@@ -761,29 +817,29 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (Lang.isEmpty(unionReimburse.getFiles())) {
return Result.error("请上传附件");
}
return validateInvoiceDetails(unionReimburse, true);
return validateInvoiceDetails(unionReimburse);
}
/**
* 发票明细提交校验:活动类必须至少一条,慰问类允许为空但填写后必须完整。
* 保存及提交前检查发票完整性,每条明细必须且只能包含一张可定位的发票文件。
*/
private Result validateInvoiceDetails(UnionReimburse unionReimburse, boolean required) {
private Result validateInvoiceDetails(UnionReimburse unionReimburse) {
if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
if (!required) {
return null;
}
return Result.error("请至少维护一条发票明细");
}
for (int i = 0; i < unionReimburse.getInvoiceDetails().size(); i++) {
UnionReimburseInvoiceDetail detail = unionReimburse.getInvoiceDetails().get(i);
int rowIndex = i + 1;
if (Lang.isEmpty(detail.getInvoiceFiles())) {
if (detail == null || Lang.isEmpty(detail.getInvoiceFiles())) {
return Result.error("第" + rowIndex + "条发票明细请上传发票文件");
}
if (detail.getInvoiceFiles().size() != 1 || StrUtil.isBlank(getInvoiceFileId(detail.getInvoiceFiles().get(0)))) {
return Result.error("第" + rowIndex + "条发票明细须上传一个有效发票文件");
}
if (StrUtil.isBlank(detail.getInvoiceNo())) {
return Result.error("第" + rowIndex + "条发票明细请填写发票号码");
}
if (detail.getInvoiceAmount() == null || detail.getInvoiceAmount() <= 0) {
if (detail.getInvoiceAmount() == null || !Double.isFinite(detail.getInvoiceAmount()) || detail.getInvoiceAmount() <= 0) {
return Result.error("第" + rowIndex + "条发票明细请填写正确的发票金额");
}
if (StrUtil.isBlank(detail.getSellerName())) {
@@ -796,6 +852,67 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return null;
}
/**
* @param form 已通过完整性校验的申请,逐条使用附件ID读取真实文件并验真
* @return 全部通过返回 null;失败返回包含明细行号的 Result,不写入任何报销数据
*/
private Result validateInvoiceAuthenticity(UnionReimburse form) {
Set<String> invoiceNumbers = new LinkedHashSet<>();
for (int i = 0; i < form.getInvoiceDetails().size(); i++) {
UnionReimburseInvoiceDetail detail = form.getInvoiceDetails().get(i);
String prefix = "第" + (i + 1) + "条发票明细:";
if (!invoiceNumbers.add(detail.getInvoiceNo())) {
return Result.error(prefix + "发票号码重复,不能保存或提交");
}
Result verified = recognizeInvoice(getInvoiceFileId(detail.getInvoiceFiles().get(0)), true);
if (verified.getCode() != 0) {
return Result.error(prefix + verified.getMsg());
}
if (!(verified.getData() instanceof NutMap data) || !Boolean.TRUE.equals(data.get("verified"))) {
return Result.error(prefix + "未取得有效验真结果,请重试");
}
Double verifiedAmount = parseMoney(data.get("invoiceAmount"));
if (!Objects.equals(detail.getInvoiceNo(), data.getString("invoiceNo"))
|| verifiedAmount == null
|| BigDecimal.valueOf(detail.getInvoiceAmount()).setScale(2, RoundingMode.HALF_UP)
.compareTo(BigDecimal.valueOf(verifiedAmount).setScale(2, RoundingMode.HALF_UP)) != 0
|| !Objects.equals(detail.getSellerName(), data.getString("sellerName"))
|| !Objects.equals(detail.getItemName(), data.getString("itemName"))) {
return Result.error(prefix + "票号、金额、销售方或项目名称与验真结果不一致,请重新识别后保存");
}
// 日期和分项金额均采用服务端重读发票后的结果,不信任前端传入值。
detail.setInvoiceDate(data.getString("invoiceDate"));
detail.setAmountWithoutTax(parseMoney(data.get("amountWithoutTax")));
detail.setTaxAmount(parseMoney(data.get("taxAmount")));
}
return null;
}
/** 兼容上传组件完整对象和历史下载地址;只提取ID,绝不请求前端传入的URL。 */
private String getInvoiceFileId(JSONObject file) {
if (file == null) {
return "";
}
if (StrUtil.isNotBlank(file.getStr("id"))) {
return file.getStr("id").trim();
}
JSONObject response = file.getJSONObject("response");
Object data = response == null ? null : response.get("data");
if (data instanceof JSONObject object && StrUtil.isNotBlank(object.getStr("id"))) {
return object.getStr("id").trim();
}
String value = data instanceof String ? (String) data
: StrUtil.blankToDefault(file.getStr("downloadPath"), file.getStr("url"));
if (StrUtil.isBlank(value)) {
return "";
}
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("[?&]id=([^&#]+)").matcher(value);
if (matcher.find()) {
return matcher.group(1);
}
return data instanceof String && value.matches("[a-zA-Z0-9_-]+") ? value : "";
}
/**
* 提交和审核前统一校验预算是否已分配、余额是否充足。
*/
@@ -1227,52 +1344,153 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
result.setv("invoiceCode", pickValue(infos, "发票代码", "代码", "Code"));
result.setv("invoiceDate", normalizeDate(pickValue(infos, "开票日期", "日期", "Date")));
result.setv("invoiceCheckCode", pickValue(infos, "校验码", "校验码后6位", "CheckCode"));
result.setv("invoiceAmount", parseMoney(pickValue(infos, "小写金额", "价税合计(小写)", "价税合计", "合计金额", "AmountWithTax")));
// 含税总额按字段优先级提取;“合计金额”通常是不含税金额,不能用于全电票核验。
result.setv("invoiceAmount", parseMoney(pickPreferredValue(infos,
"价税合计(小写)", "价税合计(小写)", "价税合计", "小写金额", "AmountWithTax")));
result.setv("amountWithoutTax", parseMoney(pickValue(infos, "合计金额", "金额合计", "不含税金额", "AmountWithoutTax")));
result.setv("taxAmount", parseMoney(pickPreferredValue(infos, "合计税额", "税额合计", "TaxAmount")));
result.setv("sellerName", pickValue(infos, "销售方名称", "销售方信息名称", "销售方", "SellerName"));
result.setv("itemName", joinItemNames(items));
return result;
}
/**
* 自动校验需要票面关键信息,缺字段时只提示人工核对,不阻塞用户继续录入。
* 从OCR票面构造新版核验参数:全电票使用含税金额,普通票使用校验码,专票使用不含税金额。
* @param result 服务端识别的票面数据,不接受前端手工填写的验真参数
* @return 腾讯云请求参数;信息不足时抛出异常,由验真入口返回失败
*/
private String enrichByVerify(Credential credential, NutMap result) {
private NutMap buildVerifyRequest(NutMap result) {
String invoiceCode = result.getString("invoiceCode");
String invoiceNo = result.getString("invoiceNo");
String invoiceDate = result.getString("invoiceDate");
String checkCode = lastDigits(result.getString("invoiceCheckCode"), 6);
if (StrUtil.isBlank(checkCode)) {
checkCode = moneyToVerifyValue(result.get("invoiceAmount"));
boolean electronic = invoiceNo != null && invoiceNo.matches("[0-9]{20}");
if (StrUtil.hasBlank(invoiceNo, invoiceDate) || (!electronic && StrUtil.isBlank(invoiceCode))) {
throw new IllegalArgumentException("发票号码、代码或开票日期识别不完整,请上传清晰发票后重试");
}
if (StrUtil.hasBlank(invoiceCode, invoiceNo, invoiceDate, checkCode)) {
return "发票识别成功,但自动校验信息不完整,请人工核对后提交";
NutMap request = NutMap.NEW().setv("InvoiceNo", invoiceNo).setv("InvoiceDate", invoiceDate);
if (!electronic) {
request.setv("InvoiceCode", invoiceCode);
}
String amount = moneyToVerifyValue(result.get(electronic ? "invoiceAmount" : "amountWithoutTax"));
if (electronic && StrUtil.isBlank(amount)
|| !electronic && checkCode.length() != 6 && StrUtil.isBlank(amount)) {
throw new IllegalArgumentException("发票校验码或核验金额识别不完整,请上传清晰发票后重试");
}
if (!electronic && checkCode.length() == 6) {
request.setv("CheckCode", checkCode);
}
if (StrUtil.isNotBlank(amount)) {
request.setv("Amount", amount);
}
return request;
}
/** 强制验真:缺字段、无结果、作废红冲、票面不符及接口异常均不允许按成功返回。 */
private String enrichByVerify(Credential credential, NutMap result) {
try {
NutMap request = buildVerifyRequest(result);
OcrClient client = createOcrClient(credential);
VatInvoiceVerifyRequest request = new VatInvoiceVerifyRequest();
request.setInvoiceCode(invoiceCode);
request.setInvoiceNo(invoiceNo);
request.setInvoiceDate(invoiceDate.replace("-", ""));
request.setAdditional(checkCode);
VatInvoiceVerifyResponse response = client.VatInvoiceVerify(request);
VatInvoice invoice = response.getInvoice();
if (invoice == null) {
return "发票识别成功,但自动校验未返回有效结果,请人工核对后提交";
// 当前SDK没有新版模型,通过已有签名调用能力请求新版接口,避免升级整个SDK。
JSONObject response = JSONUtil.parseObj(client.call("VatInvoiceVerifyNew", JSONUtil.toJsonStr(request)))
.getJSONObject("Response");
if (response == null || response.getJSONObject("Error") != null) {
String code = response == null ? "EmptyResponse" : response.getJSONObject("Error").getStr("Code");
return "发票验真未通过(" + code + "),请核对票据或稍后重试";
}
result.setv("invoiceCode", invoice.getCode());
result.setv("invoiceNo", invoice.getNumber());
result.setv("invoiceDate", normalizeDate(invoice.getDate()));
result.setv("invoiceCheckCode", invoice.getCheckCode());
result.setv("sellerName", invoice.getSellerName());
result.setv("invoiceAmount", parseMoney(invoice.getAmountWithTax()));
result.setv("itemName", joinItemNames(invoice.getItems()));
return null;
return applyVerifiedInvoice(result, response.getJSONObject("Invoice"));
} catch (IllegalArgumentException e) {
return e.getMessage();
} catch (TencentCloudSDKException e) {
String code = StrUtil.blankToDefault(e.getErrorCode(), "UnknownError");
log.warn("腾讯云发票验真失败,invoiceNo:{},invoiceDate:{},amount:{},errorCode:{},requestId:{}",
result.getString("invoiceNo"), result.getString("invoiceDate"),
result.get("invoiceAmount"), code, e.getRequestId(), e);
if ("FailedOperation.InvoiceMismatch".equals(code)
|| "InvalidParameterValue.PriceOrVerificationParameterValueLimit".equals(code)) {
return "发票验真数据不一致,请核对票号、开票日期及价税合计金额后重新上传";
}
if ("FailedOperation.CountLimitError".equals(code)) {
return "该发票今日核验次数已达上限,请次日再试";
}
if ("ResourceNotFound.NotSupportCurrentInvoiceQuery".equals(code)) {
return "该发票今天开具,暂时无法核验,请稍后再试";
}
return "发票验真未完成(" + code + "),请核对票据或稍后重试";
} catch (Exception e) {
log.warn("腾讯云发票验真失败,invoiceNo:{}", invoiceNo, e);
return "发票识别成功,但自动校验失败,请人工核对后提交";
log.warn("腾讯云发票验真失败,invoiceNo:{}", result.getString("invoiceNo"), e);
return "发票验真未完成,请稍后重试;验真通过后才能保存或提交";
}
}
/** 核验回包必须与上传票面的票号、金额、销售方一致,避免仅凭真实票号放行被改动的票面。 */
private String applyVerifiedInvoice(NutMap result, JSONObject verifiedInvoice) {
if (verifiedInvoice == null) {
return "发票验真未返回有效票据,不能保存或提交";
}
VatInvoice invoice = new com.google.gson.Gson().fromJson(verifiedInvoice.toString(), VatInvoice.class);
if (!"N".equalsIgnoreCase(invoice.getIsAbandoned()) || Boolean.TRUE.equals(verifiedInvoice.getBool("RedLetterInvoiceMark"))) {
return "发票已作废、红冲或状态无法确认,不能保存或提交";
}
Double amount = parseMoney(invoice.getAmountWithTax());
Double ocrAmount = parseMoney(result.get("invoiceAmount"));
// 分项比对只记录必要字段,区分票面识别差异;金额必须保持一致,不能用验真值覆盖后放行。
if (!Objects.equals(result.getString("invoiceNo"), StrUtil.trim(invoice.getNumber()))) {
log.warn("发票票号比对失败,ocrInvoiceNo:{},verifiedInvoiceNo:{}",
result.getString("invoiceNo"), StrUtil.trim(invoice.getNumber()));
return "发票号码与验真结果不一致,请核对上传的发票";
}
if (amount == null || amount <= 0 || ocrAmount == null
|| BigDecimal.valueOf(amount).compareTo(BigDecimal.valueOf(ocrAmount)) != 0) {
log.warn("发票价税合计比对失败,invoiceNo:{},ocrAmount:{},verifiedAmount:{}",
result.getString("invoiceNo"), ocrAmount, amount);
return "发票价税合计与验真结果不一致,请核对含税总金额后重新识别";
}
if (!Objects.equals(normalizeInvoiceSellerName(result.getString("sellerName")),
normalizeInvoiceSellerName(invoice.getSellerName()))) {
log.warn("发票销售方比对失败,invoiceNo:{},ocrSellerName:{},verifiedSellerName:{}",
result.getString("invoiceNo"), result.getString("sellerName"), invoice.getSellerName());
return "发票销售方名称与验真结果不一致,请核对上传的发票";
}
String itemName = joinItemNames(invoice.getItems());
if (StrUtil.hasBlank(invoice.getSellerName(), itemName)) {
return "发票验真结果缺少销售方或项目名称,不能保存或提交";
}
result.setv("invoiceCode", invoice.getCode());
result.setv("invoiceNo", StrUtil.trim(invoice.getNumber()));
// 个别票种核验回包不提供日期,保留已用于本次验真请求的 OCR 开票日期。
if (StrUtil.isNotBlank(invoice.getDate())) {
result.setv("invoiceDate", normalizeDate(invoice.getDate()));
}
result.setv("invoiceCheckCode", invoice.getCheckCode());
result.setv("sellerName", StrUtil.trim(invoice.getSellerName()));
result.setv("invoiceAmount", amount);
// 核验结果优先;部分票种未返回分项金额时保留 OCR 提取值,不用差额推算税额。
Double amountWithoutTax = parseMoney(invoice.getAmountWithoutTax());
Double taxAmount = parseMoney(invoice.getTaxAmount());
Double ocrAmountWithoutTax = parseMoney(result.get("amountWithoutTax"));
Double ocrTaxAmount = parseMoney(result.get("taxAmount"));
// 腾讯云对部分票种用 0.00 占位;票面 OCR 有正数时保留票面值。
if (amountWithoutTax == null || amountWithoutTax == 0D && ocrAmountWithoutTax != null && ocrAmountWithoutTax > 0) {
amountWithoutTax = ocrAmountWithoutTax;
}
if (taxAmount == null || taxAmount == 0D && ocrTaxAmount != null && ocrTaxAmount > 0) {
taxAmount = ocrTaxAmount;
}
result.setv("amountWithoutTax", amountWithoutTax);
result.setv("taxAmount", taxAmount);
result.setv("itemName", itemName);
return null;
}
/**
* 销售方比对仅统一中英文括号及首尾空白,保留名称文字与内部空白,避免将不同主体误判为一致。
* 空值保持为空值;回填仍使用验真返回的原名称,不使用此比较用字符串。
*/
private String normalizeInvoiceSellerName(String name) {
return name == null ? null : StrUtil.trim(name).replace('(', '(').replace(')', ')');
}
private String pickValue(TextVatInvoice[] infos, String... aliases) {
if (infos == null || aliases == null) {
return "";
@@ -1292,6 +1510,17 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return "";
}
/** 按候选字段优先级取首个非空票面值,避免 OCR 返回顺序决定金额含义。 */
private String pickPreferredValue(TextVatInvoice[] infos, String... aliases) {
for (String alias : aliases) {
String value = pickValue(infos, alias);
if (StrUtil.isNotBlank(value)) {
return value;
}
}
return "";
}
private String normalizeFieldName(String name) {
return StrUtil.blankToDefault(name, "")
.replace(":", "")
@@ -1353,6 +1582,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return BigDecimal.valueOf(money).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
@Aop(TransAop.READ_COMMITTED)
private void saveInvoiceDetails(String reimburseId, List<UnionReimburseInvoiceDetail> invoiceDetails) {
this.dao().clear(UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", reimburseId));
if (Lang.isEmpty(invoiceDetails)) {
@@ -1366,7 +1596,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 非慰问类报销必须维护发票明细,慰问类发票明细为可选。
* 区分活动类与慰问类的基本信息校验;所有类型的发票均为必填。
*/
private boolean needInvoiceDetails(UnionReimburse unionReimburse) {
return unionReimburse != null && !"UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject());
File diff suppressed because it is too large Load Diff
@@ -13,24 +13,24 @@ layout("/layouts/platform.html"){
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"></el-input>
</search-item>
<search-item label="所属工会" v-if="isAdmin">
<el-select v-model="pageForm.unionId" @change="doSearch" style="width: 100%"
placeholder="请选择所属工会" clearable>
<el-option v-for="item in unionOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
<el-select v-model="pageForm.unionId" @change="doSearch" style="width: 100%"
placeholder="请选择所属工会" clearable>
<el-option v-for="item in unionOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
</search-item>
<search-item label="所属单位" v-if="isAdmin">
<el-select v-model="pageForm.unitId" @change="doSearch" style="width: 100%"
placeholder="请选择所属单位" clearable>
<el-option v-for="item in unitOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
<el-select v-model="pageForm.unitId" @change="doSearch" style="width: 100%"
placeholder="请选择所属单位" clearable>
<el-option v-for="item in unitOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
</search-item>
<search-item label="报销项目">
<el-select v-model="pageForm.reimburseProject" @change="doSearch" style="width: 100%"
@@ -95,9 +95,13 @@ layout("/layouts/platform.html"){
<el-table-column label="操作" fixed="right" width="350px">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="isAdmin && row.stateId == 3" @click="openEdit(row)" size="mini" type="primary">实际金额</el-button>
<el-button v-if="row.stateId == 3" @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="$auth.hasRole('SYSADMIN')" @click="onDelete(row.id)" size="mini" type="danger">删除</el-button>
<el-button v-if="isAdmin && row.stateId == 3" @click="openEdit(row)" size="mini" type="primary">
实际金额
</el-button>
<el-button @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="$auth.hasRole('SYSADMIN')" @click="onDelete(row.id)" size="mini" type="danger">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -124,13 +128,12 @@ layout("/layouts/platform.html"){
</div>
<script nonce="${cspNonce!}">
<!--#include('../info.js'){}#-->
new Vue({
el: "#app",
store,
dicts: ["UNION_REIMBURSE_TYPE","UNION_REIMBURSE_PROJECT"],
dicts: ["UNION_REIMBURSE_TYPE", "UNION_REIMBURSE_PROJECT"],
//分页数据
mixins: [initTableMixins],
components: {
@@ -149,8 +152,8 @@ layout("/layouts/platform.html"){
},
editFormRules: {
realMoney: [
{ required: true, message: '请输入实际报销金额', trigger: 'blur' },
{ pattern: /^([0-9]+)(\.[0-9]{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur' }
{required: true, message: '请输入实际报销金额', trigger: 'blur'},
{pattern: /^([0-9]+)(\.[0-9]{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur'}
]
}
}
@@ -204,7 +207,7 @@ layout("/layouts/platform.html"){
}
},
onView(row) {
this.$refs.guava.edit(()=>{
this.$refs.guava.edit(() => {
this.showApprovalForm = false
this.$refs.unionReimburseInfoRef.onOpen(row)
})
@@ -266,11 +269,8 @@ layout("/layouts/platform.html"){
window.open('/platform/unionReimburse/collect/exportSkr?year=' + year + "&unionId=" + unionId + '&unitId=' + unitId + "&reimburseProject=" + reimburseProject)
},
doPrint(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '') + "&Print=true"
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '') + "&Print=true"
}
// 所有状态均可下载审批单 PDF,复用公共下载的 POST 请求、错误提示和 loading 收尾。
this.$downLoad("/platform/unionReimburse/collect/exportPdf", {id: row.id})
},
onExport() {
this.$downLoad('/platform/unionReimburse/collect/onExport', this.pageForm)
@@ -130,38 +130,38 @@ const unionReimburseInfo = {
<span>{{ viewData.condolenceTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="结婚时间" v-if="viewData.condolenceTypeId === 'cf4ce7af322d464f8f86d818fcc00203'">
<el-descriptions-item label="结婚时间" v-if="condolenceTypeCode === 'A'">
<span>{{ viewData.marryTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="生育时间" v-if="viewData.condolenceTypeId === 'c90cb10dce6542e99ae271bee6fe8cc0'">
<el-descriptions-item label="生育时间" v-if="condolenceTypeCode === 'D'">
<span>{{ viewData.fertilityTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="住院病由" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="住院病由" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospitalCausation }}</span>
</el-descriptions-item>
<el-descriptions-item label="住院时间" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="住院时间" v-if="condolenceTypeCode === 'E'">
<span v-if="viewData.hospitalizationStartTime && viewData.hospitalizationEndTime">
{{ $moment(viewData.hospitalizationStartTime).format('YYYY-MM-DD') }} 至 {{ $moment(viewData.hospitalizationEndTime).format('YYYY-MM-DD') }}
</span>
<span v-else>暂无住院时间</span>
</el-descriptions-item>
<el-descriptions-item label="入住医院" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="入住医院" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospital }}</span>
</el-descriptions-item>
<el-descriptions-item label="当年次数" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="当年次数" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospitalCount }}</span>
</el-descriptions-item>
<el-descriptions-item label="去逝时间" v-if="viewData.condolenceTypeId === '60f03a87b62b4823855814afdbf5672a'">
<el-descriptions-item label="去逝时间" v-if="condolenceTypeCode === 'G' || condolenceTypeCode === 'H'">
<span>{{ viewData.deathTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="与被慰问人关系" v-if="viewData.condolenceTypeId === '60f03a87b62b4823855814afdbf5672a'">
<el-descriptions-item label="与被慰问人关系" v-if="condolenceTypeCode === 'H'">
<span>{{ viewData.condolenceRelationship }}</span>
</el-descriptions-item>
@@ -174,10 +174,6 @@ const unionReimburseInfo = {
<span>{{ viewData.paymentNotes }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.notes }}</span>
</el-descriptions-item>
<el-descriptions-item label="附件" :span="3">
<file-preview v-if="viewData.files && viewData.files.length > 0"
:files="viewData.files"
@@ -204,15 +200,6 @@ const unionReimburseInfo = {
<el-col :span="24">
<el-form-item label="支付内容" label-width="100px">
<el-input :autosize="{ minRows: 4, maxRows: 8}"
:value="viewData.paymentNotes"
readonly
type="textarea"></el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" label-width="100px">
<el-input :autosize="{ minRows: 3, maxRows: 6}"
:value="viewData.notes"
readonly
type="textarea"></el-input>
@@ -240,7 +227,17 @@ const unionReimburseInfo = {
</template>
</el-table-column>
<el-table-column label="发票号码" min-width="140" prop="invoiceNo"></el-table-column>
<el-table-column align="right" label="发票金额" min-width="110">
<el-table-column align="right" label="税前金额" min-width="110">
<template slot-scope="scope">
<span>{{ scope.row.amountWithoutTax == null ? '-' : formatMoney(scope.row.amountWithoutTax) }}</span>
</template>
</el-table-column>
<el-table-column align="right" label="税额" min-width="100">
<template slot-scope="scope">
<span>{{ scope.row.taxAmount == null ? '-' : formatMoney(scope.row.taxAmount) }}</span>
</template>
</el-table-column>
<el-table-column align="right" label="价税合计" min-width="110">
<template slot-scope="scope">
<span>{{ formatMoney(scope.row.invoiceAmount) }}</span>
</template>
@@ -299,6 +296,11 @@ const unionReimburseInfo = {
}
},
computed: {
// 详情接口按关联类型返回业务编码,避免不同环境的类型 ID 影响字段显示。
condolenceTypeCode() {
return this.viewData.reimburseProject === "UNION_REIMBURSE_PROJECT_1"
? (this.viewData.condolenceTypeCode || "") : ""
},
showInvoiceTab() {
return !!this.viewData.reimburseProject
},
@@ -86,10 +86,9 @@ layout("/layouts/platform.html"){
<el-table-column label="操作" fixed="right" width="300px">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="[2, 3, 4, 5].includes(Number(row.stateId))" @click="doExport(row)" size="mini" type="primary">导出</el-button>
<el-button v-if="row.stateId == 1 || row.stateId == 5" @click="onEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button v-if="row.stateId == 2" @click="onRevoke(row.id)" size="mini" type="danger">撤回</el-button>
<el-button v-if="row.stateId == 3" @click="doExport(row)" size="mini" type="primary">导出报销表</el-button>
<el-button v-if="row.stateId == 3" @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="row.stateId == 1" @click="onDelete(row.id)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
@@ -171,19 +170,8 @@ layout("/layouts/platform.html"){
},
doExport(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '')
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '')
}
},
doPrint(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '') + "&Print=true"
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '') + "&Print=true"
}
// 复用公共下载的POST请求、错误提示和loading收尾,由后端选择对应审批单模板。
this.$downLoad("/platform/unionReimburse/mine/exportPdf", {id: row.id})
}
},
File diff suppressed because it is too large Load Diff
@@ -116,15 +116,15 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">慰问时间</div>
<div class="union-info-value">{{ formatDateText(viewData.condolenceTime) }}</div>
</div>
<div class="union-info-row" v-if="isCondolenceType('cf4ce7af322d464f8f86d818fcc00203')">
<div class="union-info-row" v-if="condolenceTypeCode === 'A'">
<div class="union-info-label">结婚时间</div>
<div class="union-info-value">{{ formatDateText(viewData.marryTime) }}</div>
</div>
<div class="union-info-row" v-if="isCondolenceType('c90cb10dce6542e99ae271bee6fe8cc0')">
<div class="union-info-row" v-if="condolenceTypeCode === 'D'">
<div class="union-info-label">生育时间</div>
<div class="union-info-value">{{ formatDateText(viewData.fertilityTime) }}</div>
</div>
<template v-if="isCondolenceType('4e316737e71047e0b01aea78524c1416')">
<template v-if="condolenceTypeCode === 'E'">
<div class="union-info-row">
<div class="union-info-label">住院病由</div>
<div class="union-info-value">{{ viewData.hospitalCausation }}</div>
@@ -146,12 +146,12 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-value">{{ viewData.hospitalCount }}</div>
</div>
</template>
<template v-if="isCondolenceType('60f03a87b62b4823855814afdbf5672a')">
<template v-if="condolenceTypeCode === 'G' || condolenceTypeCode === 'H'">
<div class="union-info-row">
<div class="union-info-label">去逝时间</div>
<div class="union-info-value">{{ formatDateText(viewData.deathTime) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-row" v-if="condolenceTypeCode === 'H'">
<div class="union-info-label">与被慰问人关系</div>
<div class="union-info-value">{{ viewData.condolenceRelationship }}</div>
</div>
@@ -164,10 +164,6 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">报销事由</div>
<div class="union-info-value">{{ viewData.paymentNotes }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">备注</div>
<div class="union-info-value">{{ viewData.notes }}</div>
</div>
</div>
<div class="union-info-section" v-if="viewData.reimburseProject && viewData.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1'">
@@ -188,10 +184,6 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">活动时间</div>
<div class="union-info-value">{{ formatDateText(viewData.activityTime) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">支付内容</div>
<div class="union-info-value">{{ viewData.paymentNotes }}</div>
</div>
</div>
<div class="union-info-section">
@@ -212,6 +204,10 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">发票张数</div>
<div class="union-info-value">{{ viewData.invoiceNumber }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">支付内容</div>
<div class="union-info-value">{{ viewData.notes }}</div>
</div>
<div class="union-invoice-subtitle">发票明细表</div>
<div class="union-invoice-card-list" v-if="viewData.invoiceDetails && viewData.invoiceDetails.length > 0">
<div class="union-invoice-card" v-for="(item, index) in viewData.invoiceDetails" :key="index">
@@ -221,7 +217,15 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-value">{{ item.invoiceNo || '未填写' }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">发票金额</div>
<div class="union-info-label">税前金额</div>
<div class="union-info-value">{{ item.amountWithoutTax == null ? '未识别' : formatMoney(item.amountWithoutTax) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">税额</div>
<div class="union-info-value">{{ item.taxAmount == null ? '未识别' : formatMoney(item.taxAmount) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">价税合计</div>
<div class="union-info-value">{{ formatMoney(item.invoiceAmount) }}</div>
</div>
<div class="union-info-row">
@@ -333,6 +337,11 @@ const UNION_REIMBURSE_INFO = {
}
},
computed: {
// 详情接口按关联类型返回业务编码,避免不同环境的类型 ID 影响字段显示。
condolenceTypeCode() {
return this.viewData.reimburseProject === "UNION_REIMBURSE_PROJECT_1"
? (this.viewData.condolenceTypeCode || "") : ""
},
paymentInfoRows() {
return this.viewData.paymentDetails || []
}
@@ -406,9 +415,6 @@ const UNION_REIMBURSE_INFO = {
const numberValue = Number(value)
return isNaN(numberValue) ? value : numberValue.toFixed(2)
},
isCondolenceType(typeId) {
return this.viewData.condolenceTypeId === typeId || this.viewData.condolenceType === typeId
},
getPaymentWayText(paymentWay) {
const matchedPaymentWay = this.dict.type.UNION_REIMBURSE_PAYMENT_WAY.find(item => item.code === paymentWay)
return matchedPaymentWay ? matchedPaymentWay.name : ""