gaoluyang
昨天 8c65f898ed6e5b0c7a94c792c4562f6ec831dccd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
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) {
    }
 
}