diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/controller/CrmBusinessTripApplyController.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/controller/CrmBusinessTripApplyController.java index a17f17c1..7826fca1 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/controller/CrmBusinessTripApplyController.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/controller/CrmBusinessTripApplyController.java @@ -1,6 +1,7 @@ package org.dromara.oa.crm.controller; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import jakarta.servlet.http.HttpServletResponse; @@ -13,10 +14,14 @@ import org.dromara.common.log.annotation.Log; import org.dromara.common.web.core.BaseController; import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; 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.common.word.util.WordTemplateUtil; +import com.deepoove.poi.config.Configure; +import org.dromara.oa.crm.word.CrmBusinessTripItineraryLoopRowPolicy; import org.dromara.oa.crm.domain.vo.CrmBusinessTripApplyVo; import org.dromara.oa.crm.domain.bo.CrmBusinessTripApplyBo; import org.dromara.oa.crm.service.ICrmBusinessTripApplyService; @@ -57,6 +62,26 @@ public class CrmBusinessTripApplyController extends BaseController { ExcelUtil.exportExcel(list, "出差申请", CrmBusinessTripApplyVo.class, response); } + /** + * 导出出差申请单 PDF(基于 Word 模板渲染后转换) + * + * @param tripId 出差申请ID + */ + @SaCheckPermission("oa/crm:businessTripApply:export") + @Log(title = "出差申请单PDF导出", businessType = BusinessType.EXPORT) + @GetMapping("/exportTripApplyPdf/{tripId}") + public void exportTripApplyPdf(@NotNull(message = "出差申请ID不能为空") @PathVariable("tripId") Long tripId, + HttpServletResponse response) { + Map data = crmBusinessTripApplyService.buildTripApplyPdfExportData(tripId); + CrmBusinessTripApplyVo vo = crmBusinessTripApplyService.queryById(tripId); + String fileName = "出差申请单_" + (vo != null && StringUtils.isNotBlank(vo.getApplyCode()) + ? vo.getApplyCode() : tripId); + Configure config = Configure.builder() + .bind("行程明细", new CrmBusinessTripItineraryLoopRowPolicy()) + .build(); + WordTemplateUtil.renderToPdfResponse("出差申请单模板.docx", fileName, data, config, response); + } + /** * 获取出差申请详细信息 * diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/ICrmBusinessTripApplyService.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/ICrmBusinessTripApplyService.java index 036447f3..3d13cc26 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/ICrmBusinessTripApplyService.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/ICrmBusinessTripApplyService.java @@ -8,6 +8,7 @@ import org.dromara.common.mybatis.core.page.PageQuery; import java.util.Collection; import java.util.List; +import java.util.Map; /** * 出差申请Service接口 @@ -74,4 +75,12 @@ public interface ICrmBusinessTripApplyService { * @return */ CrmBusinessTripApplyVo submitAndFlowStart(CrmBusinessTripApplyBo bo); + + /** + * 组装出差申请单 PDF 导出数据 + * + * @param tripId 出差申请ID + * @return Word 模板数据 + */ + Map buildTripApplyPdfExportData(Long tripId); } diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/impl/CrmBusinessTripApplyServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/impl/CrmBusinessTripApplyServiceImpl.java index 97aa54e9..1bd3e809 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/impl/CrmBusinessTripApplyServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/crm/service/impl/CrmBusinessTripApplyServiceImpl.java @@ -1,7 +1,9 @@ package org.dromara.oa.crm.service.impl; +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.satoken.utils.LoginHelper; import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.mybatis.core.page.PageQuery; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -23,9 +25,17 @@ import org.dromara.oa.crm.domain.CrmCustomerInfo; import org.dromara.oa.erp.mapper.ErpProjectInfoMapper; import org.dromara.oa.crm.mapper.CrmBusinessTripDetailsMapper; +import cn.hutool.core.bean.BeanUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Collection; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.dubbo.config.annotation.DubboReference; @@ -33,6 +43,7 @@ import org.apache.seata.spring.annotation.GlobalTransactional; import org.dromara.common.core.enums.BusinessStatusEnum; import org.dromara.common.core.exception.ServiceException; import org.dromara.system.api.RemoteCodeRuleService; +import org.dromara.system.api.RemoteUserService; import org.dromara.workflow.api.RemoteWorkflowService; import org.dromara.workflow.api.domain.RemoteStartProcess; import org.dromara.workflow.api.domain.RemoteFlowInstanceBizExt; @@ -53,6 +64,19 @@ import cn.hutool.core.convert.Convert; @Slf4j public class CrmBusinessTripApplyServiceImpl implements ICrmBusinessTripApplyService { + private static final String DATE_FORMAT = "yyyy-MM-dd"; + + private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + private static final String PRINT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm"; + + private static final Map TRIP_TYPE_LABELS = Map.of( + "1", "安装调试", + "2", "市场交流", + "3", "展会/会议", + "4", "其他" + ); + private final CrmBusinessTripApplyMapper baseMapper; private final CrmBusinessTripDetailsMapper detailsMapper; private final ErpProjectInfoMapper erpProjectInfoMapper; @@ -63,6 +87,9 @@ public class CrmBusinessTripApplyServiceImpl implements ICrmBusinessTripApplySer @DubboReference() private RemoteCodeRuleService remoteCodeRuleService; + @DubboReference + private RemoteUserService remoteUserService; + /** * 查询出差申请 * @@ -381,4 +408,229 @@ public class CrmBusinessTripApplyServiceImpl implements ICrmBusinessTripApplySer baseMapper.updateById(tripApply); }); } + + @Override + public Map buildTripApplyPdfExportData(Long tripId) { + CrmBusinessTripApplyVo apply = queryById(tripId); + if (apply == null) { + throw new ServiceException("出差申请不存在,ID:" + tripId); + } + if (!"3".equals(apply.getTripStatus())) { + throw new ServiceException("仅申请状态为“已审批”时允许导出出差申请单"); + } + + CrmBusinessTripApply entity = baseMapper.selectById(tripId); + Map data = new HashMap<>(24); + data.put("审批编号", strVal(apply.getApplyCode())); + data.put("创建人", strVal(apply.getApplicantName())); + data.put("创建人部门", strVal(apply.getDeptName())); + data.put("创建时间", formatDate(entity == null ? null : entity.getCreateTime())); + data.put("审批状态", "已通过"); + data.put("行程明细", buildItineraryLoopData(apply)); + data.put("审批流程", buildApprovalFlowText(tripId)); + data.put("打印时间", DateUtils.parseDateToStr(PRINT_DATETIME_FORMAT, new Date())); + data.put("打印人", resolvePrintUserName()); + return data; + } + + private List> buildItineraryLoopData(CrmBusinessTripApplyVo apply) { + String tripTypeLabel = resolveTripTypeLabel(apply.getTripType()); + List details = apply.getCrmBusinessTripDetailsList(); + if (details != null && !details.isEmpty()) { + List> rows = new ArrayList<>(); + for (CrmBusinessTripDetailsVo detail : details) { + rows.add(toItineraryRow(apply, detail, tripTypeLabel)); + } + return rows; + } + return List.of(toItineraryRowFromApply(apply, tripTypeLabel)); + } + + private Map toItineraryRow(CrmBusinessTripApplyVo apply, CrmBusinessTripDetailsVo detail, + String tripTypeLabel) { + Map row = new HashMap<>(12); + Long order = detail.getItineraryNumber(); + row.put("itineraryLabel", order == null ? "行程明细" : "行程明细" + order); + row.put("tripType", tripTypeLabel); + row.put("tripLocation", strVal(detail.getTripLocation())); + row.put("projectName", strVal(detail.getProjectName())); + row.put("projectCode", strVal(detail.getProjectCode())); + row.put("tripReason", firstNonBlank(detail.getTripReason(), apply.getTripReason())); + row.put("startTime", formatDate(detail.getStartTime())); + row.put("endTime", formatDate(detail.getEndTime())); + row.put("durationText", formatDuration(detail.getDurationDays(), apply.getDurationDays())); + return row; + } + + private Map toItineraryRowFromApply(CrmBusinessTripApplyVo apply, String tripTypeLabel) { + Map row = new HashMap<>(12); + row.put("itineraryLabel", "行程明细1"); + row.put("tripType", tripTypeLabel); + row.put("tripLocation", strVal(apply.getTripLocation())); + row.put("projectName", strVal(apply.getProjectName())); + row.put("projectCode", strVal(apply.getProjectCode())); + row.put("tripReason", strVal(apply.getTripReason())); + row.put("startTime", formatDate(apply.getStartTime())); + row.put("endTime", formatDate(apply.getEndTime())); + row.put("durationText", formatDuration(apply.getDurationDays(), null)); + return row; + } + + private String buildApprovalFlowText(Long tripId) { + try { + Map flowData = remoteWorkflowService.flowHisTaskList(String.valueOf(tripId)); + List> handledTasks = extractHandledTasks(flowData == null ? null : flowData.get("list")); + if (handledTasks.isEmpty()) { + return ""; + } + return handledTasks.stream() + .map(this::formatApprovalFlowLine) + .filter(StringUtils::isNotBlank) + .collect(Collectors.joining("\n")); + } catch (Exception e) { + log.warn("读取出差申请审批记录失败, tripId={}", tripId, e); + return ""; + } + } + + private String formatApprovalFlowLine(Map task) { + String message = strVal(task.get("message")); + if (StringUtils.isBlank(message)) { + message = "同意"; + } + String approveName = resolveApproveName(task); + String statusLabel = resolveTaskStatusLabel(task); + String time = formatDateTime(parseDate(task.get("updateTime"))); + return message + " " + approveName + " " + statusLabel + " " + time; + } + + private String resolveTaskStatusLabel(Map task) { + String flowStatusName = strVal(task.get("flowStatusName")); + if (StringUtils.isNotBlank(flowStatusName)) { + return flowStatusName; + } + String nodeName = strVal(task.get("nodeName")); + if (nodeName.contains("抄送")) { + return "已抄送"; + } + return "已同意"; + } + + private String resolveTripTypeLabel(String tripType) { + if (StringUtils.isBlank(tripType)) { + return ""; + } + return TRIP_TYPE_LABELS.getOrDefault(tripType, tripType); + } + + private String formatDuration(Long detailDays, Long fallbackDays) { + Long days = detailDays != null ? detailDays : fallbackDays; + if (days == null) { + return ""; + } + return days + ".0天"; + } + + private String resolvePrintUserName() { + try { + if (LoginHelper.getLoginUser() != null && StringUtils.isNotBlank(LoginHelper.getLoginUser().getNickname())) { + return LoginHelper.getLoginUser().getNickname(); + } + } catch (Exception ignored) { + // ignore + } + return LoginHelper.getUsername(); + } + + 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() && 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 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 { + 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 formatDate(Date date) { + if (date == null) { + return ""; + } + return DateUtils.parseDateToStr(DATE_FORMAT, date); + } + + private String formatDateTime(Date date) { + if (date == null) { + return ""; + } + return DateUtils.parseDateToStr(DATETIME_FORMAT, date); + } + + private String firstNonBlank(String primary, String fallback) { + if (StringUtils.isNotBlank(primary)) { + return primary; + } + return strVal(fallback); + } + + private String strVal(Object value) { + return value == null ? "" : String.valueOf(value); + } } diff --git a/ruoyi-modules/ruoyi-oa/src/main/resources/出差申请单模板.docx b/ruoyi-modules/ruoyi-oa/src/main/resources/出差申请单模板.docx new file mode 100644 index 00000000..7447840e Binary files /dev/null and b/ruoyi-modules/ruoyi-oa/src/main/resources/出差申请单模板.docx differ