工会报销导出修改

This commit is contained in:
2026-09-29 17:41:55 +08:00
parent 387e223e3e
commit 043251a55d
5 changed files with 118 additions and 34 deletions
@@ -3,7 +3,7 @@ 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,不接受其他人的单据或待提交单据
@@ -14,13 +14,13 @@ public interface UnionReimburseExportService {
/**
* 汇总页所有状态的报销单均可打印,必须具有汇总权限并符合所属工会数据范围。
* @param id 汇总页选中的报销单 ID,可为待提交或其他申请状态
* @return 与我的报销导出使用相同模板生成的完整 PDF 字节数组,无权限或生成失败时抛出业务异常
* @return 按慰问类型选择模板生成的完整 PDF 字节数组,活动沿用原模板;无权限或生成失败时抛出业务异常
*/
byte[] exportCollectPdf(String id);
/**
* @param form 已加载发票明细的报销单,支付明细为空时兼容旧主表字段
* @return 两份模板通用的平铺字段及 payments、invoices 循环行
* @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行
*/
Map<String, Object> buildExportData(UnionReimburse form);
}
@@ -16,6 +16,7 @@ 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.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
@@ -49,6 +50,13 @@ import java.util.*;
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";
// 使用慰问类型业务编码匹配模板,避免类型名称调整后选择错误;编码与现有申请页一致。
private static final Map<String, String> COLLECT_CONDOLENCE_TEMPLATES = Map.of(
"A", "union_reimburse_condolence_marriage_pdf",
"D", "union_reimburse_condolence_birth_pdf",
"E", "union_reimburse_condolence_hospital_pdf",
"G", "union_reimburse_condolence_member_death_pdf",
"H", "union_reimburse_condolence_relative_death_pdf");
@Inject
private UnionReimburseService unionReimburseService;
@Inject
@@ -62,7 +70,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
return renderDocument(loadExportForm(id, false, false), true, false);
}
/** 汇总打印支持所有状态,并严格按汇总权限和所属工会范围读取,复用我的报销 PDF 模板。 */
/** 汇总打印支持所有状态,按汇总权限和所属工会范围读取,并按慰问类型选择模板。 */
@Override
public byte[] exportCollectPdf(String id) {
return renderDocument(loadExportForm(id, true, true), true, true);
@@ -103,10 +111,32 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
return form;
}
/** 两份模板共用字段和循环行配置;collectPrint 仅为汇总打印补充编号二维码。 */
/**
* 汇总页五类慰问使用独立模板,其余类型和我的报销沿用原模板。
* @param form 已通过数据权限校验的报销单,使用慰问类型 ID 读取业务编码
* @param collectPrint 是否为汇总页打印,只有该入口启用分类模板
* @return 系统导出模板管理中的模板编码;类型关联缺失时提示核对,不猜测类型名称
*/
private String resolveTemplateCode(UnionReimburse form, boolean collectPrint) {
if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) {
return ACTIVITY_TEMPLATE;
}
if (!collectPrint) {
return CONDOLENCE_TEMPLATE;
}
// 兼容旧申请将类型 ID 保存在 condolenceType 的情况,不回写历史数据。
String typeId = StrUtil.blankToDefault(form.getCondolenceTypeId(), form.getCondolenceType());
CondolenceType type = StrUtil.isBlank(typeId) ? null : dao.fetch(CondolenceType.class, typeId);
if (type == null || StrUtil.isBlank(type.getCode())) {
throw new BaseException("慰问类型不存在或编码为空,请核对后打印");
}
// 五类之外的既有慰问继续使用通用模板;已匹配的专用模板缺失时由模板加载器明确报错。
return COLLECT_CONDOLENCE_TEMPLATES.getOrDefault(type.getCode(), CONDOLENCE_TEMPLATE);
}
/** 模板共用字段和循环行配置;汇总打印按慰问类型选模板并补充编号二维码。 */
private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) {
String templateCode = "UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())
? CONDOLENCE_TEMPLATE : ACTIVITY_TEMPLATE;
String templateCode = resolveTemplateCode(form, collectPrint);
Path docx = null;
Path pdf = null;
try {
@@ -114,7 +144,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
if (asPdf) pdf = Files.createTempFile("union_reimburse_", ".pdf");
// 活动和慰问模板的支付标记均位于表头,支付明细统一循环下一行。
// 活动模板的发票标记也位于表头,循环下一行;慰问保留原有的当前行配置。
boolean invoiceOnSameRow = CONDOLENCE_TEMPLATE.equals(templateCode);
boolean invoiceOnSameRow = "UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject());
Configure config = Configure.builder()
.bind("payments", new LoopRowTableRenderPolicy(false))
.bind("invoices", new LoopRowTableRenderPolicy(invoiceOnSameRow)).build();
@@ -125,7 +125,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
/**
* 保存、提交及重新提交共用支付信息校验,并在同一事务内保存申请和发票明细。
* @param unionReimburse 申请表单,paymentDetails 为支付方式、金额及对私收款资料组成的明细列表
* @param stateId 1 表示保存草稿,2 表示提交;均要求支付信息完整、发票必填且通过服务端验真
* @param stateId 1 表示保存草稿,2 表示提交;均要求支付信息完整,发票选填,上传后须通过服务端验真
* @return Result 成功或失败结果,msg 包含失败明细的行号及原因
*/
@Override
@@ -134,7 +134,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse == null) {
return Result.error("报销数据不能为空");
}
// 保存、提交及重提统一要求发票完整,不能通过慰问类型或草稿状态跳过。
// 发票选填;保存、提交及重提均对已上传的发票检查完整性。
Result invoiceResult = validateInvoiceDetails(unionReimburse);
if (invoiceResult != null) {
return invoiceResult;
@@ -669,7 +669,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 所有报销类型由必填发票明细自动汇总金额和张数。
* 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。
*/
private void fillInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D;
@@ -684,6 +684,14 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
invoiceFileCount++;
}
}
// 无发票申请从服务端已校验的支付明细计算金额,不能信任前端传入的报销总额。
if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
BigDecimal paymentTotal = BigDecimal.ZERO;
for (cn.hutool.json.JSONObject detail : unionReimburse.getPaymentDetails()) {
paymentTotal = paymentTotal.add(new BigDecimal(detail.get("money").toString()));
}
totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue();
}
unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue());
unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount));
}
@@ -754,6 +762,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
*/
private Result validatePaymentAmountLimit(UnionReimburse form) {
List<UnionReimburseInvoiceDetail> invoices = form.getInvoiceDetails();
// 无发票时不设票面金额上限,支付明细仍须通过必填及正金额校验。
if (Lang.isEmpty(invoices)) {
return null;
}
BigDecimal invoiceTotal = BigDecimal.ZERO.setScale(2);
if (invoices != null) {
for (int i = 0; i < invoices.size(); i++) {
@@ -821,11 +833,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 保存及提交前检查发票完整性,每条明细必须且只能包含一张可定位的发票文件。
* 发票列表允许为空;已有明细必须且只能包含一张可定位的发票文件。
*/
private Result validateInvoiceDetails(UnionReimburse unionReimburse) {
if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
return Result.error("请至少维护一条发票明细");
return null;
}
for (int i = 0; i < unionReimburse.getInvoiceDetails().size(); i++) {
UnionReimburseInvoiceDetail detail = unionReimburse.getInvoiceDetails().get(i);
@@ -1596,7 +1608,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 区分活动类与慰问类的基本信息校验;所有类型的发票均为必填。
* 区分活动类与慰问类的基本信息校验;所有类型的发票均为选填。
*/
private boolean needInvoiceDetails(UnionReimburse unionReimburse) {
return unionReimburse != null && !"UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject());
@@ -438,7 +438,7 @@ layout("/layouts/platform.html"){
<el-col :span="12">
<el-form-item label="报销金额" prop="money">
<div class="invoice-field-title">报销金额</div>
<el-input v-model="formData.money" placeholder="自动汇总发票金额" readonly></el-input>
<el-input v-model="formData.money" placeholder="有发票按发票汇总,无发票按支付信息汇总" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -587,11 +587,11 @@ layout("/layouts/platform.html"){
<div>
<div class="invoice-toolbar-title">发票明细列表</div>
<div class="invoice-summary-tip">
所有报销类型均须上传发票并通过验真;每条明细对应一张发票图片或单页 PDF,金额和张数自动汇总。
发票选填;无发票时按支付信息汇总报销金额。上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
</div>
</div>
<div>
<el-tag type="info">发票必须验真</el-tag>
<el-tag type="info">发票选填,上传须验真</el-tag>
<el-button @click="openInvoiceDialog('add')" plain type="primary"
style="margin-left: 12px">新增发票
</el-button>
@@ -943,6 +943,15 @@ layout("/layouts/platform.html"){
}
},
watch: {
// 无发票时支付明细决定报销金额,支付行增删或金额变化后同步更新汇总。
"formData.paymentDetails": {
handler() {
if (!(this.formData.invoiceDetails || []).length) {
this.rebuildInvoiceSummary()
}
},
deep: true
},
"detailForm.invoiceFiles": {
handler(newVal) {
const file = newVal && newVal.length ? newVal[0] : null
@@ -1221,7 +1230,14 @@ layout("/layouts/platform.html"){
}
}
this.$set(row, "historyData", accounts)
this.$set(row, "bankAccountId", selectedId)
// 新建户名的选项必须先完成渲染,再更新选中值,避免 el-select 将临时账户ID直接显示为户名。
this.$nextTick(() => {
// 重复输入、账户请求回填或切换付款人会替换选项列表;旧回调不得覆盖最新选择。
if (row.historyData !== accounts) {
return
}
this.$set(row, "bankAccountId", selectedId)
})
},
// 选中框只显示户名,下拉内容展示开户行和尾号,同名多卡仍按账户ID选择。
bankAccountLabel(account) {
@@ -1545,17 +1561,24 @@ layout("/layouts/platform.html"){
fileCount = fileCount + 1
}
}
// 所有类型都按必填发票汇总,不再使用无票慰问金额兜底。
// 无发票时按支付明细汇总,使用整数分避免小数累加误差。
if (!invoiceDetails.length) {
let paymentCents = 0
const paymentDetails = this.formData.paymentDetails || []
paymentDetails.forEach(item => {
const cents = this.moneyToCents(item && item.money)
if (cents !== null) {
paymentCents += cents
}
})
totalMoney = paymentCents / 100
}
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.$message.warning("请至少维护一条发票明细")
return false
}
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
const currentDetail = invoiceDetails[i]
if (!currentDetail.invoiceFiles || currentDetail.invoiceFiles.length === 0) {
@@ -1675,7 +1698,8 @@ layout("/layouts/platform.html"){
if (!Number.isSafeInteger(invoiceCents) || !Number.isSafeInteger(paymentCents)) {
return "金额合计超出可处理范围,请检查填写金额"
}
if (paymentCents > invoiceCents) {
// 无发票时只校验支付金额本身;有发票才限制支付合计不得超过票面总额。
if (invoices.length && paymentCents > invoiceCents) {
return "支付金额合计不能超过发票总额(支付合计:" + (paymentCents / 100).toFixed(2)
+ "元,发票总额:" + (invoiceCents / 100).toFixed(2) + "元)"
}
@@ -761,10 +761,11 @@ layout("/layouts/platform_h5.html"){
</van-action-sheet>
</van-cell-group>
<van-cell-group class="form-section" v-if="formData.reimburseProject">
<div class="form-section__title">电子发票</div>
<div class="form-section__title">电子发票(选填)</div>
<div class="invoice-tip">无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。</div>
<van-field label="报销金额"
v-model="formData.money"
placeholder="自动汇总发票金额"
placeholder="有发票按发票汇总,无发票按支付信息汇总"
readonly></van-field>
<van-field label="发票张数"
v-model="formData.invoiceNumber"
@@ -1167,6 +1168,15 @@ layout("/layouts/platform_h5.html"){
// 监听报销类别发生变化,报销项目联动改变
// 修复 watch 中的监听器
watch: {
// 无发票时支付明细决定报销金额,支付行增删或金额变化后同步更新汇总。
"formData.paymentDetails": {
handler() {
if (!(this.formData.invoiceDetails || []).length) {
this.rebuildInvoiceSummary()
}
},
deep: true
},
"detailForm.invoiceFiles": {
handler(newVal) {
const file = newVal && newVal.length ? newVal[0] : null
@@ -1854,17 +1864,24 @@ layout("/layouts/platform_h5.html"){
fileCount = fileCount + 1
}
}
// 所有类型都按必填发票汇总,不再使用无票慰问金额兜底。
// 无发票时按支付明细汇总,使用整数分避免小数累加误差。
if (!invoiceDetails.length) {
let paymentCents = 0
const paymentDetails = this.formData.paymentDetails || []
paymentDetails.forEach(item => {
const cents = this.moneyToCents(item && item.money)
if (cents !== null) {
paymentCents += cents
}
})
totalMoney = paymentCents / 100
}
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
this.$set(this.formData, "invoiceNumber", String(fileCount))
},
validateInvoiceDetailsBeforeSubmit() {
const invoiceDetails = this.formData.invoiceDetails || []
// 与 PC 一致:草稿、提交和重新提交均要求所有类型提供发票。
if (!invoiceDetails.length) {
this.$toast.fail("请至少维护一条发票明细")
return false
}
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
const currentDetail = invoiceDetails[i]
if (!currentDetail.invoiceFiles || currentDetail.invoiceFiles.length === 0) {
@@ -2167,7 +2184,8 @@ layout("/layouts/platform_h5.html"){
if (!Number.isSafeInteger(invoiceCents) || !Number.isSafeInteger(paymentCents)) {
return "金额合计超出可处理范围,请检查填写金额"
}
if (paymentCents > invoiceCents) {
// 无发票时只校验支付金额本身;有发票才限制支付合计不得超过票面总额。
if (invoices.length && paymentCents > invoiceCents) {
return "支付金额合计不能超过发票总额(支付合计:" + (paymentCents / 100).toFixed(2)
+ "元,发票总额:" + (invoiceCents / 100).toFixed(2) + "元)"
}