feat(oa): 新增合同审批单Word导出功能

- 在ErpContractInfoController中添加exportApprovalWord接口实现Word文档导出
- 集成WordTemplateUtil和Configure实现合同审批单模板渲染
- 添加ErpContractApprovalTablePolicy绑定审批记录动态表格策略
- 实现buildApprovalWordExportData方法组装合同基础信息和审批记录数据
- 集成RemoteUserService获取审批人昵称确保导出信息可读性
- 添加工作流已办节点数据提取和按时间排序功能
- 实现按节点名称精确定位业务方向负责人和财务经理审批信息
- 配置POI相关依赖支持Word文档生成和响应输出
dev
zangch@mesnac.com 4 days ago
parent c53b0b3400
commit 202131d2fe

@ -115,6 +115,10 @@
<version>2.5.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-word</artifactId>
</dependency>
</dependencies>

@ -1,26 +1,30 @@
package org.dromara.oa.erp.controller;
import java.util.List;
import lombok.RequiredArgsConstructor;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.*;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.annotation.Validated;
import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.web.core.BaseController;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.deepoove.poi.config.Configure;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.oa.erp.service.IErpContractInfoService;
import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.dromara.common.word.util.WordTemplateUtil;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import org.dromara.oa.erp.service.IErpContractInfoService;
import org.dromara.oa.erp.word.ErpContractApprovalTablePolicy;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
*
@ -57,6 +61,31 @@ public class ErpContractInfoController extends BaseController {
ExcelUtil.exportExcel(list, "合同信息", ErpContractInfoVo.class, response);
}
/**
* Word
*
* @param contractId ID
* @param response HttpServletResponse
*/
@SaCheckPermission("oa/erp:contractInfo:export")
@Log(title = "合同审批单Word导出", businessType = BusinessType.EXPORT)
@GetMapping("/exportApprovalWord/{contractId}")
public void exportApprovalWord(@NotNull(message = "合同ID不能为空")
@PathVariable("contractId") Long contractId,
HttpServletResponse response) {
// 组装模板数据(合同基本信息 + 审批记录)
java.util.Map<String, Object> data = erpContractInfoService.buildApprovalWordExportData(contractId);
// 生成文件名(优先使用合同编号)
ErpContractInfoVo vo = erpContractInfoService.queryById(contractId);
String fileName = "合同审批单_" + (vo != null && vo.getContractCode() != null ? vo.getContractCode() : contractId);
// 绑定审批记录动态表格策略
Configure config = Configure.builder()
.bind("审批记录表", new ErpContractApprovalTablePolicy())
.build();
// 渲染并输出Word文档
WordTemplateUtil.renderToResponse("合同审批单模板.docx", fileName, data, config, response);
}
/**
*
*

@ -33,7 +33,7 @@ public interface ErpProjectChangeMapper extends BaseMapperPlus<ErpProjectChange,
@DataColumn(key = "deptName", value = "t.create_dept"),
@DataColumn(key = "userName", value = "t.create_by")
})
public Page<ErpProjectChangeVo> selectCustomErpProjectChangeVoList(@Param("page") Page<ErpProjectChangeVo> page, @Param(Constants.WRAPPER) MPJLambdaWrapper<ErpProjectChange> queryWrapper);
Page<ErpProjectChangeVo> selectCustomErpProjectChangeVoList(@Param("page") Page<ErpProjectChangeVo> page, @Param(Constants.WRAPPER) MPJLambdaWrapper<ErpProjectChange> queryWrapper);
/**
*
@ -45,7 +45,7 @@ public interface ErpProjectChangeMapper extends BaseMapperPlus<ErpProjectChange,
@DataColumn(key = "deptName", value = "t.create_dept"),
@DataColumn(key = "userName", value = "t.create_by")
})
public List<ErpProjectChangeVo> selectCustomErpProjectChangeVoList(@Param(Constants.WRAPPER) MPJLambdaWrapper<ErpProjectChange> queryWrapper);
List<ErpProjectChangeVo> selectCustomErpProjectChangeVoList(@Param(Constants.WRAPPER) MPJLambdaWrapper<ErpProjectChange> queryWrapper);
/**
* ID

@ -1,10 +1,9 @@
package org.dromara.oa.erp.service;
import org.dromara.oa.erp.domain.ErpContractInfo;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import java.util.Collection;
import java.util.List;
@ -73,4 +72,12 @@ public interface IErpContractInfoService {
* @return
*/
ErpContractInfoVo contractSubmitAndFlowStart(ErpContractInfoBo bo);
/**
* Word
*
* @param contractId ID
* @return WordMap
*/
java.util.Map<String, Object> buildApprovalWordExportData(Long contractId);
}

@ -1,44 +1,49 @@
package org.dromara.oa.erp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.convert.NumberChineseFormatter;
import cn.hutool.core.map.MapUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.deepoove.poi.data.RowRenderData;
import com.deepoove.poi.data.Rows;
import com.github.yulichang.toolkit.JoinWrappers;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.dubbo.config.annotation.DubboReference;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.dromara.common.core.enums.BusinessStatusEnum;
import org.dromara.common.core.enums.OAStatusEnum;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.DateUtils;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.toolkit.JoinWrappers;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.tenant.helper.TenantHelper;
import org.dromara.oa.base.domain.vo.BaseRelationMaterialVo;
import org.dromara.oa.erp.domain.ErpContractMaterial;
import org.dromara.oa.erp.domain.ErpContractPaymentMethod;
import org.dromara.oa.erp.domain.vo.ErpContractMaterialVo;
import org.dromara.oa.erp.domain.vo.ErpContractPaymentMethodVo;
import org.dromara.oa.erp.mapper.ErpContractMaterialMapper;
import org.dromara.oa.erp.mapper.ErpContractPaymentMethodMapper;
import org.dromara.oa.base.domain.bo.BaseRelationMaterialBo;
import org.dromara.oa.base.domain.vo.BaseRelationMaterialVo;
import org.dromara.oa.base.service.IBaseRelationMaterialService;
import org.dromara.oa.crm.domain.vo.CrmPaymentAccountVo;
import org.dromara.oa.crm.service.ICrmPaymentAccountService;
import org.dromara.oa.erp.domain.ErpContractInfo;
import org.dromara.oa.erp.domain.ErpContractMaterial;
import org.dromara.oa.erp.domain.ErpContractPaymentMethod;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import org.dromara.oa.erp.domain.vo.ErpContractMaterialVo;
import org.dromara.oa.erp.domain.vo.ErpContractPaymentMethodVo;
import org.dromara.oa.erp.mapper.ErpContractInfoMapper;
import org.dromara.oa.erp.mapper.ErpContractMaterialMapper;
import org.dromara.oa.erp.mapper.ErpContractPaymentMethodMapper;
import org.dromara.oa.erp.service.IErpContractInfoService;
import org.dromara.system.api.RemoteUserService;
import org.dromara.workflow.api.RemoteWorkflowService;
import org.dromara.workflow.api.domain.RemoteStartProcess;
import org.dromara.workflow.api.event.ProcessEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.dromara.oa.erp.domain.bo.ErpContractInfoBo;
import org.dromara.oa.erp.domain.vo.ErpContractInfoVo;
import org.dromara.oa.erp.domain.ErpContractInfo;
import org.dromara.oa.erp.mapper.ErpContractInfoMapper;
import org.dromara.oa.erp.service.IErpContractInfoService;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@ -55,6 +60,12 @@ import java.util.stream.Collectors;
@Slf4j
public class ErpContractInfoServiceImpl implements IErpContractInfoService {
private static final String WORD_DATE_FORMAT = "yyyy年MM月dd日";
private static final String BUSINESS_DIRECTION_LEADER_NODE_NAME = "业务方向负责人";
private static final String FINANCE_MANAGER_NODE_NAME = "财务经理";
private final ErpContractInfoMapper baseMapper;
private final ErpContractMaterialMapper contractMaterialMapper;
@ -65,6 +76,8 @@ public class ErpContractInfoServiceImpl implements IErpContractInfoService {
private final ICrmPaymentAccountService crmPaymentAccountService;
private final RemoteUserService remoteUserService;
@DubboReference(timeout = 30000)
private RemoteWorkflowService remoteWorkflowService;
@ -395,6 +408,215 @@ public class ErpContractInfoServiceImpl implements IErpContractInfoService {
return baseMapper.deleteByIds(ids) > 0;
}
/**
* Word
*
* @param contractId ID
* @return WordMap
*/
@Override
public Map<String, Object> buildApprovalWordExportData(Long contractId) {
ErpContractInfoVo contractInfo = queryById(contractId);
if (contractInfo == null) {
throw new ServiceException("合同不存在ID" + contractId);
}
if (!OAStatusEnum.COMPLETED.getStatus().equals(contractInfo.getContractStatus())) {
throw new ServiceException("仅合同状态为“可用”时允许导出审批单");
}
Map<String, Object> data = new HashMap<>(64);
// 审批单导出优先保障“合同基础信息”完整,避免审批记录正确但主表关键信息缺失导致单据不可用
data.put("合同编号", strVal(contractInfo.getContractCode()));
data.put("合同名称", strVal(contractInfo.getContractName()));
data.put("客户合同编号", strVal(contractInfo.getCustomerContractCode()));
data.put("合同签订日期", formatWordDate(contractInfo.getContractDate()));
data.put("合同总价", contractInfo.getTotalPrice() == null ? "" : contractInfo.getTotalPrice().toPlainString());
data.put("甲方公司", strVal(contractInfo.getOneCustomerName()));
data.put("乙方公司", strVal(contractInfo.getTwoCustomerName()));
data.put("合同负责人", strVal(contractInfo.getContractManagerName()));
data.put("所属部门", strVal(contractInfo.getDeptName()));
data.put("付款方式", strVal(contractInfo.getPaymentMethod()));
data.put("付款账户信息", strVal(contractInfo.getPaymentAccountInfo()));
data.put("合同类型", strVal(contractInfo.getContractType()));
data.put("合同大类", strVal(contractInfo.getContractCategory()));
data.put("业务方向", strVal(contractInfo.getBusinessDirection()));
data.put("签订地点", strVal(contractInfo.getSigningPlace()));
data.put("备注", strVal(contractInfo.getRemark()));
data.put("内部合同号", strVal(contractInfo.getInternalContractCode()));
data.put("外部合同号", strVal(contractInfo.getExternalContractCode()));
data.put("订单号", strVal(contractInfo.getOrderContractCode()));
data.put("项目号", strVal(contractInfo.getProjectContractCode()));
// Why当前业务已明确“交付启动期限”与“交货期”同义双键同时输出可兼容不同模板占位符命名
String deliveryPeriod = strVal(contractInfo.getDeliveryStart());
data.put("交货期", deliveryPeriod);
data.put("交付启动期限", deliveryPeriod);
data.put("质保期", strVal(contractInfo.getWarrantyPeriod()));
data.put("质保期描述", strVal(contractInfo.getWarrantyPeriodDescription()));
// 按需求先置空的字段
data.put("行业/大区", "");
data.put("是否为销售合同标准文本", "");
data.put("是否经法务部备案的销售合同文本", "");
data.put("销售产品是否出口", "");
data.put("盖章要求", "");
data.put("合同争议解决方式", "");
// 审批单只展示“已办节点”,避免把待办节点(无审批时间/意见)混入导出文档
Map<String, Object> flowData = remoteWorkflowService.flowHisTaskList(String.valueOf(contractId));
List<Map<String, Object>> handledTasks = extractHandledTasks(flowData == null ? null : flowData.get("list"));
// Why固定审批区块要按流程节点名精确落位避免后续流程扩展后把别的节点意见写错位置
fillApprovalNodeData(data, handledTasks, BUSINESS_DIRECTION_LEADER_NODE_NAME, "业务方向负责人");
fillApprovalNodeData(data, handledTasks, FINANCE_MANAGER_NODE_NAME, "财务经理");
List<RowRenderData> approvalRows = new ArrayList<>();
int seq = 1;
for (Map<String, Object> task : handledTasks) {
String approveName = resolveApproveName(task);
String message = strVal(task.get("message"));
Date updateTime = parseDate(task.get("updateTime"));
String approvalTime = formatWordDate(updateTime);
approvalRows.add(Rows.of(
String.valueOf(seq++),
approveName,
message,
approvalTime
).create());
}
data.put("审批记录表", approvalRows);
return data;
}
/**
*
*/
private void fillApprovalNodeData(Map<String, Object> data, List<Map<String, Object>> handledTasks,
String nodeName, String keyPrefix) {
Map<String, Object> task = findLatestTaskByNodeName(handledTasks, nodeName);
if (task == null) {
data.put(keyPrefix + "审批人", "");
data.put(keyPrefix + "审批意见", "");
data.put(keyPrefix + "审批时间", "");
return;
}
data.put(keyPrefix + "审批人", resolveApproveName(task));
data.put(keyPrefix + "审批意见", strVal(task.get("message")));
data.put(keyPrefix + "审批时间", formatWordDate(parseDate(task.get("updateTime"))));
}
/**
*
*/
private List<Map<String, Object>> extractHandledTasks(Object listObj) {
if (!(listObj instanceof List<?> taskList) || taskList.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> handledTasks = new ArrayList<>();
for (Object taskObj : taskList) {
Map<String, Object> taskMap = toMap(taskObj);
if (taskMap.isEmpty()) {
continue;
}
if (taskMap.get("updateTime") != null) {
handledTasks.add(taskMap);
}
}
handledTasks.sort(Comparator.comparing(task -> {
Date updateTime = parseDate(task.get("updateTime"));
return updateTime == null ? new Date(0L) : updateTime;
}));
return handledTasks;
}
/**
*
*/
private Map<String, Object> findLatestTaskByNodeName(List<Map<String, Object>> handledTasks, String nodeName) {
if (StringUtils.isBlank(nodeName) || handledTasks == null || handledTasks.isEmpty()) {
return null;
}
for (int i = handledTasks.size() - 1; i >= 0; i--) {
Map<String, Object> task = handledTasks.get(i);
// Why不同工作流版本可能返回 nodeName / targetNodeName双字段兼容可以减少后续接口改动带来的模板失效
String taskNodeName = strVal(task.get("nodeName"));
String targetNodeName = strVal(task.get("targetNodeName"));
if (nodeName.equals(taskNodeName) || nodeName.equals(targetNodeName)) {
return task;
}
}
return null;
}
/**
*
*/
private String resolveApproveName(Map<String, Object> task) {
String approveName = strVal(task.get("approveName"));
if (StringUtils.isNotBlank(approveName)) {
return approveName;
}
String approver = strVal(task.get("approver"));
if (StringUtils.isBlank(approver)) {
return "";
}
try {
// WhyDubbo 导出链路返回的是纯 Map 后,工作流里的 @Translation 不再生效,这里补一层用户昵称查询保证导出给业务人员的是可读姓名
String nickname = remoteUserService.selectNicknameByIds(approver);
return StringUtils.isNotBlank(nickname) ? nickname : approver;
} catch (Exception e) {
log.warn("审批人昵称解析失败, approver={}", approver, e);
return approver;
}
}
private Map<String, Object> toMap(Object obj) {
if (obj == null) {
return Collections.emptyMap();
}
if (obj instanceof Map<?, ?> rawMap) {
Map<String, Object> result = new HashMap<>(rawMap.size());
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
result.put(String.valueOf(entry.getKey()), entry.getValue());
}
return result;
}
return BeanUtil.beanToMap(obj);
}
private Date parseDate(Object rawDate) {
if (rawDate == null) {
return null;
}
if (rawDate instanceof Date date) {
return date;
}
if (rawDate instanceof Number number) {
long millis = number.longValue();
if (String.valueOf(millis).length() == 10) {
millis = millis * 1000;
}
return new Date(millis);
}
return DateUtils.parseDate(rawDate);
}
private String formatDateTime(Date date, String format) {
if (date == null) {
return "";
}
return DateUtils.parseDateToStr(format, date);
}
private String formatWordDate(Date date) {
return formatDateTime(date, WORD_DATE_FORMAT);
}
private String strVal(Object value) {
if (value == null) {
return "";
}
return String.valueOf(value);
}
/**
*
*

Loading…
Cancel
Save