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 实现类
*
*
计算口径(与《工时考核(数改模版)》一致):
*
* - 方式 1-转速×节距:线速度 = 转速 × 节距 ÷ 1000 × 折算系数
* - 方式 2-线速度直取:线速度 = 参数值
* - 方式 3-满速百分比×节距:转速 = 满转速 × 百分比 ÷ 100,再按方式 1 折算线速度
* - 主工时(小时)= 生产长度(米)÷ 线速度(米/分)÷ 60
* - 辅助工时(小时)= Σ(次数 × 单项分钟数) ÷ 60;按米折算时 = 米数 ÷ 折算速度
* - 标准工时 = 主工时 + 辅助工时
*
* 任何参数缺失都只记为“未计算原因”,不阻断报工。
*
* @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) {
}
}