diff --git a/ruoyi-modules/ruoyi-oa/pom.xml b/ruoyi-modules/ruoyi-oa/pom.xml index 3ff2a153..22566120 100644 --- a/ruoyi-modules/ruoyi-oa/pom.xml +++ b/ruoyi-modules/ruoyi-oa/pom.xml @@ -115,6 +115,10 @@ 2.5.0 compile + + org.dromara + ruoyi-common-word + diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpContractInfoController.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpContractInfoController.java index d168c722..e14b98fc 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpContractInfoController.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpContractInfoController.java @@ -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 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); + } + /** * 获取合同信息详细信息 * diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/mapper/ErpProjectChangeMapper.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/mapper/ErpProjectChangeMapper.java index 398380a2..a07d1ae1 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/mapper/ErpProjectChangeMapper.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/mapper/ErpProjectChangeMapper.java @@ -33,7 +33,7 @@ public interface ErpProjectChangeMapper extends BaseMapperPlus selectCustomErpProjectChangeVoList(@Param("page") Page page, @Param(Constants.WRAPPER) MPJLambdaWrapper queryWrapper); + Page selectCustomErpProjectChangeVoList(@Param("page") Page page, @Param(Constants.WRAPPER) MPJLambdaWrapper queryWrapper); /** * 查询项目变更申请列表 @@ -45,7 +45,7 @@ public interface ErpProjectChangeMapper extends BaseMapperPlus selectCustomErpProjectChangeVoList(@Param(Constants.WRAPPER) MPJLambdaWrapper queryWrapper); + List selectCustomErpProjectChangeVoList(@Param(Constants.WRAPPER) MPJLambdaWrapper queryWrapper); /** * 根据ID查询项目变更申请详情 diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpContractInfoService.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpContractInfoService.java index d30ab633..4c6d7008 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpContractInfoService.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpContractInfoService.java @@ -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 Word模板数据Map + */ + java.util.Map buildApprovalWordExportData(Long contractId); } diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpContractInfoServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpContractInfoServiceImpl.java index 723e0518..41e7ba96 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpContractInfoServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpContractInfoServiceImpl.java @@ -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 Word模板数据Map + */ + @Override + public Map 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 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 flowData = remoteWorkflowService.flowHisTaskList(String.valueOf(contractId)); + List> 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 approvalRows = new ArrayList<>(); + int seq = 1; + for (Map 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 data, List> handledTasks, + String nodeName, String keyPrefix) { + Map 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> extractHandledTasks(Object listObj) { + if (!(listObj instanceof List taskList) || taskList.isEmpty()) { + return Collections.emptyList(); + } + List> handledTasks = new ArrayList<>(); + for (Object taskObj : taskList) { + Map 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 findLatestTaskByNodeName(List> handledTasks, String nodeName) { + if (StringUtils.isBlank(nodeName) || handledTasks == null || handledTasks.isEmpty()) { + return null; + } + for (int i = handledTasks.size() - 1; i >= 0; i--) { + Map 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 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 { + // Why:Dubbo 导出链路返回的是纯 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 toMap(Object obj) { + if (obj == null) { + return Collections.emptyMap(); + } + if (obj instanceof Map rawMap) { + Map 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); + } + /** * 提交合同信息并提交流程 *