package cn.iocoder.yudao.module.mes.service.pro.worktime; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.mes.controller.admin.pro.feedback.vo.MesProFeedbackSaveReqVO; import cn.iocoder.yudao.module.mes.controller.admin.pro.worktime.vo.MesProWorktimeCalcReqVO; import cn.iocoder.yudao.module.mes.controller.admin.pro.worktime.vo.MesProWorktimeCalcRespVO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO; import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackAuxDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.worktime.MesProWorktimeAuxDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderParamRecordDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderParamRecordDetailDO; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderParamRecordDetailMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderParamRecordMapper; import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService; import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService; import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService; import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_TASK_NOT_EXISTS; /** * MES 标准工时计算 Service 实现类 * *

计算口径(与《工时考核(数改模版)》一致): *

* 任何参数缺失都只记为“未计算原因”,不阻断报工。 * * @author 超级管理员 */ @Service public class MesProWorktimeServiceImpl implements MesProWorktimeService { private static final BigDecimal SIXTY = BigDecimal.valueOf(60); private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); @Resource private MesProTaskService taskService; @Resource private MesProProcessService processService; @Resource private MesProWorktimeAuxService worktimeAuxService; @Resource private MesMdmItemService mdmItemService; @Resource private MesMdUnitMeasureService unitMeasureService; @Resource private MesProWorkOrderParamRecordMapper paramRecordMapper; @Resource private MesProWorkOrderParamRecordDetailMapper paramRecordDetailMapper; /** * 工时超出标准工时的告警阈值(百分比),默认 20% */ @Value("${yudao.mes.worktime.warn-threshold:20}") private BigDecimal warnThreshold; @Override public MesProWorktimeCalcRespVO calculate(MesProWorktimeCalcReqVO reqVO) { MesProTaskDO task = taskService.getTask(reqVO.getTaskId()); if (task == null) { throw exception(PRO_TASK_NOT_EXISTS); } return doCalculate(task, reqVO.getProductionLength(), reqVO.getFeedbackQuantity(), reqVO.getAuxList()); } @Override public List calculateForFeedback(MesProFeedbackDO feedback, List auxValues) { MesProTaskDO task = feedback.getTaskId() != null ? taskService.getTask(feedback.getTaskId()) : null; List auxInputs = toAuxInputs(auxValues); MesProWorktimeCalcRespVO result = doCalculate(task, feedback.getProductionLength(), feedback.getFeedbackQuantity(), auxInputs); feedback.setProductionLength(result.getProductionLength() != null ? result.getProductionLength() : BigDecimal.ZERO); feedback.setStandardWorkHours(result.getStandardWorkHours()); feedback.setStandardPrimaryHours(result.getPrimaryHours()); feedback.setStandardAuxHours(result.getAuxHours()); String note = result.getNote(); if (CollUtil.isNotEmpty(result.getMissingReasons())) { note = StrUtil.format("{};未计算原因:{}", note, String.join(";", result.getMissingReasons())); } feedback.setStdCalcNote(StrUtil.maxLength(note, 500)); // 辅助工时明细:以计算结果为准,保留录入的次数/材料对,便于审核追查 List auxList = new ArrayList<>(); if (CollUtil.isNotEmpty(result.getAuxList())) { for (MesProWorktimeCalcRespVO.AuxResult auxResult : result.getAuxList()) { auxList.add(MesProFeedbackAuxDO.builder() .auxItemId(auxResult.getAuxItemId()) .itemName(auxResult.getItemName()) .measureType(auxResult.getMeasureType()) .quantity(auxResult.getQuantity()) .materialFrom(auxResult.getMaterialFrom()) .materialTo(auxResult.getMaterialTo()) .minutes(auxResult.getMinutes()) .remark(auxResult.getRemark()) .build()); } } return auxList; } @Override public String buildWarnMessage(BigDecimal actualHours, BigDecimal standardHours) { if (actualHours == null || standardHours == null || standardHours.signum() <= 0) { return null; } BigDecimal threshold = warnThreshold != null ? warnThreshold : BigDecimal.valueOf(20); BigDecimal diffRate = actualHours.subtract(standardHours) .multiply(HUNDRED) .divide(standardHours, 2, RoundingMode.HALF_UP); if (diffRate.compareTo(threshold) <= 0) { return null; } return StrUtil.format("本次报工工时 {} 小时,高于标准工时 {} 小时达 {}%,超过 {}% 阈值;" + "请核对报工工时是否录错,以及辅助项次数是否按实际填写", actualHours.setScale(2, RoundingMode.HALF_UP), standardHours.setScale(2, RoundingMode.HALF_UP), diffRate, threshold.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros()); } private MesProWorktimeCalcRespVO doCalculate(MesProTaskDO task, BigDecimal productionLength, BigDecimal feedbackQuantity, List auxInputs) { MesProWorktimeCalcRespVO resp = new MesProWorktimeCalcRespVO(); resp.setProductionLength(productionLength); resp.setStandardWorkHours(BigDecimal.ZERO); resp.setPrimaryHours(BigDecimal.ZERO); resp.setAuxHours(BigDecimal.ZERO); resp.setAuxList(List.of()); List missingReasons = new ArrayList<>(); resp.setMissingReasons(missingReasons); if (task == null) { missingReasons.add("生产任务不存在,无法计算标准工时"); resp.setNote(String.join(";", missingReasons)); return resp; } Long processId = task.getProcessId(); MesProProcessDO process = processId != null ? processService.getProcess(processId) : null; if (process == null) { missingReasons.add(StrUtil.format("生产任务的工序 ID={} 不存在,无法计算标准工时", processId)); resp.setNote(String.join(";", missingReasons)); return resp; } // 1. 生产长度:未填且报工单位为“米”时按报工数量带入 BigDecimal length = productionLength; if (length == null || length.signum() <= 0) { length = defaultProductionLength(task, feedbackQuantity); } if (length == null || length.signum() <= 0) { missingReasons.add("未填写生产长度(米),无法计算主工时;请在报工单填写本次生产长度"); } resp.setProductionLength(length); // 2. 线速度 SpeedResult speedResult = calculateSpeed(task, process, missingReasons); // 3. 主工时 BigDecimal primaryHours = BigDecimal.ZERO; if (length != null && length.signum() > 0 && speedResult.speed != null && speedResult.speed.signum() > 0) { primaryHours = length.divide(speedResult.speed, 6, RoundingMode.HALF_UP) .divide(SIXTY, 2, RoundingMode.HALF_UP); } // 4. 辅助工时 AuxCalcResult auxResult = calculateAux(task, process, auxInputs, missingReasons); // 5. 汇总 BigDecimal standardHours = primaryHours.add(auxResult.auxHours).setScale(2, RoundingMode.HALF_UP); resp.setPrimaryHours(primaryHours); resp.setAuxHours(auxResult.auxHours); resp.setStandardWorkHours(standardHours); resp.setAuxList(auxResult.items); resp.setNote(buildNote(length, speedResult, primaryHours, auxResult)); return resp; } /** * 计算线速度(米/分) */ private SpeedResult calculateSpeed(MesProTaskDO task, MesProProcessDO process, List missingReasons) { Integer mode = process.getStdTimeMode(); if (mode == null || mode == 0) { missingReasons.add(StrUtil.format("工序「{}」未启用标准工时计算方式,主工时按 0 处理;" + "请在工序设置中选择计算方式并绑定参数", process.getName())); return new SpeedResult(null, null); } Map paramValues = loadTaskParamValues(task.getId()); BigDecimal factor = process.getStdTimeFactor() != null ? process.getStdTimeFactor() : BigDecimal.ONE; if (mode == 2) { BigDecimal speed = parseParam(process.getStdSpeedParamCode(), "线速度", paramValues, missingReasons); return new SpeedResult(speed, StrUtil.format("线速度 {} 米/分(参数直取)", speed)); } BigDecimal pitch = parseParam(process.getStdPitchParamCode(), "节距", paramValues, missingReasons); BigDecimal rpm; String rpmNote; if (mode == 1) { rpm = parseParam(process.getStdRpmParamCode(), "转速", paramValues, missingReasons); rpmNote = "转速 " + plain(rpm) + " r/min"; } else if (mode == 3) { BigDecimal percent = parseParam(process.getStdPercentParamCode(), "转速百分比", paramValues, missingReasons); BigDecimal maxRpm = process.getMaxRpm(); if (maxRpm == null || maxRpm.signum() <= 0) { missingReasons.add(StrUtil.format("工序「{}」未配置机台满转速(r/min),无法按“满速百分比×节距”计算", process.getName())); rpm = null; } else if (percent == null) { rpm = null; } else { rpm = maxRpm.multiply(percent).divide(HUNDRED, 4, RoundingMode.HALF_UP); } rpmNote = StrUtil.format("转速 {} r/min(满转速 {} × {}%)", plain(rpm), plain(maxRpm), plain(percent)); } else { missingReasons.add(StrUtil.format("工序「{}」的标准工时计算方式[{}]无效,只能是 0/1/2/3", process.getName(), mode)); return new SpeedResult(null, null); } if (rpm == null || pitch == null) { return new SpeedResult(null, null); } BigDecimal speed = rpm.multiply(pitch).divide(THOUSAND, 4, RoundingMode.HALF_UP).multiply(factor) .setScale(4, RoundingMode.HALF_UP); if (speed.signum() <= 0) { missingReasons.add(StrUtil.format("按转速 {} × 节距 {} ÷ 1000 × 系数 {} 算出的线速度不大于 0,请检查工艺参数", plain(rpm), plain(pitch), plain(factor))); return new SpeedResult(null, null); } String note = StrUtil.format("线速度 {} 米/分({} × 节距 {} ÷ 1000 × 系数 {})", plain(speed), rpmNote, plain(pitch), plain(factor)); return new SpeedResult(speed, note); } private AuxCalcResult calculateAux(MesProTaskDO task, MesProProcessDO process, List auxInputs, List missingReasons) { if (CollUtil.isEmpty(auxInputs)) { return new AuxCalcResult(BigDecimal.ZERO, List.of()); } Long workstationId = task.getWorkstationId(); Map applicableMap = new HashMap<>(); worktimeAuxService.getApplicableList(process.getId(), workstationId) .forEach(aux -> applicableMap.put(aux.getId(), aux)); List items = new ArrayList<>(); BigDecimal totalMinutes = BigDecimal.ZERO; for (MesProWorktimeCalcReqVO.AuxInput input : auxInputs) { if (input == null || input.getAuxItemId() == null) { continue; } MesProWorktimeAuxDO aux = applicableMap.get(input.getAuxItemId()); if (aux == null) { missingReasons.add(StrUtil.format("辅助工时项目 ID={} 未配置在当前工序「{}」或机台「{}」下," + "已忽略该项;请刷新页面重新选择辅助项目", input.getAuxItemId(), process.getName(), workstationId == null ? "不限机台" : "ID=" + workstationId)); continue; } BigDecimal quantity = input.getQuantity() != null ? input.getQuantity() : BigDecimal.ZERO; BigDecimal minutes; if (MesProWorktimeAuxDO.MEASURE_METERS == aux.getMeasureType()) { BigDecimal speed = aux.getSpeedMPerMin(); if (speed == null || speed.signum() <= 0) { missingReasons.add(StrUtil.format("辅助项目「{}」按米折算,但未配置折算速度(米/分),已忽略该项", aux.getItemName())); continue; } minutes = quantity.divide(speed, 2, RoundingMode.HALF_UP); } else { BigDecimal duration = aux.getDurationMinutes() != null ? aux.getDurationMinutes() : BigDecimal.ZERO; minutes = quantity.multiply(duration).setScale(2, RoundingMode.HALF_UP); } totalMinutes = totalMinutes.add(minutes); MesProWorktimeCalcRespVO.AuxResult item = new MesProWorktimeCalcRespVO.AuxResult(); item.setAuxItemId(aux.getId()); item.setItemName(aux.getItemName()); item.setMeasureType(aux.getMeasureType()); item.setQuantity(quantity); item.setMaterialFrom(input.getMaterialFrom()); item.setMaterialTo(input.getMaterialTo()); item.setMinutes(minutes); item.setRemark(input.getRemark()); items.add(item); } BigDecimal auxHours = totalMinutes.divide(SIXTY, 2, RoundingMode.HALF_UP); return new AuxCalcResult(auxHours, items); } /** * 生产长度未填时:报工单位是“米”则直接按报工数量带入,否则返回 null 由人工填写 */ private BigDecimal defaultProductionLength(MesProTaskDO task, BigDecimal feedbackQuantity) { if (feedbackQuantity == null || feedbackQuantity.signum() <= 0 || task.getItemId() == null) { return null; } MesMdmItemDO item = mdmItemService.getItem(task.getItemId()); if (item == null || item.getUnitMeasureId() == null) { return null; } MesMdUnitMeasureDO unit = unitMeasureService.getUnitMeasure(item.getUnitMeasureId()); if (unit == null || unit.getName() == null) { return null; } String unitName = unit.getName().trim(); if (Objects.equals(unitName, "米") || Objects.equals(unitName, "m") || Objects.equals(unitName, "M")) { return feedbackQuantity; } return null; } /** * 读取任务工艺参数记录中的参数值(编码 → 值),取第一条记录 */ private Map loadTaskParamValues(Long taskId) { Map values = new HashMap<>(); MesProWorkOrderParamRecordDO record = paramRecordMapper.selectFirstByTaskId(taskId); if (record == null) { return values; } List details = paramRecordDetailMapper.selectListByRecordId(record.getId()); for (MesProWorkOrderParamRecordDetailDO detail : details) { if (StrUtil.isNotBlank(detail.getParamCode()) && StrUtil.isNotBlank(detail.getParamValue())) { values.putIfAbsent(detail.getParamCode(), detail.getParamValue()); } } return values; } private BigDecimal parseParam(String paramCode, String paramLabel, Map values, List missingReasons) { if (StrUtil.isBlank(paramCode)) { missingReasons.add(StrUtil.format("工序未绑定「{}」参数编码,请在工序设置中选择{}参数", paramLabel, paramLabel)); return null; } String raw = values.get(paramCode); if (StrUtil.isBlank(raw)) { missingReasons.add(StrUtil.format("工艺路线参数「{}」未填写,无法计算标准工时;请先在下达的工艺参数中填写{}", paramCode, paramLabel)); return null; } BigDecimal value = parseNumber(raw); if (value == null) { missingReasons.add(StrUtil.format("参数「{}」的值[{}]不是有效数字,无法计算标准工时;请按数值填写", paramCode, raw)); } return value; } private static BigDecimal parseNumber(String raw) { if (StrUtil.isBlank(raw)) { return null; } String normalized = StrUtil.trim(raw).replace(",", "").replace(",", ""); try { return new BigDecimal(normalized); } catch (NumberFormatException ignored) { // 参数值可能带单位,例如 "1200 r/min",取其中最前面的数值 java.util.regex.Matcher matcher = java.util.regex.Pattern .compile("-?\\d+(?:\\.\\d+)?").matcher(normalized); if (matcher.find()) { try { return new BigDecimal(matcher.group()); } catch (NumberFormatException ignoredAgain) { return null; } } return null; } } private String buildNote(BigDecimal length, SpeedResult speedResult, BigDecimal primaryHours, AuxCalcResult auxResult) { List parts = new ArrayList<>(); if (length != null && length.signum() > 0) { parts.add(StrUtil.format("生产长度 {} 米", plain(length))); } parts.add(speedResult.note != null ? speedResult.note : "线速度未计算"); parts.add(StrUtil.format("主工时 {} 小时", plain(primaryHours))); parts.add(StrUtil.format("辅助工时 {} 小时", plain(auxResult.auxHours))); return String.join(";", parts); } private List toAuxInputs(List auxValues) { if (CollUtil.isEmpty(auxValues)) { return List.of(); } List inputs = new ArrayList<>(); for (MesProFeedbackSaveReqVO.AuxValue auxValue : auxValues) { if (auxValue == null || auxValue.getAuxItemId() == null) { continue; } MesProWorktimeCalcReqVO.AuxInput input = new MesProWorktimeCalcReqVO.AuxInput(); input.setAuxItemId(auxValue.getAuxItemId()); input.setQuantity(auxValue.getQuantity()); input.setMaterialFrom(auxValue.getMaterialFrom()); input.setMaterialTo(auxValue.getMaterialTo()); input.setRemark(auxValue.getRemark()); inputs.add(input); } return inputs; } private static String plain(BigDecimal value) { if (value == null) { return "-"; } return value.stripTrailingZeros().toPlainString(); } private record SpeedResult(BigDecimal speed, String note) { } private record AuxCalcResult(BigDecimal auxHours, List items) { } }