From d94a6ed01450ba9854b7e34a37c881e48a79260e Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Tue, 29 Sep 2026 14:06:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=B7=A5=E4=BC=9A=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=BB=A5=E5=8F=8A=E5=8F=91=E7=A5=A8=E5=A1=AB?= =?UTF-8?q?=E6=8A=A5=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UnionReimburseApplyController.java | 37 +- .../UnionReimburseCollectController.java | 18 + .../UnionReimburseMineController.java | 288 +----- .../unionReimburse/model/UnionReimburse.java | 3 + .../model/UnionReimburseBankAccount.java | 42 + .../model/UnionReimburseInvoiceDetail.java | 15 + .../UnionReimburseBankAccountService.java | 23 + .../service/UnionReimburseExportService.java | 26 + .../service/UnionReimburseService.java | 5 +- .../UnionReimburseBankAccountServiceImpl.java | 128 +++ .../impl/UnionReimburseExportServiceImpl.java | 271 ++++++ .../impl/UnionReimburseServiceImpl.java | 376 +++++-- .../unionReimburse/apply/index.html | 722 +++++++++----- .../unionReimburse/collect/index.html | 58 +- .../zhgh/dayofficework/unionReimburse/info.js | 46 +- .../unionReimburse/mine/index.html | 18 +- .../unionReimburse/apply/index.html | 916 ++++++++++-------- .../dayofficework/unionReimburse/info.js | 40 +- 18 files changed, 1959 insertions(+), 1073 deletions(-) create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java create mode 100644 src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java index 509f6499..fe93d84a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java @@ -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); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java index 40e77654..63996dc9 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java @@ -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) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java index bf4572da..58e737c0 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java @@ -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 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 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 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 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()); - } - } - - } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java index 665ee424..79b342c8 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java @@ -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) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java new file mode 100644 index 00000000..e42b5422 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java @@ -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; +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java index a526f7b7..975395f7 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java @@ -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) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java new file mode 100644 index 00000000..0dc6995e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java @@ -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 { + /** + * @param payer 付款人用户ID,非姓名或工号 + * @param reimburseFundSource 当前经费来源字典编码 + * @param clubId 协会经费对应的协会ID,其他来源可为空 + * @return 可选账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit + */ + List listAccounts(String payer, String reimburseFundSource, String clubId); + + /** @param form 已通过校验的报销单;提取全部对私支付明细,去重保存完整账户。 */ + void saveAccounts(UnionReimburse form); + + /** @return 扫描的历史报销单数量;仅系统管理员可执行,可重复执行且不覆盖已有账户。 */ + int importHistory(); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java new file mode 100644 index 00000000..506930c1 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java @@ -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 buildExportData(UnionReimburse form); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java index 7bac1561..138f5a01 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java @@ -50,7 +50,10 @@ public interface UnionReimburseService extends BaseService { Result checkInvoiceDuplicate(String invoiceNo, String reimburseId); /** - * 识别单张发票文件,并在需要时调用验真接口补齐票面信息。 + * 识别单张发票文件并强制验真。 + * @param fileId 已上传发票的文件ID + * @param verifySwitch 兼容旧页面参数,不再允许关闭验真 + * @return Result.data 包含票号、金额、销售方、项目名称及 verified=true;失败返回非零 code 和原因 */ Result recognizeInvoice(String fileId, Boolean verifySwitch); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java new file mode 100644 index 00000000..b81d29cc --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java @@ -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 + implements UnionReimburseBankAccountService { + public UnionReimburseBankAccountServiceImpl(Dao dao) { + super(dao); + } + + /** 按当前付款人候选范围校验用户ID,防止直接传入其他人员ID读取账户。 */ + @Override + public List 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 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(); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java new file mode 100644 index 00000000..f81d3a2f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java @@ -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 buildExportData(UnionReimburse form) { + Map 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 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> 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 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> 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 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"); } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java index 42b6951e..8e47146f 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java @@ -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 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 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 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 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 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 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 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 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 invoiceDetails = this.dao().query( UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", id).asc("createdAt") @@ -416,6 +441,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl 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 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 i } /** - * 活动类报销由发票明细自动汇总金额和发票张数,减少手工输入出错。 + * 所有报销类型由必填发票明细自动汇总金额和张数。 */ private void fillInvoiceSummary(UnionReimburse unionReimburse) { double totalMoney = 0D; @@ -650,29 +688,6 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl 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 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 i return null; } + /** + * 支付明细须先通过单行金额校验,再按分比较全部对公、对私金额与发票总额。 + * @param form 当前申请,使用 paymentDetails 和 invoiceDetails 重新汇总,不信任前端 money 字段 + * @return 超额或发票金额无效时返回 Result 错误,通过返回 null + */ + private Result validatePaymentAmountLimit(UnionReimburse form) { + List 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 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 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 i return null; } + /** + * @param form 已通过完整性校验的申请,逐条使用附件ID读取真实文件并验真 + * @return 全部通过返回 null;失败返回包含明细行号的 Result,不写入任何报销数据 + */ + private Result validateInvoiceAuthenticity(UnionReimburse form) { + Set 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 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 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 i return BigDecimal.valueOf(money).setScale(2, RoundingMode.HALF_UP).toPlainString(); } + @Aop(TransAop.READ_COMMITTED) private void saveInvoiceDetails(String reimburseId, List invoiceDetails) { this.dao().clear(UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", reimburseId)); if (Lang.isEmpty(invoiceDetails)) { @@ -1366,7 +1596,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } /** - * 非慰问类报销必须维护发票明细,慰问类发票明细为可选。 + * 区分活动类与慰问类的基本信息校验;所有类型的发票均为必填。 */ private boolean needInvoiceDetails(UnionReimburse unionReimburse) { return unionReimburse != null && !"UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject()); diff --git a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/apply/index.html b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/apply/index.html index f8f33bb7..9f612138 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/apply/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/apply/index.html @@ -101,7 +101,8 @@ layout("/layouts/platform.html"){ - {{formData.userName}}({{formData.loginName}}) + {{formData.userName}}({{formData.loginName}}) + @@ -155,7 +156,6 @@ layout("/layouts/platform.html"){ - @@ -290,7 +290,7 @@ layout("/layouts/platform.html"){ + v-if="condolenceTypeCode === 'A'"> + v-if="condolenceTypeCode === 'D'"> + v-if="condolenceTypeCode === 'E'"> @@ -320,7 +320,7 @@ layout("/layouts/platform.html"){ + v-if="condolenceTypeCode === 'E'"> + v-if="condolenceTypeCode === 'E'"> @@ -343,7 +343,7 @@ layout("/layouts/platform.html"){ + v-if="condolenceTypeCode === 'E'"> + v-if="condolenceTypeCode === 'G' || condolenceTypeCode === 'H'"> + v-if="condolenceTypeCode === 'H'"> @@ -377,6 +377,9 @@ layout("/layouts/platform.html"){ + + @@ -386,6 +389,8 @@ layout("/layouts/platform.html"){ type="textarea" :autosize="{ minRows: 4, maxRows: 8}"> + + @@ -393,17 +398,8 @@ layout("/layouts/platform.html"){ type="textarea" :autosize="{ minRows: 4, maxRows: 8}"> - - - - - - + @@ -416,7 +412,7 @@ layout("/layouts/platform.html"){ upload_mode="drag" > - +
{{ formData.condolenceFilesNotes || getFileDescByProject(formData.reimburseProject) || '需要提供购买的慰问品发票,慰问照片' }} @@ -453,9 +449,9 @@ layout("/layouts/platform.html"){ - +
支付内容
-
@@ -463,9 +459,13 @@ layout("/layouts/platform.html"){
支付信息
- 新增支付信息 + 新增支付信息 +
- + + @@ -518,25 +521,27 @@ layout("/layouts/platform.html"){ 无需填写 - + @@ -573,29 +565,18 @@ layout("/layouts/platform.html"){ - - - - + 无需填写 @@ -606,16 +587,11 @@ layout("/layouts/platform.html"){
发票明细列表
- 每条明细对应一个发票文件,可上传图片或 PDF;报销金额和发票张数会根据下表自动汇总。 + 所有报销类型均须上传发票并通过验真;每条明细对应一张发票图片或单页 PDF,金额和张数自动汇总。
- - + 发票必须验真 新增发票 @@ -634,8 +610,17 @@ layout("/layouts/platform.html"){ - + + + + + + @@ -658,12 +643,15 @@ layout("/layouts/platform.html"){ - 保存 + 保存 + + 上一步 + 下一步 - 提交 - 提交 + 提交 + 提交 @@ -688,30 +676,49 @@ layout("/layouts/platform.html"){ accept=".jpg,.jpeg,.png,.pdf">
- 上传发票文件后会自动识别发票号码、金额、销售方信息名称和项目名称;如已开启自动校验,还会继续校验发票信息。 + 上传后自动识别并验真,票面信息由验真结果回填。验真失败时请更换清晰发票或重试。 + 重新验真 +
{{ recognizingInvoice ? '正在识别并验真,请稍候' : (detailForm.verified ? '发票验真通过' : '发票尚未通过验真') }}
- + - - + + + + + + + + + + + + + + + + + - + - + @@ -727,7 +734,7 @@ layout("/layouts/platform.html"){ 取 消 - 保 存 + 保 存
@@ -748,8 +755,7 @@ layout("/layouts/platform.html"){ hospitalizationTimeRange: [], reimburseProject: '', paymentDetails: [], - invoiceDetails: [], - invoiceVerifyEnabled: true + invoiceDetails: [] }, historyData: [], invoiceDialogVisible: false, @@ -757,10 +763,15 @@ layout("/layouts/platform.html"){ detailFormIndex: -1, lastRecognizedFileId: "", recognizingInvoice: false, + invoiceRequestId: 0, + formLoading: false, detailForm: { + verified: false, invoiceFiles: [], invoiceNo: "", invoiceAmount: null, + amountWithoutTax: null, + taxAmount: null, sellerName: "", itemName: "", remark: "", @@ -773,7 +784,12 @@ layout("/layouts/platform.html"){ {label: "提货券(B)", value: "提货券(B)"}, {label: "慰问品(C)", value: "慰问品(C)"} ], + // 直系亲属关系增加明确称谓,保留旧选项以兼容历史申请。 relationships: [ + {label: "父子", value: "父子"}, + {label: "父女", value: "父女"}, + {label: "母子", value: "母子"}, + {label: "母女", value: "母女"}, {label: "配偶", value: "配偶"}, {label: "父亲", value: "父亲"}, {label: "母亲", value: "母亲"}, @@ -784,18 +800,35 @@ layout("/layouts/platform.html"){ paymentWay: [{required: true, message: "请选择支付方式", trigger: ["change", "blur"]}], reimburseProject: [{required: true, message: "请选择报销项目", trigger: ["change", "blur"]}], reimburseFundSource: [{required: true, message: "请选择经费来源", trigger: ["change", "blur"]}], - bankUserName: [{required: true, whitespace: true, message: "请填写户名", trigger: ["change", "blur"]}], + bankUserName: [{ + required: true, + whitespace: true, + message: "请填写户名", + trigger: ["change", "blur"] + }], bankCardNumber: [{ required: true, message: "请填写银行账号", trigger: ["change", "blur"] }, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}], - bankOfDeposit: [{required: true, whitespace: true, message: "请填写开户行", trigger: ["change", "blur"]}], + bankOfDeposit: [{ + required: true, + whitespace: true, + message: "请填写开户行", + trigger: ["change", "blur"] + }], condolenceTime: [{required: true, message: "请选择慰问时间", trigger: ["change", "blur"]}], // invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}], // invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}], activityName: [{required: true, message: "请填写活动名称", trigger: ["change", "blur"]}], money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}], + condolenceRelationship: [{required: true, message: "与被慰问人关系", trigger: ["change", "blur"]}], + marryTime: [{required: true, message: "结婚时间", trigger: ["change", "blur"]}], + fertilityTime: [{required: true, message: "生育时间", trigger: ["change", "blur"]}], + hospitalizationTimeRange: [{required: true, message: "住院时间", trigger: ["change", "blur"]}], + hospital: [{required: true, message: "入住医院", trigger: ["change", "blur"]}], + hospitalCount: [{required: true, message: "当年次数", trigger: ["change", "blur"]}], + deathTime: [{required: true, message: "去逝时间", trigger: ["change", "blur"]}], mobile: [ {required: true, message: "请填写联系方式", trigger: ["change", "blur"]}, {pattern: /^(1[3-9]\d{9}|0\d{2,3}-?\d{7,8})$/, message: "请输入正确的联系方式", trigger: "blur"} @@ -846,13 +879,23 @@ layout("/layouts/platform.html"){ trigger: ["change", "blur"] }], payer: [{required: true, message: "请选择付款人", trigger: ["change", "blur"]}], - bankUserName: [{required: true, whitespace: true, message: "请填写户名", trigger: ["change", "blur"]}], + bankUserName: [{ + required: true, + whitespace: true, + message: "请填写户名", + trigger: ["change", "blur"] + }], bankCardNumber: [{ required: true, message: "请填写银行卡号", trigger: ["change", "blur"] }, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}], - bankOfDeposit: [{required: true, whitespace: true, message: "请填写开户行", trigger: ["change", "blur"]}] + bankOfDeposit: [{ + required: true, + whitespace: true, + message: "请填写开户行", + trigger: ["change", "blur"] + }] }, chooseType: {}, userOptions: [], @@ -867,6 +910,13 @@ layout("/layouts/platform.html"){ } }, computed: { + // 按选中类型的业务编码控制字段,类型列表异步加载及旧申请回显后自动重新计算。 + condolenceTypeCode() { + if (this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_1") return "" + const typeId = this.formData.condolenceTypeId || this.formData.condolenceType + const selectedType = this.typeOptions.find(item => item.id === typeId) + return selectedType ? selectedType.code : "" + }, // 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。 canViewFundBalance() { return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"]) @@ -877,6 +927,10 @@ layout("/layouts/platform.html"){ paymentInfoRows() { return this.formData.paymentDetails || [] }, + // 支付金额或发票增删改后自动重算,填写过程中持续显示超额原因。 + paymentAmountLimitMessage() { + return this.getPaymentAmountLimitMessage(this.formData.paymentDetails || []) + }, bankHistoryUserNames() { const userNameMap = {} return (this.historyData || []).reduce((result, item) => { @@ -894,13 +948,17 @@ layout("/layouts/platform.html"){ const file = newVal && newVal.length ? newVal[0] : null const currentFileId = this.getInvoiceFileId(file) if (!currentFileId) { - this.lastRecognizedFileId = "" + // 删除文件使旧请求失效,同时清除验真状态和票面,避免沿用上一张发票。 + this.$set(this, "invoiceRequestId", this.invoiceRequestId + 1) + this.$set(this, "lastRecognizedFileId", "") + this.$set(this, "recognizingInvoice", false) + this.resetInvoiceVerification() return } if (currentFileId === this.lastRecognizedFileId) { return } - this.recognizeCurrentInvoice(false) + this.recognizeCurrentInvoice() }, deep: true }, @@ -929,9 +987,12 @@ layout("/layouts/platform.html"){ methods: { buildDefaultDetailForm() { return { + verified: false, invoiceFiles: [], invoiceNo: "", invoiceAmount: null, + amountWithoutTax: null, + taxAmount: null, sellerName: "", itemName: "", remark: "", @@ -942,7 +1003,7 @@ layout("/layouts/platform.html"){ }, buildDefaultPaymentDetail(detail) { const currentDetail = detail || {} - return { + const row = { paymentWay: currentDetail.paymentWay || "", money: currentDetail.money || "", payer: currentDetail.payer || null, @@ -952,8 +1013,14 @@ layout("/layouts/platform.html"){ bankCardNumber: currentDetail.bankCardNumber || null, bankOfDeposit: currentDetail.bankOfDeposit || null, payerOptions: Array.isArray(currentDetail.payerOptions) ? currentDetail.payerOptions : [], - historyData: Array.isArray(currentDetail.historyData) ? currentDetail.historyData : [] + historyData: Array.isArray(currentDetail.historyData) ? currentDetail.historyData : [], + bankAccountId: null, + bankAccountLoading: false, + bankAccountRequestId: 0 } + // 账户列表加载前也为单据快照建立选项,避免户名文本被识别为新建账户。 + this.syncCurrentBankAccount(row) + return row }, ensureFormData(formData) { const nextFormData = formData || {} @@ -963,9 +1030,6 @@ layout("/layouts/platform.html"){ if (!Array.isArray(nextFormData.files)) { this.$set(nextFormData, "files", []) } - if (typeof nextFormData.invoiceVerifyEnabled !== "boolean") { - this.$set(nextFormData, "invoiceVerifyEnabled", true) - } const hasLegacyPayment = nextFormData.paymentWay || nextFormData.payer || nextFormData.bankUserName || nextFormData.bankCardNumber || nextFormData.bankOfDeposit if (Array.isArray(nextFormData.paymentDetails) && nextFormData.paymentDetails.length > 0) { this.$set(nextFormData, "paymentDetails", nextFormData.paymentDetails.map(item => this.buildDefaultPaymentDetail(item))) @@ -1042,6 +1106,9 @@ layout("/layouts/platform.html"){ this.$set(item, "bankCardNumber", null) this.$set(item, "bankOfDeposit", null) this.$set(item, "historyData", []) + this.$set(item, "bankAccountId", null) + this.$set(item, "bankAccountRequestId", (item.bankAccountRequestId || 0) + 1) + this.$set(item, "bankAccountLoading", false) this.$set(item, "payerOptions", []) }) this.$set(this.formData, "payer", null) @@ -1082,84 +1149,113 @@ layout("/layouts/platform.html"){ } }) }, - // 获取用户历史银行卡信息 - getUserBankHistory(row) { - if (!row.payer) { - this.$set(row, "historyData", []) - return; + // payer 为用户ID;接口返回独立账户表的 id、户名、卡号和开户行列表。 + // 编辑回显只加载候选;只有主动切换付款人且尚未手工输入时才允许唯一账户自动回填。 + getUserBankHistory(row, autoFill) { + const requestId = (row.bankAccountRequestId || 0) + 1 + const payer = row.payer + this.$set(row, "bankAccountRequestId", requestId) + this.$set(row, "historyData", []) + this.syncCurrentBankAccount(row) + if (!payer) { + this.$set(row, "bankAccountLoading", false) + return } - - this.$axios.get("/platform/unionReimburse/apply/findUserBankHistory", { - params: {payer: row.payer} + this.$set(row, "bankAccountLoading", true) + return this.$axios.post("/platform/unionReimburse/apply/listBankAccounts", { + payer: payer, + reimburseFundSource: this.formData.reimburseFundSource, + clubId: this.formData.clubId }).then((res) => { - if (res.code === 0) { - this.$set(row, "historyData", Array.isArray(res.data) ? res.data : []) + // 付款人切换、删除支付行或切换对公支付后,丢弃旧请求结果。 + if (row.bankAccountRequestId !== requestId || row.payer !== payer + || !this.isPrivatePayment(row) || !this.formData.paymentDetails.includes(row)) { + return + } + if (res.code !== 0) { + this.$message.warning(res.msg || "查询收款账户失败") + return + } + const accounts = Array.isArray(res.data) ? res.data : [] + this.$set(row, "historyData", accounts) + if (autoFill && accounts.length === 1 && !row.bankUserName + && !row.bankCardNumber && !row.bankOfDeposit) { + this.handleBankUserNameChange(row, accounts[0].id) } else { - console.warn("获取历史银行卡信息失败:", res); - this.$set(row, "historyData", []) + // 仅建立选中关系,不能用账户表资料覆盖正在编辑的单据快照。 + this.syncCurrentBankAccount(row) + } + }).catch(() => { + if (row.bankAccountRequestId === requestId && row.payer === payer) { + this.$message.warning("查询收款账户失败,请重试或手工填写") + } + }).finally(() => { + if (row.bankAccountRequestId === requestId) { + this.$set(row, "bankAccountLoading", false) } }) }, - fillBankInfoFromHistory(row) { - const matchItem = (row.historyData || []).find((item) => { - if (row.bankUserName && item.bankUserName !== row.bankUserName) { - return false + // 按户名、完整卡号和开户行匹配账户;不匹配时只补充当前行的临时选项。 + // 临时选项随表单字段更新,getCleanPaymentDetails 不提交选项或账户ID。 + syncCurrentBankAccount(row) { + const accounts = (row.historyData || []).filter(item => !item.currentFormAccount) + const normalize = value => String(value == null ? "" : value).trim() + const name = normalize(row.bankUserName) + let selectedId = null + if (name) { + const matched = accounts.find(item => normalize(item.bankUserName) === name + && normalize(item.bankCardNumber) === normalize(row.bankCardNumber) + && normalize(item.bankOfDeposit) === normalize(row.bankOfDeposit)) + if (matched) { + selectedId = matched.id + } else { + // 使用保留前缀区分本行快照与数据库UUID;每行下拉独立维护选项。 + selectedId = "current-form-bank-account" + accounts.unshift({ + id: selectedId, + bankUserName: row.bankUserName, + bankCardNumber: row.bankCardNumber, + bankOfDeposit: row.bankOfDeposit, + currentFormAccount: true + }) } - if (row.bankCardNumber && item.bankCardNumber !== row.bankCardNumber) { - return false - } - if (row.bankOfDeposit && item.bankOfDeposit !== row.bankOfDeposit) { - return false - } - return true - }) - if (matchItem) { - this.$set(row, "bankUserName", matchItem.bankUserName) - this.$set(row, "bankCardNumber", matchItem.bankCardNumber) - this.$set(row, "bankOfDeposit", matchItem.bankOfDeposit) } + this.$set(row, "historyData", accounts) + this.$set(row, "bankAccountId", selectedId) }, - handleBankUserNameChange(row) { - const matchItem = (row.historyData || []).find((item) => item.bankUserName === row.bankUserName) - if (matchItem) { - this.$set(row, "bankCardNumber", matchItem.bankCardNumber) - this.$set(row, "bankOfDeposit", matchItem.bankOfDeposit) - } + // 选中框只显示户名,下拉内容展示开户行和尾号,同名多卡仍按账户ID选择。 + bankAccountLabel(account) { + return account.bankUserName + (account.bankOfDeposit ? "|" + account.bankOfDeposit : "") + + (account.bankCardNumber ? "|尾号" + String(account.bankCardNumber).slice(-4) : "") }, - getBankHistoryUserNames(row) { - const userNameMap = {} - return (row.historyData || []).reduce((result, item) => { - if (item.bankUserName && !userNameMap[item.bankUserName]) { - userNameMap[item.bankUserName] = true - result.push(item.bankUserName) - } - return result - }, []) + // value 为选中的账户ID,或 allow-create 输入的新户名;账户ID仅用于界面选择。 + handleBankUserNameChange(row, value) { + const account = (row.historyData || []).find(item => item.id === value) + // 重选当前纯文本户名时保留银行资料;真正改名或清空时才重置资料。 + if (!account && value && value === row.bankUserName) { + this.syncCurrentBankAccount(row) + return + } + this.$set(row, "bankUserName", account ? account.bankUserName : (value || null)) + this.$set(row, "bankCardNumber", account ? account.bankCardNumber : null) + this.$set(row, "bankOfDeposit", account ? account.bankOfDeposit : null) + this.syncCurrentBankAccount(row) }, - handleVerifySwitchChange(value) { - this.$set(this.formData, "invoiceVerifyEnabled", !!value) - if (value && this.invoiceDialogVisible) { - this.recognizeCurrentInvoice(true) - } + // 手工修改后重新匹配账户或更新本行临时选项,避免选中值与回显资料不一致。 + handleBankFieldInput(row, field, value) { + this.$set(row, field, value) + this.syncCurrentBankAccount(row) }, - - async payerUserChange(row) { - if (row.payer) { - await this.getUserBankHistory(row); - } else { - this.$set(row, "historyData", []) - this.$set(row, "payerName", null) - this.$set(row, "payerLoginname", null) - this.$set(row, "bankUserName", null) - this.$set(row, "bankCardNumber", null) - this.$set(row, "bankOfDeposit", null) - } - const user = row.payerOptions.find(o => o.id === row.payer) - if (user) { - const {userName, loginName,} = user - this.$set(row, "payerName", userName); - this.$set(row, "payerLoginname", loginName); - } + // 切换付款人必须清空旧账户;从A切到B再切回A也由请求序号隔离响应。 + payerUserChange(row) { + this.$set(row, "bankAccountId", null) + this.$set(row, "bankUserName", null) + this.$set(row, "bankCardNumber", null) + this.$set(row, "bankOfDeposit", null) + const user = row.payerOptions.find(item => item.id === row.payer) + this.$set(row, "payerName", user ? user.userName : null) + this.$set(row, "payerLoginname", user ? user.loginName : null) + this.getUserBankHistory(row, true) }, async userChange(val) { const user = this.userOptions.find(o => o.id === val) @@ -1212,6 +1308,9 @@ layout("/layouts/platform.html"){ }) }, openInvoiceDialog(mode, row, index) { + // 每次打开独立的编辑会话;旧弹框请求不得回填到当前发票。 + this.$set(this, "invoiceRequestId", this.invoiceRequestId + 1) + this.$set(this, "recognizingInvoice", false) this.invoiceDialogMode = mode this.detailFormIndex = typeof index === "number" ? index : -1 this.lastRecognizedFileId = "" @@ -1220,6 +1319,7 @@ layout("/layouts/platform.html"){ } else { this.$set(this, "detailForm", this.buildDefaultDetailForm()) } + this.$set(this.detailForm, "verified", false) this.invoiceDialogVisible = true this.$nextTick(() => { if (this.$refs.invoiceDetailForm) { @@ -1281,22 +1381,40 @@ layout("/layouts/platform.html"){ } window.open("/platform/sys/file/download?id=" + fileId) }, - recognizeCurrentInvoice(showLoading) { + // 更换文件及重新验真时清空旧票面,失败后不保留可保存的成功状态。 + resetInvoiceVerification() { + this.$set(this.detailForm, "verified", false) + const fields = ["invoiceNo", "sellerName", "itemName", "invoiceCode", "invoiceDate", "invoiceCheckCode"] + fields.forEach((field) => { + this.$set(this.detailForm, field, "") + }) + this.$set(this.detailForm, "invoiceAmount", null) + this.$set(this.detailForm, "amountWithoutTax", null) + this.$set(this.detailForm, "taxAmount", null) + }, + // 请求参数为上传文件ID;仅 verified=true 的成功响应允许保存,前端状态不替代后端验真。 + recognizeCurrentInvoice() { const invoiceFile = this.getInvoiceFile(this.detailForm) const invoiceFileId = this.getInvoiceFileId(invoiceFile) if (!invoiceFileId) { - return Promise.resolve() + return Promise.resolve(false) } - this.lastRecognizedFileId = invoiceFileId - this.recognizingInvoice = !!showLoading - return this.$axios.get("/platform/unionReimburse/apply/recognizeInvoice", { - params: { - fileId: invoiceFileId, - verifySwitch: this.formData.invoiceVerifyEnabled - } + const requestId = this.invoiceRequestId + 1 + const currentForm = this.detailForm + this.$set(this, "invoiceRequestId", requestId) + this.$set(this, "lastRecognizedFileId", invoiceFileId) + this.$set(this, "recognizingInvoice", true) + this.resetInvoiceVerification() + return this.$axios.post("/platform/unionReimburse/apply/recognizeInvoice", { + fileId: invoiceFileId, + verifySwitch: true }).then((resp) => { - if (resp.code !== 0) { - this.$message.warning(resp.msg) + if (requestId !== this.invoiceRequestId || currentForm !== this.detailForm + || invoiceFileId !== this.getInvoiceFileId(this.getInvoiceFile(this.detailForm))) { + return false + } + if (resp.code !== 0 || !resp.data || resp.data.verified !== true) { + this.$message.warning(resp.msg || "发票尚未通过验真") return false } const data = resp.data || {} @@ -1306,6 +1424,12 @@ layout("/layouts/platform.html"){ if (data.invoiceAmount !== null && data.invoiceAmount !== undefined && data.invoiceAmount !== "") { this.$set(this.detailForm, "invoiceAmount", Number(data.invoiceAmount)) } + if (data.amountWithoutTax !== null && data.amountWithoutTax !== undefined && data.amountWithoutTax !== "") { + this.$set(this.detailForm, "amountWithoutTax", Number(data.amountWithoutTax)) + } + if (data.taxAmount !== null && data.taxAmount !== undefined && data.taxAmount !== "") { + this.$set(this.detailForm, "taxAmount", Number(data.taxAmount)) + } if (data.sellerName) { this.$set(this.detailForm, "sellerName", data.sellerName) } @@ -1322,23 +1446,31 @@ layout("/layouts/platform.html"){ this.$set(this.detailForm, "invoiceCheckCode", data.invoiceCheckCode) } if (this.detailForm.invoiceNo) { - return this.validateInvoiceDuplicateBeforeSave(true) + return this.validateInvoiceDuplicateBeforeSave(true).then((valid) => { + if (requestId === this.invoiceRequestId && currentForm === this.detailForm) { + this.$set(this.detailForm, "verified", valid) + } + return valid + }) } - return true + return false }).catch((error) => { + if (requestId !== this.invoiceRequestId) return false const errorMsg = error && error.response && error.response.data && error.response.data.msg ? error.response.data.msg : (error && error.msg ? error.msg : "发票识别请求失败,请稍后重试") this.$message.error(errorMsg) return false }).finally(() => { - this.recognizingInvoice = false + if (requestId === this.invoiceRequestId) { + this.$set(this, "recognizingInvoice", false) + } }) }, - async validateInvoiceDuplicateBeforeSave(checkHistory) { + validateInvoiceDuplicateBeforeSave(checkHistory) { const invoiceNo = this.detailForm.invoiceNo ? String(this.detailForm.invoiceNo).trim() : "" if (!invoiceNo) { - return false + return Promise.resolve(false) } const duplicateRows = [] const invoiceDetails = this.formData.invoiceDetails || [] @@ -1353,40 +1485,42 @@ layout("/layouts/platform.html"){ } if (duplicateRows.length > 0) { this.$message.warning("发票号码【" + invoiceNo + "】与本次提交的第" + duplicateRows.join("、") + "条发票重复,不能保存") - return false + return Promise.resolve(false) } if (!checkHistory) { - return true + return Promise.resolve(true) } - const resp = await this.$axios.get("/platform/unionReimburse/apply/checkInvoiceDuplicate", { - params: { - invoiceNo: invoiceNo, - reimburseId: this.formData.id || "" + return this.$axios.post("/platform/unionReimburse/apply/checkInvoiceDuplicate", { + invoiceNo: invoiceNo, + reimburseId: this.formData.id || "" + }).then((resp) => { + if (resp.code !== 0) { + this.$message.warning(resp.msg) + return false } + return true }) - if (resp.code !== 0) { - this.$message.warning(resp.msg) - return false - } - return true }, - async saveInvoiceDetail() { - this.$refs.invoiceDetailForm.validate(async (valid) => { + saveInvoiceDetail() { + if (this.recognizingInvoice || !this.detailForm.verified) { + this.$message.warning("请等待发票验真通过后保存") + return + } + this.$refs.invoiceDetailForm.validate((valid) => { if (!valid) { return } - const duplicateValid = await this.validateInvoiceDuplicateBeforeSave(false) - if (!duplicateValid) { - return - } - const detailData = JSON.parse(JSON.stringify(this.detailForm)) - if (this.detailFormIndex > -1) { - this.$set(this.formData.invoiceDetails, this.detailFormIndex, detailData) - } else { - this.formData.invoiceDetails.push(detailData) - } - this.rebuildInvoiceSummary() - this.invoiceDialogVisible = false + this.validateInvoiceDuplicateBeforeSave(false).then((duplicateValid) => { + if (!duplicateValid) return + const detailData = JSON.parse(JSON.stringify(this.detailForm)) + if (this.detailFormIndex > -1) { + this.$set(this.formData.invoiceDetails, this.detailFormIndex, detailData) + } else { + this.formData.invoiceDetails.push(detailData) + } + this.rebuildInvoiceSummary() + this.$set(this, "invoiceDialogVisible", false) + }) }) }, removeInvoiceDetail(index) { @@ -1411,19 +1545,14 @@ layout("/layouts/platform.html"){ fileCount = fileCount + 1 } } - // 慰问类发票为可选附件;有发票按发票汇总金额,没有发票按慰问金额。 - if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_1") { - const condolenceMoney = this.formData.condolenceMoney - this.$set(this.formData, "money", fileCount > 0 ? (totalMoney ? totalMoney.toFixed(2) : "0.00") : condolenceMoney) - } else { - this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00") - } + // 所有类型都按必填发票汇总,不再使用无票慰问金额兜底。 + this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00") this.$set(this.formData, "invoiceNumber", String(fileCount)) }, validateInvoiceDetailsBeforeSubmit() { const invoiceDetails = this.formData.invoiceDetails || [] - // 慰问类允许不填发票;如果填写了发票明细,则每条明细仍需完整。 - if (!invoiceDetails.length && this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_1") { + // 保存、提交及重提均要求至少一张发票,包括慰问类申请。 + if (!invoiceDetails.length) { this.$message.warning("请至少维护一条发票明细") return false } @@ -1495,7 +1624,9 @@ layout("/layouts/platform.html"){ const text = String(value == null ? "" : value) this.$set(row, "money", text) if (!/^\d*(\.\d{0,2})?$/.test(text)) { - this.$nextTick(() => { this.$set(row, "money", previous) }) + this.$nextTick(() => { + this.$set(row, "money", previous) + }) this.$message.warning("金额只能输入数字,最多两位小数") } }, @@ -1504,7 +1635,53 @@ layout("/layouts/platform.html"){ const text = String(value == null ? "" : value) return /^\d+(\.\d{1,2})?$/.test(text) && Number.isFinite(Number(text)) && Number(text) > 0 }, - // 校验每行支付信息;对私支付还需完整的付款人和银行信息,空白文字不能视为已填写。 + // 十进制金额转整数分,第三位小数按后端 HALF_UP 口径处理,避免浮点加法误判。 + moneyToCents(value) { + const text = String(value == null ? "" : value) + if (!/^\d+(\.\d*)?$/.test(text)) { + return null + } + const parts = text.split(".") + const decimal = (parts[1] || "") + "000" + const cents = Number(parts[0]) * 100 + Number(decimal.slice(0, 2)) + + (Number(decimal.charAt(2)) >= 5 ? 1 : 0) + return Number.isSafeInteger(cents) ? cents : null + }, + // 参数为全部支付明细;返回空字符串表示未超额,否则返回带合计金额的提示。 + // 所有类型直接汇总发票明细,不依赖表单汇总字段。 + getPaymentAmountLimitMessage(paymentDetails) { + const invoices = (this.formData.invoiceDetails || []).filter(item => item) + let invoiceCents = 0 + for (let i = 0; i < invoices.length; i++) { + const cents = this.moneyToCents(invoices[i].invoiceAmount) + if (cents === null || cents <= 0) { + return "第" + (i + 1) + "条发票明细请填写正确的发票金额" + } + invoiceCents += cents + } + let paymentCents = 0 + for (let i = 0; i < paymentDetails.length; i++) { + const value = paymentDetails[i] && paymentDetails[i].money + // 空值为输入中的状态,单行必填和格式仍由现有校验负责。 + if (value === null || value === undefined || value === "") { + continue + } + const cents = this.moneyToCents(value) + if (cents === null) { + return "第" + (i + 1) + "条支付信息请填写有效金额" + } + paymentCents += cents + } + if (!Number.isSafeInteger(invoiceCents) || !Number.isSafeInteger(paymentCents)) { + return "金额合计超出可处理范围,请检查填写金额" + } + if (paymentCents > invoiceCents) { + return "支付金额合计不能超过发票总额(支付合计:" + (paymentCents / 100).toFixed(2) + + "元,发票总额:" + (invoiceCents / 100).toFixed(2) + "元)" + } + return "" + }, + // 校验每行支付资料及全部支付金额上限,保存、提交和重新提交共用。 validatePaymentDetailsBeforeSubmit(paymentDetails) { if (!paymentDetails.length) { this.$message.warning("请至少维护一条支付信息") @@ -1539,6 +1716,11 @@ layout("/layouts/platform.html"){ } } } + const amountMessage = this.getPaymentAmountLimitMessage(paymentDetails) + if (amountMessage) { + this.$message.warning(amountMessage) + return false + } return true }, // @@ -1555,6 +1737,11 @@ layout("/layouts/platform.html"){ }, // 保存 onSave() { + if (this.formLoading || this.recognizingInvoice) return + if (!this.validateInvoiceDetailsBeforeSubmit()) { + this.$set(this, "activeTabName", "invoice") + return + } this.rebuildInvoiceSummary() this.normalizeFundBalance() const paymentDetails = this.preparePaymentDetailsForSubmit() @@ -1568,22 +1755,34 @@ layout("/layouts/platform.html"){ cancelButtonText: "取消", type: "warning" }).then(() => { + if (this.formLoading) return + this.$set(this, "formLoading", true) this.$axios.post("/platform/unionReimburse/apply/save", {data: JSON.stringify(this.buildSubmitFormData(paymentDetails))}).then(res => { if (res.code === 0) { this.$message.success(res.msg) commonUtil.pjaxPush("/platform/unionReimburse/mine/index") } + }).finally(() => { + this.$set(this, "formLoading", false) }) }) }, - // 下一步 + // 返回基本信息页,仅切换页签,保留已填写的支付信息和发票明细。 + onPrevStep() { + this.$set(this, "activeTabName", "basic") + }, + // 进入发票信息页前,沿用现有金额汇总和经费余额处理。 onNextStep() { this.rebuildInvoiceSummary() this.normalizeFundBalance() - this.activeTabName = "invoice" + this.$set(this, "activeTabName", "invoice") }, // 提交验证 validateBeforeSubmit() { + if (!this.validateInvoiceDetailsBeforeSubmit()) { + this.$set(this, "activeTabName", "invoice") + return Promise.resolve(false) + } return new Promise((resolve) => { this.rebuildInvoiceSummary() const paymentDetails = this.preparePaymentDetailsForSubmit() @@ -1599,11 +1798,6 @@ layout("/layouts/platform.html"){ resolve(false) return } - if (!this.validateInvoiceDetailsBeforeSubmit()) { - this.$set(this, "activeTabName", "invoice") - resolve(false) - return - } this.normalizeFundBalance() resolve(true) } else { @@ -1620,43 +1814,53 @@ layout("/layouts/platform.html"){ }) }, // 提交 - async onSubmit() { - const isValid = await this.validateBeforeSubmit() - if (!isValid) return - - this.$confirm("您确定要提交吗?审核通过后可前往我的报销打印!", "提示", { - confirmButtonText: "确定", - cancelButtonText: "取消", - type: "warning" - }).then(() => { - this.$axios.post("/platform/unionReimburse/apply/submit", { - data: JSON.stringify(this.buildSubmitFormData()) - }).then(res => { - if (res.code === 0) { - this.$message.success("提交成功") - commonUtil.pjaxPush("/platform/unionReimburse/mine/index") - } + onSubmit() { + if (this.formLoading || this.recognizingInvoice) return + this.validateBeforeSubmit().then((isValid) => { + if (!isValid) return + this.$confirm("您确定要提交吗?审核通过后可前往我的报销打印!", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(() => { + if (this.formLoading) return + this.$set(this, "formLoading", true) + this.$axios.post("/platform/unionReimburse/apply/submit", { + data: JSON.stringify(this.buildSubmitFormData()) + }).then(res => { + if (res.code === 0) { + this.$message.success("提交成功") + commonUtil.pjaxPush("/platform/unionReimburse/mine/index") + } + }).finally(() => { + this.$set(this, "formLoading", false) + }) }) }) }, // 再次提交 - async onFinishTask() { - const isValid = await this.validateBeforeSubmit() - if (!isValid) return - - this.$confirm("您确定要提交吗?审核通过后可前往我的报销打印!", "提示", { - confirmButtonText: "确定", - cancelButtonText: "取消", - type: "warning" - }).then(() => { - this.$axios.post("/platform/unionReimburse/apply/submitAgain", { - data: JSON.stringify(this.buildSubmitFormData()), - taskId: GetQueryString("taskId") - }).then(res => { - if (res.code === 0) { - this.$message.success("提交成功") - commonUtil.pjaxPush("/platform/unionReimburse/mine/index") - } + onFinishTask() { + if (this.formLoading || this.recognizingInvoice) return + this.validateBeforeSubmit().then((isValid) => { + if (!isValid) return + this.$confirm("您确定要提交吗?审核通过后可前往我的报销打印!", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(() => { + if (this.formLoading) return + this.$set(this, "formLoading", true) + this.$axios.post("/platform/unionReimburse/apply/submitAgain", { + data: JSON.stringify(this.buildSubmitFormData()), + taskId: GetQueryString("taskId") + }).then(res => { + if (res.code === 0) { + this.$message.success("提交成功") + commonUtil.pjaxPush("/platform/unionReimburse/mine/index") + } + }).finally(() => { + this.$set(this, "formLoading", false) + }) }) }) }, diff --git a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html index 1f396bd3..f34e924d 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html @@ -13,24 +13,24 @@ layout("/layouts/platform.html"){ - - - + + + - - - + + +
@@ -124,13 +128,12 @@ layout("/layouts/platform.html"){
-