package cn.iocoder.yudao.module.hrm.service.employee;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO;
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeContractDocumentRespVO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
|
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
|
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeContractMapper;
|
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
|
import cn.iocoder.yudao.module.system.api.dept.PostApi;
|
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
|
import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO;
|
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
|
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
|
import cn.iocoder.yudao.module.system.api.storage.StorageBlobApi;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
import org.apache.poi.xwpf.usermodel.XWPFFooter;
|
import org.apache.poi.xwpf.usermodel.XWPFHeader;
|
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
import org.apache.poi.xwpf.usermodel.XWPFTable;
|
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
|
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
|
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.stereotype.Service;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.io.ByteArrayOutputStream;
|
import java.io.InputStream;
|
import java.math.BigDecimal;
|
import java.text.DecimalFormat;
|
import java.time.LocalDate;
|
import java.time.format.DateTimeFormatter;
|
import java.util.Collections;
|
import java.util.HashMap;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.regex.Matcher;
|
import java.util.regex.Pattern;
|
import java.util.stream.Collectors;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
|
|
/**
|
* 员工合同文档 Service 实现类
|
* <p>
|
* 基于 Word 模板占位符填充生成劳动合同,生成结果存入系统文件库并绑定到合同附件。
|
* 客户提供正式模板后,只需替换模板文件中的占位符内容,无需修改代码。
|
*/
|
@Service
|
@Validated
|
@Slf4j
|
public class HrmEmployeeContractDocumentServiceImpl implements HrmEmployeeContractDocumentService {
|
|
private static final String TEMPLATE_PATH = "templates/hrm/employee-contract-template.docx";
|
private static final String APPLICATION = "file";
|
private static final String RECORD_TYPE = "hrm_employee_contract";
|
private static final String CONTENT_TYPE =
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy年M月d日");
|
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
|
private static final String UNSET_TEXT = "未填写";
|
|
@Resource
|
private HrmEmployeeContractMapper contractMapper;
|
@Resource
|
private HrmEmployeeService employeeService;
|
@Resource
|
private DeptApi deptApi;
|
@Resource
|
private PostApi postApi;
|
@Resource
|
private DictDataApi dictDataApi;
|
@Resource
|
private StorageBlobApi storageBlobApi;
|
@Resource
|
private StorageAttachmentApi storageAttachmentApi;
|
|
@Override
|
public HrmEmployeeContractDocumentRespVO generateContractDocument(Long contractId) {
|
// 1. 校验合同与员工
|
HrmEmployeeContractDO contract = contractMapper.selectById(contractId);
|
if (contract == null) {
|
throw exception(EMPLOYEE_CONTRACT_NOT_EXISTS);
|
}
|
HrmEmployeeDO employee = employeeService.getEmployee(contract.getEmployeeId());
|
if (employee == null) {
|
throw exception(EMPLOYEE_CONTRACT_DOCUMENT_EMPLOYEE_NOT_EXISTS,
|
contract.getContractNo(), contract.getEmployeeId());
|
}
|
|
// 2. 模板必须存在,避免生成空文档
|
ClassPathResource template = new ClassPathResource(TEMPLATE_PATH);
|
if (!template.exists()) {
|
throw exception(EMPLOYEE_CONTRACT_TEMPLATE_NOT_FOUND, TEMPLATE_PATH);
|
}
|
|
// 3. 填充模板
|
String fileName = buildFileName(contract, employee);
|
byte[] content = fillTemplate(template, contract, employee);
|
if (content.length == 0) {
|
throw exception(EMPLOYEE_CONTRACT_DOCUMENT_GENERATE_FAIL, "生成的文件内容为空");
|
}
|
|
// 4. 存入系统文件库,并绑定为合同附件(每次生成保留一份历史版本)
|
Long blobId = storageBlobApi.saveBlob(content, fileName, CONTENT_TYPE);
|
if (blobId == null) {
|
throw exception(EMPLOYEE_CONTRACT_DOCUMENT_GENERATE_FAIL, "文件保存失败");
|
}
|
storageAttachmentApi.bindAttachments(APPLICATION, RECORD_TYPE, contractId,
|
Collections.singletonList(blobId));
|
|
HrmEmployeeContractDocumentRespVO respVO = new HrmEmployeeContractDocumentRespVO();
|
respVO.setBlobId(blobId);
|
respVO.setFileName(fileName);
|
return respVO;
|
}
|
|
private byte[] fillTemplate(ClassPathResource template, HrmEmployeeContractDO contract,
|
HrmEmployeeDO employee) {
|
Map<String, String> values = buildPlaceholderValues(contract, employee);
|
try (InputStream in = template.getInputStream();
|
XWPFDocument document = new XWPFDocument(in)) {
|
replacePlaceholders(document, values);
|
Set<String> unresolved = findUnresolvedPlaceholders(document);
|
if (CollUtil.isNotEmpty(unresolved)) {
|
throw exception(EMPLOYEE_CONTRACT_TEMPLATE_PLACEHOLDER_UNRESOLVED,
|
String.join("、", unresolved));
|
}
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
document.write(out);
|
return out.toByteArray();
|
} catch (ServiceException e) {
|
throw e;
|
} catch (Exception e) {
|
log.error("[fillTemplate][合同({}) 生成文档失败]", contract.getId(), e);
|
throw exception(EMPLOYEE_CONTRACT_DOCUMENT_GENERATE_FAIL, "读取模板或生成 Word 文件异常");
|
}
|
}
|
|
private Map<String, String> buildPlaceholderValues(HrmEmployeeContractDO contract,
|
HrmEmployeeDO employee) {
|
Map<String, String> values = new HashMap<>();
|
// 合同信息
|
values.put("documentTitle", buildDocumentTitle(contract.getContractType()));
|
values.put("contractNo", defaultText(contract.getContractNo()));
|
values.put("contractType", getDictLabel("hrm_contract_type", contract.getContractType()));
|
values.put("contractTermType", getDictLabel("hrm_contract_term_type", contract.getContractTermType()));
|
values.put("signCompany", defaultText(contract.getSignCompany()));
|
values.put("signDate", formatDate(contract.getSignDate()));
|
values.put("contractPeriod", buildContractPeriod(contract));
|
values.put("probationPeriod", buildProbationPeriod(contract));
|
values.put("probationSalary", formatSalary(contract.getProbationSalary()));
|
values.put("regularSalary", formatSalary(contract.getRegularSalary()));
|
values.put("generateDate", formatDate(LocalDate.now()));
|
// 员工信息
|
values.put("employeeName", defaultText(employee.getName()));
|
values.put("gender", formatGender(employee.getGender()));
|
values.put("idCard", defaultText(employee.getIdCard()));
|
values.put("employeeNo", defaultText(employee.getEmployeeNo()));
|
values.put("phone", defaultText(employee.getPhone()));
|
values.put("address", defaultText(employee.getAddress()));
|
values.put("hireDate", formatDate(employee.getHireDate()));
|
values.put("deptName", getDeptName(employee.getDeptId()));
|
values.put("postNames", getPostNames(employee.getPostIds()));
|
return values;
|
}
|
|
private String buildContractPeriod(HrmEmployeeContractDO contract) {
|
if (contract.getStartDate() == null) {
|
return "未填写(合同开始日期为空,请补全合同信息后重新生成)";
|
}
|
if (contract.getEndDate() == null) {
|
return "自 " + formatDate(contract.getStartDate()) + " 起,无固定期限";
|
}
|
return "自 " + formatDate(contract.getStartDate()) + " 起至 " + formatDate(contract.getEndDate()) + " 止";
|
}
|
|
private String buildProbationPeriod(HrmEmployeeContractDO contract) {
|
LocalDate startDate = contract.getProbationStartDate();
|
LocalDate endDate = contract.getProbationEndDate();
|
if (startDate == null && endDate == null) {
|
return "无试用期";
|
}
|
if (startDate == null || endDate == null) {
|
return "试用期信息不完整(开始日期:" + formatDate(startDate) + ",结束日期:" + formatDate(endDate)
|
+ "),请补全后重新生成";
|
}
|
return "自 " + formatDate(startDate) + " 起至 " + formatDate(endDate) + " 止";
|
}
|
|
private String buildDocumentTitle(Integer contractType) {
|
if (contractType == null) {
|
return "合同书";
|
}
|
switch (contractType) {
|
case 1:
|
return "劳动合同书";
|
case 2:
|
return "劳务合同书";
|
case 3:
|
return "实习协议书";
|
case 4:
|
return "劳务派遣合同书";
|
default:
|
return "合同书";
|
}
|
}
|
|
private String buildFileName(HrmEmployeeContractDO contract, HrmEmployeeDO employee) {
|
String contractType = getDictLabel("hrm_contract_type", contract.getContractType());
|
String name = defaultText(employee.getName());
|
String contractNo = defaultText(contract.getContractNo());
|
return sanitizeFileName(contractType + "-" + name + "-" + contractNo + ".docx");
|
}
|
|
private String sanitizeFileName(String fileName) {
|
return fileName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
}
|
|
private String getDictLabel(String dictType, Integer value) {
|
if (value == null) {
|
return UNSET_TEXT;
|
}
|
try {
|
return dictDataApi.getDictDataList(dictType).stream()
|
.filter(data -> Objects.equals(data.getValue(), String.valueOf(value)))
|
.map(DictDataRespDTO::getLabel)
|
.findFirst()
|
.orElse("未知(" + value + ")");
|
} catch (Exception e) {
|
log.warn("[getDictLabel][字典({}) 获取失败,使用原始值({})]", dictType, value, e);
|
return "未知(" + value + ")";
|
}
|
}
|
|
private String getDeptName(Long deptId) {
|
if (deptId == null) {
|
return UNSET_TEXT;
|
}
|
try {
|
Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(Collections.singleton(deptId));
|
DeptRespDTO dept = deptMap.get(deptId);
|
return dept == null ? "未知(ID=" + deptId + ")" : dept.getName();
|
} catch (Exception e) {
|
log.warn("[getDeptName][部门({}) 获取失败]", deptId, e);
|
return "未知(ID=" + deptId + ")";
|
}
|
}
|
|
private String getPostNames(String postIds) {
|
if (StrUtil.isBlank(postIds)) {
|
return UNSET_TEXT;
|
}
|
try {
|
List<Long> ids = JsonUtils.parseArray(postIds, Long.class);
|
if (CollUtil.isEmpty(ids)) {
|
return UNSET_TEXT;
|
}
|
Map<Long, PostRespDTO> postMap = postApi.getPostMap(ids);
|
String names = ids.stream()
|
.map(postMap::get)
|
.filter(Objects::nonNull)
|
.map(PostRespDTO::getName)
|
.collect(Collectors.joining("、"));
|
return StrUtil.isBlank(names) ? UNSET_TEXT : names;
|
} catch (Exception e) {
|
log.warn("[getPostNames][岗位({}) 解析失败]", postIds, e);
|
return UNSET_TEXT;
|
}
|
}
|
|
private String formatGender(Integer gender) {
|
if (gender == null) {
|
return UNSET_TEXT;
|
}
|
if (gender == 1) {
|
return "男";
|
}
|
if (gender == 2) {
|
return "女";
|
}
|
return UNSET_TEXT;
|
}
|
|
private String formatSalary(BigDecimal salary) {
|
if (salary == null) {
|
return UNSET_TEXT;
|
}
|
return "¥" + new DecimalFormat("#,##0.00").format(salary) + " 元/月";
|
}
|
|
private String formatDate(LocalDate date) {
|
return date == null ? UNSET_TEXT : DATE_FORMATTER.format(date);
|
}
|
|
private String defaultText(String text) {
|
return StrUtil.isBlank(text) ? UNSET_TEXT : text;
|
}
|
|
// ========== 模板占位符替换 ==========
|
|
private void replacePlaceholders(XWPFDocument document, Map<String, String> values) {
|
document.getParagraphs().forEach(paragraph -> replaceInParagraph(paragraph, values));
|
document.getTables().forEach(table -> replaceInTable(table, values));
|
for (XWPFHeader header : document.getHeaderList()) {
|
header.getParagraphs().forEach(paragraph -> replaceInParagraph(paragraph, values));
|
header.getTables().forEach(table -> replaceInTable(table, values));
|
}
|
for (XWPFFooter footer : document.getFooterList()) {
|
footer.getParagraphs().forEach(paragraph -> replaceInParagraph(paragraph, values));
|
footer.getTables().forEach(table -> replaceInTable(table, values));
|
}
|
}
|
|
private void replaceInTable(XWPFTable table, Map<String, String> values) {
|
for (XWPFTableRow row : table.getRows()) {
|
for (XWPFTableCell cell : row.getTableCells()) {
|
cell.getParagraphs().forEach(paragraph -> replaceInParagraph(paragraph, values));
|
cell.getTables().forEach(nested -> replaceInTable(nested, values));
|
}
|
}
|
}
|
|
private void replaceInParagraph(XWPFParagraph paragraph, Map<String, String> values) {
|
String text = paragraph.getText();
|
if (StrUtil.isBlank(text) || !text.contains("{{")) {
|
return;
|
}
|
String replaced = text;
|
for (Map.Entry<String, String> entry : values.entrySet()) {
|
replaced = replaced.replace("{{" + entry.getKey() + "}}", entry.getValue());
|
}
|
if (Objects.equals(replaced, text)) {
|
return;
|
}
|
List<XWPFRun> runs = paragraph.getRuns();
|
if (CollUtil.isEmpty(runs)) {
|
return;
|
}
|
// Word 可能把占位符拆分到多个 run,统一合并到首个 run 后再替换,保留首个 run 的格式
|
XWPFRun firstRun = runs.get(0);
|
for (int i = firstRun.getCTR().sizeOfTArray() - 1; i >= 1; i--) {
|
firstRun.getCTR().removeT(i);
|
}
|
firstRun.setText(replaced, 0);
|
for (int i = runs.size() - 1; i >= 1; i--) {
|
paragraph.removeRun(i);
|
}
|
}
|
|
private Set<String> findUnresolvedPlaceholders(XWPFDocument document) {
|
Set<String> placeholders = new LinkedHashSet<>();
|
document.getParagraphs().forEach(paragraph -> collectPlaceholders(paragraph, placeholders));
|
document.getTables().forEach(table -> collectPlaceholders(table, placeholders));
|
for (XWPFHeader header : document.getHeaderList()) {
|
header.getParagraphs().forEach(paragraph -> collectPlaceholders(paragraph, placeholders));
|
header.getTables().forEach(table -> collectPlaceholders(table, placeholders));
|
}
|
for (XWPFFooter footer : document.getFooterList()) {
|
footer.getParagraphs().forEach(paragraph -> collectPlaceholders(paragraph, placeholders));
|
footer.getTables().forEach(table -> collectPlaceholders(table, placeholders));
|
}
|
return placeholders;
|
}
|
|
private void collectPlaceholders(XWPFTable table, Set<String> placeholders) {
|
for (XWPFTableRow row : table.getRows()) {
|
for (XWPFTableCell cell : row.getTableCells()) {
|
cell.getParagraphs().forEach(paragraph -> collectPlaceholders(paragraph, placeholders));
|
cell.getTables().forEach(nested -> collectPlaceholders(nested, placeholders));
|
}
|
}
|
}
|
|
private void collectPlaceholders(XWPFParagraph paragraph, Set<String> placeholders) {
|
Matcher matcher = PLACEHOLDER_PATTERN.matcher(paragraph.getText());
|
while (matcher.find()) {
|
placeholders.add("{{" + matcher.group(1).trim() + "}}");
|
}
|
}
|
|
}
|