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 实现类
|
*
|
* <p>计算口径(与《工时考核(数改模版)》一致):
|
* <ul>
|
* <li>方式 1-转速×节距:线速度 = 转速 × 节距 ÷ 1000 × 折算系数</li>
|
* <li>方式 2-线速度直取:线速度 = 参数值</li>
|
* <li>方式 3-满速百分比×节距:转速 = 满转速 × 百分比 ÷ 100,再按方式 1 折算线速度</li>
|
* <li>主工时(小时)= 生产长度(米)÷ 线速度(米/分)÷ 60</li>
|
* <li>辅助工时(小时)= Σ(次数 × 单项分钟数) ÷ 60;按米折算时 = 米数 ÷ 折算速度</li>
|
* <li>标准工时 = 主工时 + 辅助工时</li>
|
* </ul>
|
* 任何参数缺失都只记为“未计算原因”,不阻断报工。
|
*
|
* @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<MesProFeedbackAuxDO> calculateForFeedback(MesProFeedbackDO feedback,
|
List<MesProFeedbackSaveReqVO.AuxValue> auxValues) {
|
MesProTaskDO task = feedback.getTaskId() != null ? taskService.getTask(feedback.getTaskId()) : null;
|
List<MesProWorktimeCalcReqVO.AuxInput> 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<MesProFeedbackAuxDO> 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<MesProWorktimeCalcReqVO.AuxInput> 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<String> 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<String> missingReasons) {
|
Integer mode = process.getStdTimeMode();
|
if (mode == null || mode == 0) {
|
missingReasons.add(StrUtil.format("工序「{}」未启用标准工时计算方式,主工时按 0 处理;"
|
+ "请在工序设置中选择计算方式并绑定参数", process.getName()));
|
return new SpeedResult(null, null);
|
}
|
Map<String, String> 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<MesProWorktimeCalcReqVO.AuxInput> auxInputs,
|
List<String> missingReasons) {
|
if (CollUtil.isEmpty(auxInputs)) {
|
return new AuxCalcResult(BigDecimal.ZERO, List.of());
|
}
|
Long workstationId = task.getWorkstationId();
|
Map<Long, MesProWorktimeAuxDO> applicableMap = new HashMap<>();
|
worktimeAuxService.getApplicableList(process.getId(), workstationId)
|
.forEach(aux -> applicableMap.put(aux.getId(), aux));
|
|
List<MesProWorktimeCalcRespVO.AuxResult> 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<String, String> loadTaskParamValues(Long taskId) {
|
Map<String, String> values = new HashMap<>();
|
MesProWorkOrderParamRecordDO record = paramRecordMapper.selectFirstByTaskId(taskId);
|
if (record == null) {
|
return values;
|
}
|
List<MesProWorkOrderParamRecordDetailDO> 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<String, String> values,
|
List<String> 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<String> 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<MesProWorktimeCalcReqVO.AuxInput> toAuxInputs(List<MesProFeedbackSaveReqVO.AuxValue> auxValues) {
|
if (CollUtil.isEmpty(auxValues)) {
|
return List.of();
|
}
|
List<MesProWorktimeCalcReqVO.AuxInput> 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<MesProWorktimeCalcRespVO.AuxResult> items) {
|
}
|
|
}
|