From ef68690cc13327d9ed26b42d4481b37044703997 Mon Sep 17 00:00:00 2001 From: 86156 <823267011@qq.com> Date: Wed, 28 Jan 2026 18:40:53 +0800 Subject: [PATCH] =?UTF-8?q?OEE=E5=88=9D=E7=89=88=EF=BC=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=97=AE=E9=A2=98=E5=92=8C=E4=BA=A4=E4=BA=92=E9=97=AE?= =?UTF-8?q?=E9=A2=981.29=E5=86=8D=E8=AF=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- klp-da/pom.xml | 4 + .../da/controller/OeeReportController.java | 53 +- .../com/klp/da/service/IOeeReportService.java | 5 + .../klp/da/service/OeeSummaryJobService.java | 19 + .../da/service/OeeTheoryCycleJobService.java | 19 + .../da/service/impl/OeeReportServiceImpl.java | 857 ++++++++++-- .../impl/OeeSummaryJobServiceImpl.java | 155 +++ .../impl/OeeTheoryCycleJobServiceImpl.java | 182 +++ .../websocket/TypeWebSocketConfig.java | 30 + .../websocket/TypeWebSocketHandler.java | 93 ++ .../websocket/TypeWebSocketInterceptor.java | 47 + .../websocket/TypeWebSocketUtil.java | 43 + klp-ui/src/api/da/oee.js | 86 +- klp-ui/src/views/da/oee/index.vue | 1211 ++++++++++++----- .../WmsCoilPendingActionController.java | 16 + .../com/klp/domain/vo/TheoryCyclePointVo.java | 33 + .../vo/TheoryCycleRegressionResultVo.java | 18 + .../domain/vo/TheoryCycleRegressionVo.java | 49 + .../service/IWmsCoilPendingActionService.java | 15 + .../impl/WmsCoilPendingActionServiceImpl.java | 292 +++- .../impl/WmsMaterialCoilServiceImpl.java | 86 +- 21 files changed, 2789 insertions(+), 524 deletions(-) create mode 100644 klp-da/src/main/java/com/klp/da/service/OeeSummaryJobService.java create mode 100644 klp-da/src/main/java/com/klp/da/service/OeeTheoryCycleJobService.java create mode 100644 klp-da/src/main/java/com/klp/da/service/impl/OeeSummaryJobServiceImpl.java create mode 100644 klp-da/src/main/java/com/klp/da/service/impl/OeeTheoryCycleJobServiceImpl.java create mode 100644 klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketConfig.java create mode 100644 klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketHandler.java create mode 100644 klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketInterceptor.java create mode 100644 klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketUtil.java create mode 100644 klp-wms/src/main/java/com/klp/domain/vo/TheoryCyclePointVo.java create mode 100644 klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionResultVo.java create mode 100644 klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionVo.java diff --git a/klp-da/pom.xml b/klp-da/pom.xml index b522e3e8..d8a884c8 100644 --- a/klp-da/pom.xml +++ b/klp-da/pom.xml @@ -15,6 +15,10 @@ com.klp klp-common + + com.klp + klp-framework + org.projectlombok lombok diff --git a/klp-da/src/main/java/com/klp/da/controller/OeeReportController.java b/klp-da/src/main/java/com/klp/da/controller/OeeReportController.java index 964bd48d..997b0dee 100644 --- a/klp-da/src/main/java/com/klp/da/controller/OeeReportController.java +++ b/klp-da/src/main/java/com/klp/da/controller/OeeReportController.java @@ -8,10 +8,10 @@ import com.klp.common.core.page.TableDataInfo; import com.klp.common.enums.BusinessType; import com.klp.da.domain.bo.OeeQueryBo; import com.klp.da.domain.vo.OeeLineSummaryVo; -import com.klp.da.domain.vo.OeeLossCategorySummaryVo; -import com.klp.da.domain.vo.OeeLossReasonVo; import com.klp.da.domain.vo.OeeEventVo; import com.klp.da.service.IOeeReportService; +import com.klp.da.service.OeeSummaryJobService; +import com.klp.da.service.OeeTheoryCycleJobService; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -40,6 +40,8 @@ import java.util.Map; public class OeeReportController extends BaseController { private final IOeeReportService oeeReportService; + private final OeeTheoryCycleJobService oeeTheoryCycleJobService; + private final OeeSummaryJobService oeeSummaryJobService; /** * KPI + 趋势汇总 @@ -50,6 +52,24 @@ public class OeeReportController extends BaseController { return R.ok(oeeReportService.summary(queryBo)); } + /** + * KPI + 趋势汇总(异步任务):立即返回 jobId,通过 WebSocket 推送进度与结果。 + * + * 前端订阅: + * ws://{host}/websocket?type={wsType} + */ + @Log(title = "OEE 报表-汇总(异步)", businessType = BusinessType.OTHER) + @GetMapping("/summary/job") + public R> summaryJob(OeeQueryBo queryBo) { + System.out.println("[OEE][summary][job][controller] request received, thread=" + Thread.currentThread().getName() + + ", startDate=" + (queryBo == null ? null : queryBo.getStartDate()) + + ", endDate=" + (queryBo == null ? null : queryBo.getEndDate()) + + ", lineIds=" + (queryBo == null ? null : queryBo.getLineIds())); + Map resp = oeeSummaryJobService.createAndStart(queryBo); + System.out.println("[OEE][summary][job][controller] job created, resp=" + resp); + return R.ok(resp); + } + /** * 7 大损失汇总 */ @@ -77,5 +97,34 @@ public class OeeReportController extends BaseController { public void exportWord(OeeQueryBo queryBo, HttpServletResponse response) { oeeReportService.exportWord(queryBo, response); } + + /** + * 理论节拍回归(散点+拟合线) + * + * 说明:该接口用于前端绘制“散点+回归折线”的图形,节拍数据来源于 WMS 回归结果。 + */ + @Log(title = "OEE 报表-理论节拍回归", businessType = BusinessType.OTHER) + @GetMapping("/theoryCycle/regression") + public R> theoryCycleRegression(OeeQueryBo queryBo) { + return R.ok(oeeReportService.theoryCycleRegression(queryBo)); + } + + /** + * 理论节拍回归(异步任务):立即返回 jobId,通过 WebSocket 推送进度与结果。 + * + * 前端订阅: + * ws://{host}/websocket?type={wsType} + */ + @Log(title = "OEE 报表-理论节拍回归(异步)", businessType = BusinessType.OTHER) + @GetMapping("/theoryCycle/regression/job") + public R> theoryCycleRegressionJob(OeeQueryBo queryBo) { + System.out.println("[OEE][theoryCycle][job][controller] request received, thread=" + Thread.currentThread().getName() + + ", startTime=" + (queryBo == null ? null : queryBo.getStartTime()) + + ", endTime=" + (queryBo == null ? null : queryBo.getEndTime()) + + ", lineIds=" + (queryBo == null ? null : queryBo.getLineIds())); + Map resp = oeeTheoryCycleJobService.createAndStart(queryBo); + System.out.println("[OEE][theoryCycle][job][controller] job created, resp=" + resp); + return R.ok(resp); + } } diff --git a/klp-da/src/main/java/com/klp/da/service/IOeeReportService.java b/klp-da/src/main/java/com/klp/da/service/IOeeReportService.java index b198d21c..f0fb5d05 100644 --- a/klp-da/src/main/java/com/klp/da/service/IOeeReportService.java +++ b/klp-da/src/main/java/com/klp/da/service/IOeeReportService.java @@ -43,5 +43,10 @@ public interface IOeeReportService { * 导出 Word 报表 */ void exportWord(OeeQueryBo queryBo, HttpServletResponse response); + + /** + * 理论节拍线性回归(散点+拟合线),用于前端绘图。 + */ + Map theoryCycleRegression(OeeQueryBo queryBo); } diff --git a/klp-da/src/main/java/com/klp/da/service/OeeSummaryJobService.java b/klp-da/src/main/java/com/klp/da/service/OeeSummaryJobService.java new file mode 100644 index 00000000..7e0d51fe --- /dev/null +++ b/klp-da/src/main/java/com/klp/da/service/OeeSummaryJobService.java @@ -0,0 +1,19 @@ +package com.klp.da.service; + +import com.klp.da.domain.bo.OeeQueryBo; + +import java.util.Map; + +/** + * OEE summary 异步任务(WebSocket 推送) + */ +public interface OeeSummaryJobService { + + /** + * 创建任务并异步执行,立即返回 job 信息。 + * + * @return Map: { jobId, wsType } + */ + Map createAndStart(OeeQueryBo queryBo); +} + diff --git a/klp-da/src/main/java/com/klp/da/service/OeeTheoryCycleJobService.java b/klp-da/src/main/java/com/klp/da/service/OeeTheoryCycleJobService.java new file mode 100644 index 00000000..6d69685d --- /dev/null +++ b/klp-da/src/main/java/com/klp/da/service/OeeTheoryCycleJobService.java @@ -0,0 +1,19 @@ +package com.klp.da.service; + +import com.klp.da.domain.bo.OeeQueryBo; + +import java.util.Map; + +/** + * 理论节拍回归异步任务(WebSocket 推送) + */ +public interface OeeTheoryCycleJobService { + + /** + * 创建任务并异步执行,立即返回 job 信息。 + * + * @return Map: { jobId, wsType } + */ + Map createAndStart(OeeQueryBo queryBo); +} + diff --git a/klp-da/src/main/java/com/klp/da/service/impl/OeeReportServiceImpl.java b/klp-da/src/main/java/com/klp/da/service/impl/OeeReportServiceImpl.java index 4d0fdee0..aa09f416 100644 --- a/klp-da/src/main/java/com/klp/da/service/impl/OeeReportServiceImpl.java +++ b/klp-da/src/main/java/com/klp/da/service/impl/OeeReportServiceImpl.java @@ -13,9 +13,20 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequest; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URI; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -26,6 +37,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.stream.Collectors; /** @@ -50,12 +64,68 @@ public class OeeReportServiceImpl implements IOeeReportService { private final ObjectMapper objectMapper = new ObjectMapper(); + /** + * 仅用于耗时/大报文的下游调用(例如理论节拍回归),避免默认 60s readTimeout 导致连接被中止。 + */ + private final RestTemplate longReadRestTemplate = buildLongReadRestTemplate(); + @Value("${da.oee.acid-line-base-url:}") private String acidLineBaseUrl; @Value("${da.oee.galvanize-line-base-url:}") private String galvanizeLineBaseUrl; + /** + * WMS 服务基础地址(用于理论节拍回归接口) + */ + @Value("${da.oee.wms-base-url:}") + private String wmsBaseUrl; + + /** + * 理论节拍(标准节拍):min / 单位(建议单位为 吨,保持两条线一致) + * 用于计算 Performance:ideal_time_min = ideal_cycle_time * total_output + */ + @Value("${da.oee.ideal-cycle-time-min-per-unit.sy:0}") + private BigDecimal idealCycleTimeMinPerUnitSy; + + @Value("${da.oee.ideal-cycle-time-min-per-unit.dx1:0}") + private BigDecimal idealCycleTimeMinPerUnitDx1; + + /** + * 回归结果本地缓存(避免每次 summary/loss 都调用 WMS) + */ + private static final long REGRESSION_CACHE_TTL_MS = 60_000L; + private static final ConcurrentHashMap REGRESSION_CACHE = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap REGRESSION_REFRESHING = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap REGRESSION_LAST_FAIL_TS = new ConcurrentHashMap<>(); + private static final long REGRESSION_FAIL_BACKOFF_MS = 120_000L; + private static final ExecutorService REGRESSION_EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("oee-theory-cycle-regression-refresh"); + t.setDaemon(true); + return t; + }); + + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_BEARER_PREFIX = "Bearer "; + + /** + * 异步任务透传用:当 RequestContextHolder 不可用时,从 ThreadLocal 读取 Authorization。 + */ + private static final ThreadLocal AUTH_OVERRIDE = new ThreadLocal<>(); + + public static void setAuthOverride(String authorization) { + if (authorization == null || authorization.trim().isEmpty()) { + AUTH_OVERRIDE.remove(); + } else { + AUTH_OVERRIDE.set(authorization.trim()); + } + } + + public static void clearAuthOverride() { + AUTH_OVERRIDE.remove(); + } + @Override public List summary(OeeQueryBo queryBo) { log.info("OEE summary query: {}", queryBo); @@ -98,31 +168,141 @@ public class OeeReportServiceImpl implements IOeeReportService { List> byLine = new ArrayList<>(); for (String lineId : lineIds) { List events = fetchEvents(lineId, start, end); - Map byStopType = events.stream() - .collect(Collectors.groupingBy( - e -> e.getLossCategoryCode() == null ? "UNKNOWN" : e.getLossCategoryCode(), - Collectors.summingInt(e -> e.getDurationMin() == null ? 0 : e.getDurationMin()) - )); - List> losses = byStopType.entrySet().stream() - .map(e -> { + + // 过滤掉“计划停机”,它应该进入 planned_downtime_min,用于扣除负荷时间,而不算 7 大损失 + List effective = events.stream() + .filter(e -> !isPlannedDowntime(e)) + .collect(Collectors.toList()); + + int downtimeLossMin = effective.stream().mapToInt(e -> safeInt(e.getDurationMin())).sum(); + + Map> byCat = effective.stream() + .collect(Collectors.groupingBy(e -> { + LossCategory c = mapLossCategory(e); + return c.code; + })); + + // 追加第6类:速度下降(根据 Performance 损失折算分钟数) + BigDecimal slope = resolveIdealCycleMinPerUnit(lineId, queryBo); + PerformanceLoss perfLoss = calcPerformanceLossMinutes(lineId, start, end, slope); + int performanceLossMin = perfLoss.performanceLossMin; + + int totalLossMin = downtimeLossMin + performanceLossMin; + + List> losses = byCat.entrySet().stream() + .map(entry -> { + String catCode = entry.getKey(); + List es = entry.getValue(); + int lossMin = es.stream().mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int cnt = es.size(); + BigDecimal avg = cnt <= 0 ? BigDecimal.ZERO + : BigDecimal.valueOf(lossMin).divide(BigDecimal.valueOf(cnt), 2, RoundingMode.HALF_UP); + BigDecimal rate = totalLossMin <= 0 ? BigDecimal.ZERO + : BigDecimal.valueOf(lossMin).divide(BigDecimal.valueOf(totalLossMin), 6, RoundingMode.HALF_UP); + + LossCategory cat = LossCategory.byCode(catCode); Map m = new HashMap<>(); - m.put("lossCategoryCode", e.getKey()); - m.put("lossCategoryName", e.getKey()); - m.put("lossTimeMin", e.getValue()); + m.put("lossCategoryCode", cat.code); + m.put("lossCategoryName", cat.name); + m.put("lossTimeMin", lossMin); + m.put("lossTimeRate", rate); + m.put("count", cnt); + m.put("avgDurationMin", avg); return m; }) .collect(Collectors.toList()); + if (performanceLossMin > 0 || (slope != null && slope.compareTo(BigDecimal.ZERO) > 0)) { + Map m6 = new HashMap<>(); + m6.put("lossCategoryCode", "6"); + m6.put("lossCategoryName", "速度下降"); + m6.put("lossTimeMin", performanceLossMin); + m6.put("lossTimeRate", totalLossMin <= 0 ? BigDecimal.ZERO + : BigDecimal.valueOf(performanceLossMin).divide(BigDecimal.valueOf(totalLossMin), 6, RoundingMode.HALF_UP)); + m6.put("count", null); + m6.put("avgDurationMin", null); + // 额外信息:回归斜率、runTime/idealTime + m6.put("idealCycleTimeMinPerUnit", slope == null ? null : slope); + m6.put("runTimeMin", perfLoss.runTimeMin); + m6.put("idealTimeMin", perfLoss.idealTimeMin); + losses.add(m6); + } + + losses = losses.stream() + .sorted((a, b) -> Integer.compare((Integer) b.get("lossTimeMin"), (Integer) a.get("lossTimeMin"))) + .collect(Collectors.toList()); + + // TopN 原因(按 rawReasonName/remark 聚合) + int topN = queryBo.getTopN() == null || queryBo.getTopN() <= 0 ? 10 : queryBo.getTopN(); + Map> byReason = effective.stream() + .collect(Collectors.groupingBy(this::reasonKey)); + List> topReasons = byReason.entrySet().stream() + .map(entry -> { + List es = entry.getValue(); + int lossMin = es.stream().mapToInt(e -> safeInt(e.getDurationMin())).sum(); + Map m = new HashMap<>(); + m.put("reason", entry.getKey()); + m.put("lossTimeMin", lossMin); + m.put("count", es.size()); + return m; + }) + .sorted((a, b) -> Integer.compare((Integer) b.get("lossTimeMin"), (Integer) a.get("lossTimeMin"))) + .limit(topN) + .collect(Collectors.toList()); + Map lineBlock = new HashMap<>(); lineBlock.put("lineId", lineId); lineBlock.put("lineName", isAcidLine(lineId) ? "酸轧线" : (isGalvanizeLine(lineId) ? "镀锌一线" : lineId)); lineBlock.put("losses", losses); + lineBlock.put("topReasons", topReasons); byLine.add(lineBlock); } Map resp = new HashMap<>(); resp.put("byLine", byLine); - // topReasons:当前两套数据源只有 remark/stopType,先不做更细的 Pareto(后续可按 remark 做 TopN) + return resp; + } + + @Override + public Map theoryCycleRegression(OeeQueryBo queryBo) { + // 同步:直接向 WMS 请求线性回归结果,等待计算完成后再返回给前端 + log.info("OEE theoryCycleRegression query: {}", queryBo); + if (queryBo == null) { + queryBo = new OeeQueryBo(); + } + long t0 = System.currentTimeMillis(); + + // 直接同步调用 WMS 接口(通过 RestTemplate),不再走异步刷新逻辑 + Map data; + try { + data = fetchTheoryCycleRegressionFromWms(queryBo, null); + } catch (Exception e) { + log.warn("theoryCycleRegression sync call failed, queryBo={}", queryBo, e); + data = new HashMap<>(); + } + if (data == null) { + data = new HashMap<>(); + } + + // 计算元信息,并写入本地缓存,供其他地方(如 resolveIdealCycleMinPerUnit)复用 + boolean empty = isEmptyRegression(data); + Map meta = buildRegressionMeta( + empty ? "FAILED" : "SUCCESS", + empty ? "empty regression result" : null, + System.currentTimeMillis() - t0, + data + ); + String cacheKey = buildRegressionCacheKey(queryBo); + long now = System.currentTimeMillis(); + REGRESSION_CACHE.put(cacheKey, new CachedRegression(now, data, meta)); + + // 对前端保持与原来尽量一致的返回结构 + Map resp = new HashMap<>(); + resp.putAll(data); + resp.put("cacheHit", false); + resp.put("refreshing", false); + resp.put("cacheTime", now); + resp.put("meta", meta); return resp; } @@ -194,21 +374,17 @@ public class OeeReportServiceImpl implements IOeeReportService { * 对应 ReportController.getReportSummary(groupNo, shiftNo, startTime, endTime) */ private String buildGalvanizeSummaryUrl(OeeQueryBo queryBo) { - StringBuilder sb = new StringBuilder(); - sb.append(galvanizeLineBaseUrl).append("/api/report/summary"); - boolean hasParam = false; + String base = normalizeBaseUrl(galvanizeLineBaseUrl); + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(base + "/api/report/summary"); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - if (queryBo.getStartTime() != null) { - sb.append(hasParam ? "&" : "?") - .append("startTime=").append(queryBo.getStartTime().format(formatter)); - hasParam = true; + if (queryBo != null && queryBo.getStartTime() != null) { + builder.queryParam("startTime", queryBo.getStartTime().format(formatter)); } - if (queryBo.getEndTime() != null) { - sb.append(hasParam ? "&" : "?") - .append("endTime=").append(queryBo.getEndTime().format(formatter)); + if (queryBo != null && queryBo.getEndTime() != null) { + builder.queryParam("endTime", queryBo.getEndTime().format(formatter)); } // groupNo / shiftNo 暂不使用,后续需要可从 queryBo 中扩展字段再拼接 - return sb.toString(); + return builder.toUriString(); } private boolean isAcidLine(String lineId) { @@ -240,8 +416,10 @@ public class OeeReportServiceImpl implements IOeeReportService { PocketProductionStats stats = fetchPocketProductionStats(d, d); BigDecimal qualityRate = fetchCoilQualityRateByDay(d, lineId); List events = fetchPocketStoppageEvents(d, d); - int downtime = events.stream().mapToInt(e -> e.getDurationMin() == null ? 0 : e.getDurationMin()).sum(); - int loading = (int) ChronoUnit.MINUTES.between(d.atStartOfDay(), d.plusDays(1).atStartOfDay()); + int plannedDowntime = events.stream().filter(this::isPlannedDowntime).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int downtime = events.stream().filter(e -> !isPlannedDowntime(e)).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int planned = (int) ChronoUnit.MINUTES.between(d.atStartOfDay(), d.plusDays(1).atStartOfDay()); + int loading = Math.max(0, planned - plannedDowntime); int run = Math.max(0, loading - downtime); OeeLineSummaryVo.Daily day = new OeeLineSummaryVo.Daily(); @@ -249,20 +427,27 @@ public class OeeReportServiceImpl implements IOeeReportService { day.setLoadingTimeMin(loading); day.setDowntimeMin(downtime); day.setRunTimeMin(run); - day.setTotalOutput(stats.totalExitWeight); - BigDecimal good = nvl(stats.totalEntryWeight).multiply(qualityRate); - BigDecimal defect = nvl(stats.totalEntryWeight).subtract(good).max(BigDecimal.ZERO); + // 产量口径:使用出口重量作为“总产量”,良品量 = 出口重量 * 良品率,保证 goodOutput 不大于 totalOutput + BigDecimal total = nvl(stats.totalExitWeight); + day.setTotalOutput(total); + BigDecimal good = total.multiply(qualityRate); + BigDecimal defect = total.subtract(good).max(BigDecimal.ZERO); day.setGoodOutput(good); day.setDefectOutput(defect); day.setAvailability(calcRate(run, loading)); day.setQuality(qualityRate); - day.setPerformance(BigDecimal.ONE); - day.setOee(day.getAvailability().multiply(day.getQuality())); + BigDecimal ict = resolveIdealCycleMinPerUnit(lineId, null); + if (ict == null || ict.compareTo(BigDecimal.ZERO) <= 0) { + ict = idealCycleTimeMinPerUnitSy; + } + BigDecimal perf = calcPerformance(run, nvl(day.getTotalOutput()), ict); + day.setPerformance(perf); + day.setOee(day.getAvailability().multiply(day.getPerformance()).multiply(day.getQuality())); daily.add(day); - totalOutput = totalOutput.add(nvl(stats.totalEntryWeight)); + totalOutput = totalOutput.add(nvl(day.getTotalOutput())); goodOutput = goodOutput.add(good); defectOutput = defectOutput.add(defect); downtimeSum += downtime; @@ -278,10 +463,16 @@ public class OeeReportServiceImpl implements IOeeReportService { total.setGoodOutput(goodOutput); total.setDefectOutput(defectOutput); total.setAvailability(calcRate(runSum, loadingSum)); + // 汇总口径下的良品率 = 区间“良品总量 / 总产量”,同样基于出口重量 BigDecimal qualityRate = calcRate(goodOutput, totalOutput); total.setQuality(qualityRate); - total.setPerformance(BigDecimal.ONE); - total.setOee(total.getAvailability().multiply(total.getQuality())); + BigDecimal ict = resolveIdealCycleMinPerUnit(lineId, null); + if (ict == null || ict.compareTo(BigDecimal.ZERO) <= 0) { + ict = idealCycleTimeMinPerUnitSy; + } + BigDecimal perf = calcPerformance(runSum, nvl(totalOutput), ict); + total.setPerformance(perf); + total.setOee(total.getAvailability().multiply(total.getPerformance()).multiply(total.getQuality())); vo.setDaily(daily); vo.setTotal(total); @@ -305,8 +496,10 @@ public class OeeReportServiceImpl implements IOeeReportService { FizzReportSummary stats = fetchFizzReportSummary(d.atStartOfDay(), d.plusDays(1).atStartOfDay().minusSeconds(1)); BigDecimal qualityRate = fetchCoilQualityRateByDay(d, lineId); List events = fetchFizzStoppageEvents(d, d); - int downtime = events.stream().mapToInt(e -> e.getDurationMin() == null ? 0 : e.getDurationMin()).sum(); - int loading = (int) ChronoUnit.MINUTES.between(d.atStartOfDay(), d.plusDays(1).atStartOfDay()); + int plannedDowntime = events.stream().filter(this::isPlannedDowntime).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int downtime = events.stream().filter(e -> !isPlannedDowntime(e)).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int planned = (int) ChronoUnit.MINUTES.between(d.atStartOfDay(), d.plusDays(1).atStartOfDay()); + int loading = Math.max(0, planned - plannedDowntime); int run = Math.max(0, loading - downtime); OeeLineSummaryVo.Daily day = new OeeLineSummaryVo.Daily(); @@ -314,20 +507,27 @@ public class OeeReportServiceImpl implements IOeeReportService { day.setLoadingTimeMin(loading); day.setDowntimeMin(downtime); day.setRunTimeMin(run); - day.setTotalOutput(stats.totalActualWeight); - BigDecimal good = nvl(stats.totalEntryWeight).multiply(qualityRate); - BigDecimal defect = nvl(stats.totalEntryWeight).subtract(good).max(BigDecimal.ZERO); + // 产量口径:使用实际产出重量作为“总产量”,良品量 = 出口重量 * 良品率 + BigDecimal total = nvl(stats.totalActualWeight); + day.setTotalOutput(total); + BigDecimal good = total.multiply(qualityRate); + BigDecimal defect = total.subtract(good).max(BigDecimal.ZERO); day.setGoodOutput(good); day.setDefectOutput(defect); day.setAvailability(calcRate(run, loading)); day.setQuality(qualityRate); - day.setPerformance(BigDecimal.ONE); - day.setOee(day.getAvailability().multiply(day.getQuality())); + BigDecimal ict = resolveIdealCycleMinPerUnit(lineId, null); + if (ict == null || ict.compareTo(BigDecimal.ZERO) <= 0) { + ict = idealCycleTimeMinPerUnitDx1; + } + BigDecimal perf = calcPerformance(run, nvl(day.getTotalOutput()), ict); + day.setPerformance(perf); + day.setOee(day.getAvailability().multiply(day.getPerformance()).multiply(day.getQuality())); daily.add(day); - totalOutput = totalOutput.add(nvl(stats.totalEntryWeight)); + totalOutput = totalOutput.add(nvl(day.getTotalOutput())); goodOutput = goodOutput.add(good); defectOutput = defectOutput.add(defect); downtimeSum += downtime; @@ -345,8 +545,13 @@ public class OeeReportServiceImpl implements IOeeReportService { total.setAvailability(calcRate(runSum, loadingSum)); BigDecimal qualityRate = calcRate(goodOutput, totalOutput); total.setQuality(qualityRate); - total.setPerformance(BigDecimal.ONE); - total.setOee(total.getAvailability().multiply(total.getQuality())); + BigDecimal ict = resolveIdealCycleMinPerUnit(lineId, null); + if (ict == null || ict.compareTo(BigDecimal.ZERO) <= 0) { + ict = idealCycleTimeMinPerUnitDx1; + } + BigDecimal perf = calcPerformance(runSum, nvl(totalOutput), ict); + total.setPerformance(perf); + total.setOee(total.getAvailability().multiply(total.getPerformance()).multiply(total.getQuality())); vo.setDaily(daily); vo.setTotal(total); @@ -373,13 +578,12 @@ public class OeeReportServiceImpl implements IOeeReportService { String url = base + "/pocket/productionStatistics/summary" + "?startDate=" + startDate + "&endDate=" + endDate; - String json = restTemplate.getForObject(url, String.class); + String json = exchangeGet(url); JsonNode root = objectMapper.readTree(json); JsonNode data = root.get("data"); PocketProductionStats s = new PocketProductionStats(); s.totalExitWeight = toBigDecimal(data, "totalExitWeight"); s.totalEntryWeight = toBigDecimal(data, "totalEntryWeight"); - s.yieldRate = toBigDecimal(data, "yieldRate"); return s; } catch (Exception e) { log.warn("fetchPocketProductionStats failed: {}~{}", startDate, endDate, e); @@ -400,7 +604,7 @@ public class OeeReportServiceImpl implements IOeeReportService { + "?pageNum=1&pageSize=10000" + "&startDate=" + startDate + "&endDate=" + endDate; - String json = restTemplate.getForObject(url, String.class); + String json = exchangeGet(url); JsonNode root = objectMapper.readTree(json); JsonNode rows = root.get("rows"); if (rows == null || !rows.isArray()) return Collections.emptyList(); @@ -412,7 +616,10 @@ public class OeeReportServiceImpl implements IOeeReportService { e.setLineName("酸轧线"); e.setEventStartTime(toLocalDateTime(n, "startDate")); e.setEventEndTime(toLocalDateTime(n, "endDate")); - e.setDurationMin(toInt(n, "duration")); + // 源库 duration 为“秒”,这里统一换算成分钟(向上取整),与整体 OEE 口径保持一致 + Integer sec = toInt(n, "duration"); + int minutes = sec == null ? 0 : (int) Math.ceil(sec / 60.0); + e.setDurationMin(minutes); e.setRawReasonCode(toText(n, "stopType")); e.setRawReasonName(toText(n, "remark")); e.setLossCategoryCode(toText(n, "stopType")); @@ -437,12 +644,11 @@ public class OeeReportServiceImpl implements IOeeReportService { qb.setStartTime(startTime); qb.setEndTime(endTime); String url = buildGalvanizeSummaryUrl(qb); - String json = restTemplate.getForObject(url, String.class); + String json = exchangeGet(url); JsonNode root = objectMapper.readTree(json); FizzReportSummary s = new FizzReportSummary(); s.totalActualWeight = toBigDecimal(root, "totalActualWeight"); s.totalEntryWeight = toBigDecimal(root, "totalEntryWeight"); - s.yieldRate = toBigDecimal(root, "yieldRate"); return s; } catch (Exception e) { log.warn("fetchFizzReportSummary failed: {}~{}", startTime, endTime, e); @@ -451,9 +657,11 @@ public class OeeReportServiceImpl implements IOeeReportService { } /** - * 使用本地 WMS 钢卷物料接口,根据 quality_status + actual_warehouse_id 统计某一天某条线的良品率: - * - 分母:当天该逻辑库区的钢卷数量(按创建时间 byCreateTimeStart/End + actualWarehouseId) - * - 分子:quality_status=0(正常)的钢卷数量 + * 使用本地 WMS 钢卷物料接口,根据“质量等级”统计某一天某条线的良品率: + * - 质量字段:quality_status,取值包括 A/B/...、C+、C、C-、D+、D、D- 等 + * - 规则:C+、C、C-、D+、D、D- 视为次品,其余全部视为良品 + * - 分母:当天该实际库区的钢卷数量(按创建时间 byCreateTimeStart/End + actualWarehouseId) + * - 分子:分母减去“次品数量”(onlyScrap=true,对应 C/D 整个等级) * * lineId -> 库区映射: * - 酸轧线:actualWarehouseId = 1988150099140866050 @@ -477,25 +685,34 @@ public class OeeReportServiceImpl implements IOeeReportService { String end = day.plusDays(1).atStartOfDay().minusSeconds(1) .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - // 全部钢卷数量 - String allUrl = base + "/wms/materialCoil/list" - + "?pageNum=1&pageSize=1" - + "&byCreateTimeStart=" + start - + "&byCreateTimeEnd=" + end - + "&actualWarehouseId=" + actualWarehouseId; - String allJson = restTemplate.getForObject(allUrl, String.class); + // 全部钢卷数量 - 使用 UriComponentsBuilder 正确构建 URL 并处理编码 + String allUrl = UriComponentsBuilder.fromHttpUrl(base + "/wms/materialCoil/list") + .queryParam("pageNum", 1) + .queryParam("pageSize", 1) + .queryParam("byCreateTimeStart", start) + .queryParam("byCreateTimeEnd", end) + .queryParam("actualWarehouseId", actualWarehouseId) + .toUriString(); + String allJson = exchangeGet(allUrl); JsonNode allRoot = objectMapper.readTree(allJson); long totalAll = allRoot.path("total").asLong(0); if (totalAll <= 0) { return BigDecimal.ONE; } - // 良品钢卷数量:quality_status = '0' - String goodUrl = allUrl + "&qualityStatus=0"; - String goodJson = restTemplate.getForObject(goodUrl, String.class); + // 次品钢卷数量:onlyScrap = true => quality_status IN ('C+','C','C-','D+','D','D-') + String goodUrl = UriComponentsBuilder.fromHttpUrl(base + "/wms/materialCoil/list") + .queryParam("pageNum", 1) + .queryParam("pageSize", 1) + .queryParam("byCreateTimeStart", start) + .queryParam("byCreateTimeEnd", end) + .queryParam("actualWarehouseId", actualWarehouseId) + .queryParam("onlyScrap", "true") + .toUriString(); + String goodJson = exchangeGet(goodUrl); JsonNode goodRoot = objectMapper.readTree(goodJson); - long totalGood = goodRoot.path("total").asLong(0); - + long totalScrap = goodRoot.path("total").asLong(0); + long totalGood = Math.max(0, totalAll - totalScrap); return calcRate(BigDecimal.valueOf(totalGood), BigDecimal.valueOf(totalAll)); } catch (Exception e) { log.warn("fetchCoilQualityRateByDay failed for day {}: {}", day, e.getMessage()); @@ -514,19 +731,29 @@ public class OeeReportServiceImpl implements IOeeReportService { String end = day.plusDays(1).atStartOfDay().minusSeconds(1) .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - String allUrl = base + "/wms/materialCoil/list" - + "?pageNum=1&pageSize=1" - + "&byCreateTimeStart=" + start - + "&byCreateTimeEnd=" + end; - String allJson = restTemplate.getForObject(allUrl, String.class); + // 全部钢卷数量 - 使用 UriComponentsBuilder 正确构建 URL 并处理编码 + String allUrl = UriComponentsBuilder.fromHttpUrl(base + "/wms/materialCoil/list") + .queryParam("pageNum", 1) + .queryParam("pageSize", 1) + .queryParam("byCreateTimeStart", start) + .queryParam("byCreateTimeEnd", end) + .toUriString(); + String allJson = exchangeGet(allUrl); JsonNode allRoot = objectMapper.readTree(allJson); long totalAll = allRoot.path("total").asLong(0); if (totalAll <= 0) { return BigDecimal.ONE; } - String goodUrl = allUrl + "&qualityStatus=0"; - String goodJson = restTemplate.getForObject(goodUrl, String.class); + // 良品钢卷数量:quality_status = '0' + String goodUrl = UriComponentsBuilder.fromHttpUrl(base + "/wms/materialCoil/list") + .queryParam("pageNum", 1) + .queryParam("pageSize", 1) + .queryParam("byCreateTimeStart", start) + .queryParam("byCreateTimeEnd", end) + .queryParam("qualityStatus", "0") + .toUriString(); + String goodJson = exchangeGet(goodUrl); JsonNode goodRoot = objectMapper.readTree(goodJson); long totalGood = goodRoot.path("total").asLong(0); @@ -549,7 +776,7 @@ public class OeeReportServiceImpl implements IOeeReportService { body.put("startDate", startDate.toString()); body.put("endDate", endDate.toString()); - String json = restTemplate.postForObject(url, body, String.class); + String json = exchangePost(url, body); JsonNode root = objectMapper.readTree(json); JsonNode data = root.get("data"); if (data == null || !data.isArray()) return Collections.emptyList(); @@ -561,8 +788,10 @@ public class OeeReportServiceImpl implements IOeeReportService { e.setLineName("镀锌一线"); e.setEventStartTime(toLocalDateTime(n, "startDate")); e.setEventEndTime(toLocalDateTime(n, "endDate")); - // duration 在 Fizz 为 BigDecimal,字段名 duration - e.setDurationMin(toBigDecimal(n, "duration").intValue()); + // duration 在 Fizz 为 BigDecimal,单位为“秒”,这里统一换算成分钟(向上取整) + BigDecimal sec = toBigDecimal(n, "duration"); + int minutes = sec == null ? 0 : (int) Math.ceil(sec.doubleValue() / 60.0); + e.setDurationMin(minutes); e.setRawReasonCode(toText(n, "stopType")); e.setRawReasonName(toText(n, "remark")); e.setLossCategoryCode(toText(n, "stopType")); @@ -587,17 +816,80 @@ public class OeeReportServiceImpl implements IOeeReportService { return s; } + /** + * 透传当前请求的 Authorization(Bearer Token)到下游服务调用。 + */ + private HttpHeaders buildForwardHeaders() { + HttpHeaders headers = new HttpHeaders(); + try { + String override = AUTH_OVERRIDE.get(); + if (override != null && !override.trim().isEmpty()) { + headers.set(HEADER_AUTHORIZATION, override.trim()); + return headers; + } + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return headers; + } + HttpServletRequest req = attrs.getRequest(); + if (req == null) { + return headers; + } + String auth = req.getHeader(HEADER_AUTHORIZATION); + if (auth != null && !auth.trim().isEmpty()) { + headers.set(HEADER_AUTHORIZATION, auth); + } else { + // 兜底:若上游只传 token,则按 Bearer 拼接(兼容性处理) + String token = req.getHeader("token"); + if (token != null && !token.trim().isEmpty()) { + headers.set(HEADER_AUTHORIZATION, HEADER_BEARER_PREFIX + token.trim()); + } + } + } catch (Exception ignore) { + } + return headers; + } + + /** + * 用于异步线程:显式带入上游 Authorization,避免 RequestContextHolder 不可用导致无法透传 token。 + */ + private HttpHeaders buildForwardHeaders(String authorization) { + HttpHeaders headers = new HttpHeaders(); + if (authorization != null && !authorization.trim().isEmpty()) { + headers.set(HEADER_AUTHORIZATION, authorization.trim()); + } + return headers; + } + + private String exchangeGet(String url) { + return exchangeGet(url, null); + } + + private String exchangeGet(String url, String authorization) { + HttpHeaders headers = (authorization == null) ? buildForwardHeaders() : buildForwardHeaders(authorization); + HttpEntity entity = new HttpEntity<>(headers); + // 注意:url 可能已经包含 %20 等编码字符。使用 URI 形态调用可避免 RestTemplate 再次对 % 进行转义导致 %2520。 + URI uri = URI.create(url); + return restTemplate.exchange(uri, HttpMethod.GET, entity, String.class).getBody(); + } + + private String exchangePost(String url, Object body) { + HttpEntity entity = new HttpEntity<>(body, buildForwardHeaders()); + URI uri = URI.create(url); + return restTemplate.exchange(uri, HttpMethod.POST, entity, String.class).getBody(); + } + private BigDecimal calcRate(int numerator, int denominator) { if (denominator <= 0) return BigDecimal.ZERO; return BigDecimal.valueOf(numerator) - .divide(BigDecimal.valueOf(denominator), 6, BigDecimal.ROUND_HALF_UP); + .divide(BigDecimal.valueOf(denominator), 6, RoundingMode.HALF_UP); } private BigDecimal calcRate(BigDecimal numerator, BigDecimal denominator) { if (denominator == null || denominator.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } - return nvl(numerator).divide(denominator, 6, BigDecimal.ROUND_HALF_UP); + return nvl(numerator).divide(denominator, 6, RoundingMode.HALF_UP); } private BigDecimal nvl(BigDecimal v) { @@ -605,21 +897,416 @@ public class OeeReportServiceImpl implements IOeeReportService { } /** - * 将可能是 0~1 或 0~100 的比率统一规范到 0~1 区间。 + * 性能稼动率(Performance) + * P = ideal_time_min / run_time_min = (ideal_cycle_time * total_output) / run_time_min + * + * 注意:如果 idealCycleTimeMinPerUnit 未配置(<=0),则退化为 1(不让报表全为 0)。 */ - private BigDecimal normalizeRate(BigDecimal raw) { - BigDecimal r = nvl(raw); - if (r.compareTo(BigDecimal.ONE) > 0 && r.compareTo(BigDecimal.valueOf(100)) <= 0) { - r = r.divide(BigDecimal.valueOf(100), 6, BigDecimal.ROUND_HALF_UP); + private BigDecimal calcPerformance(int runTimeMin, BigDecimal totalOutput, BigDecimal idealCycleTimeMinPerUnit) { + if (runTimeMin <= 0) { + return BigDecimal.ZERO; } - if (r.compareTo(BigDecimal.ZERO) < 0) { - r = BigDecimal.ZERO; - } else if (r.compareTo(BigDecimal.ONE) > 0) { - r = BigDecimal.ONE; + BigDecimal ict = nvl(idealCycleTimeMinPerUnit); + if (ict.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ONE; } + BigDecimal idealTimeMin = ict.multiply(nvl(totalOutput)); // min + return idealTimeMin.divide(BigDecimal.valueOf(runTimeMin), 6, RoundingMode.HALF_UP); + } + + /** + * 从 WMS 回归结果中解析“分钟/吨”的斜率作为理论节拍(优先用近6个月默认窗口;如 queryBo 带 startTime/endTime,则以其为窗口)。 + */ + private BigDecimal resolveIdealCycleMinPerUnit(String lineId, OeeQueryBo queryBo) { + try { + OeeQueryBo qb = queryBo == null ? new OeeQueryBo() : queryBo; + String cacheKey = buildRegressionCacheKey(qb); + CachedRegression cached = REGRESSION_CACHE.get(cacheKey); + long now = System.currentTimeMillis(); + boolean expired = cached == null || (now - cached.ts) > REGRESSION_CACHE_TTL_MS; + if (expired) { + long t0 = System.currentTimeMillis(); + Map m = fetchTheoryCycleRegressionFromWmsSafe(qb, captureAuthorization()); + Map meta = buildRegressionMeta(isEmptyRegression(m) ? "FAILED" : "SUCCESS", + isEmptyRegression(m) ? "empty regression result" : null, System.currentTimeMillis() - t0, m); + REGRESSION_CACHE.put(cacheKey, new CachedRegression(System.currentTimeMillis(), m, meta)); + cached = REGRESSION_CACHE.get(cacheKey); + } + if (cached == null || cached.data == null) return null; + + Object linesObj = cached.data.get("lines"); + if (!(linesObj instanceof List)) return null; + List lines = (List) linesObj; + for (Object o : lines) { + if (!(o instanceof Map)) continue; + Map lm = (Map) o; + Object lid = lm.get("lineId"); + if (lid == null) continue; + if (!Objects.equals(lid.toString().trim().toUpperCase(), lineId.trim().toUpperCase())) continue; + Object slope = lm.get("slopeMinPerTon"); + if (slope == null) return null; + return new BigDecimal(slope.toString()); + } + return null; + } catch (Exception e) { + log.warn("resolveIdealCycleMinPerUnit failed for lineId={}", lineId, e); + return null; + } + } + + private String buildRegressionCacheKey(OeeQueryBo queryBo) { + if (queryBo == null) return "DEFAULT"; + String s = queryBo.getStartTime() == null ? "" : queryBo.getStartTime().toString(); + String e = queryBo.getEndTime() == null ? "" : queryBo.getEndTime().toString(); + return s + "|" + e; + } + + /** + * 安全地从 WMS 拉取回归结果,异常时返回空 Map,避免影响主流程。 + */ + private Map fetchTheoryCycleRegressionFromWmsSafe(OeeQueryBo queryBo, String authorization) { + try { + Map m = fetchTheoryCycleRegressionFromWms(queryBo, authorization); + return (m == null) ? new HashMap<>() : m; + } catch (ResourceAccessException e) { + String msg = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage(); + log.warn("fetchTheoryCycleRegressionFromWmsSafe timeout, queryBo={}, msg={}", queryBo, msg); + return new HashMap<>(); + } catch (Exception e) { + log.warn("fetchTheoryCycleRegressionFromWmsSafe failed, queryBo={}", queryBo, e); + return new HashMap<>(); + } + } + + /** + * 从当前请求上下文中提取 Authorization(或 token 拼接为 Bearer),供异步/安全调用使用。 + */ + private String captureAuthorization() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null || attrs.getRequest() == null) { + return null; + } + HttpServletRequest req = attrs.getRequest(); + String auth = req.getHeader(HEADER_AUTHORIZATION); + if (auth != null && !auth.trim().isEmpty()) { + return auth.trim(); + } + String token = req.getHeader("token"); + if (token != null && !token.trim().isEmpty()) { + return HEADER_BEARER_PREFIX + token.trim(); + } + } catch (Exception ignore) { + } + return null; + } + + private Map getTheoryCycleRegressionCached(OeeQueryBo queryBo, boolean triggerRefresh) { + if (queryBo == null) { + queryBo = new OeeQueryBo(); + } + String cacheKey = buildRegressionCacheKey(queryBo); + CachedRegression cached = REGRESSION_CACHE.get(cacheKey); + long now = System.currentTimeMillis(); + + boolean expired = cached == null || (now - cached.ts) > REGRESSION_CACHE_TTL_MS; + if (expired && triggerRefresh) { + triggerRegressionRefreshAsync(queryBo, cacheKey); + } + + Map resp = new HashMap<>(); + if (cached != null && cached.data != null && !cached.data.isEmpty()) { + resp.putAll(cached.data); + } + resp.put("cacheHit", cached != null && !expired); + resp.put("refreshing", Boolean.TRUE.equals(REGRESSION_REFRESHING.get(cacheKey))); + resp.put("cacheTime", cached == null ? null : cached.ts); + if (cached != null && cached.meta != null) { + resp.put("meta", cached.meta); + } + return resp; + } + + private void triggerRegressionRefreshAsync(OeeQueryBo queryBo, String cacheKey) { + if (Boolean.TRUE.equals(REGRESSION_REFRESHING.putIfAbsent(cacheKey, Boolean.TRUE))) { + return; + } + Long lastFail = REGRESSION_LAST_FAIL_TS.get(cacheKey); + long now = System.currentTimeMillis(); + if (lastFail != null && (now - lastFail) < REGRESSION_FAIL_BACKOFF_MS) { + REGRESSION_REFRESHING.remove(cacheKey); + return; + } + // 捕获当前请求的 Authorization(异步线程拿不到 RequestContextHolder) + String auth = null; + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null && attrs.getRequest() != null) { + auth = attrs.getRequest().getHeader(HEADER_AUTHORIZATION); + if ((auth == null || auth.trim().isEmpty())) { + String token = attrs.getRequest().getHeader("token"); + if (token != null && !token.trim().isEmpty()) { + auth = HEADER_BEARER_PREFIX + token.trim(); + } + } + } + } catch (Exception ignore) { + } + // 复制一份 queryBo,避免异步读取时对象被外部修改 + OeeQueryBo qb = new OeeQueryBo(); + qb.setStartTime(queryBo == null ? null : queryBo.getStartTime()); + qb.setEndTime(queryBo == null ? null : queryBo.getEndTime()); + + final String finalAuth = auth; + REGRESSION_EXECUTOR.submit(() -> { + long t0 = System.currentTimeMillis(); + log.info("theoryCycleRegression async refresh started, key={}, window={}~{}", cacheKey, qb.getStartTime(), qb.getEndTime()); + try { + Map m = fetchTheoryCycleRegressionFromWms(qb, finalAuth); + if (isEmptyRegression(m)) { + throw new IllegalStateException("empty regression result"); + } + Map meta = buildRegressionMeta("SUCCESS", null, System.currentTimeMillis() - t0, m); + REGRESSION_CACHE.put(cacheKey, new CachedRegression(System.currentTimeMillis(), m, meta)); + log.info("theoryCycleRegression async refresh finished: success, costMs={}, summary={}", (System.currentTimeMillis() - t0), summarizeRegression(m)); + } catch (ResourceAccessException e) { + // Read timed out 这类不要刷堆栈,避免噪音 + String msg = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage(); + Map meta = buildRegressionMeta("FAILED", msg, System.currentTimeMillis() - t0, null); + CachedRegression prev = REGRESSION_CACHE.get(cacheKey); + Map keep = prev == null ? new HashMap<>() : prev.data; + REGRESSION_CACHE.put(cacheKey, new CachedRegression(System.currentTimeMillis(), keep, meta)); + REGRESSION_LAST_FAIL_TS.put(cacheKey, System.currentTimeMillis()); + log.warn("theoryCycleRegression async refresh finished: failed(timeout), costMs={}, msg={}", (System.currentTimeMillis() - t0), msg); + } catch (Exception e) { + String msg = e.getMessage(); + Map meta = buildRegressionMeta("FAILED", msg, System.currentTimeMillis() - t0, null); + CachedRegression prev = REGRESSION_CACHE.get(cacheKey); + Map keep = prev == null ? new HashMap<>() : prev.data; + REGRESSION_CACHE.put(cacheKey, new CachedRegression(System.currentTimeMillis(), keep, meta)); + REGRESSION_LAST_FAIL_TS.put(cacheKey, System.currentTimeMillis()); + log.warn("theoryCycleRegression async refresh finished: failed, costMs={}, msg={}", (System.currentTimeMillis() - t0), msg); + } finally { + REGRESSION_REFRESHING.remove(cacheKey); + } + }); + } + + private Map buildRegressionMeta(String status, String errorMsg, long costMs, Map data) { + Map meta = new HashMap<>(); + meta.put("status", status); + meta.put("costMs", costMs); + meta.put("errorMsg", errorMsg); + meta.put("updatedAt", System.currentTimeMillis()); + meta.put("summary", summarizeRegression(data)); + return meta; + } + + private Map summarizeRegression(Map data) { + Map s = new HashMap<>(); + if (data == null) return s; + Object linesObj = data.get("lines"); + if (!(linesObj instanceof List)) return s; + List lines = (List) linesObj; + for (Object o : lines) { + if (!(o instanceof Map)) continue; + Map lm = (Map) o; + Object lineId = lm.get("lineId"); + if (lineId == null) continue; + Map one = new HashMap<>(); + one.put("slopeMinPerTon", lm.get("slopeMinPerTon")); + one.put("r2", lm.get("r2")); + one.put("sampleCount", lm.get("sampleCount")); + s.put(lineId.toString(), one); + } + return s; + } + + private Map fetchTheoryCycleRegressionFromWms(OeeQueryBo queryBo, String authorization) { + String base = normalizeBaseUrl(wmsBaseUrl); + String url = base + "/wms/coilPendingAction/theoryCycle/regression"; + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); + DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + if (queryBo != null && queryBo.getStartTime() != null) { + builder.queryParam("startTime", queryBo.getStartTime().format(f)); + } + if (queryBo != null && queryBo.getEndTime() != null) { + builder.queryParam("endTime", queryBo.getEndTime().format(f)); + } + // 默认不拉取 points,避免返回体过大导致连接写入中断 + builder.queryParam("includePoints", "false"); + builder.queryParam("maxPoints", "2000"); + String json = exchangeGetLongRead(builder.toUriString(), authorization); + if (json == null || json.trim().isEmpty()) { + return new HashMap<>(); + } + try { + JsonNode root = objectMapper.readTree(json); + JsonNode data = root.has("data") ? root.get("data") : root; + @SuppressWarnings("unchecked") + Map m = objectMapper.convertValue(data, Map.class); + return m == null ? new HashMap<>() : m; + } catch (Exception e) { + throw new IllegalStateException("parse regression response failed", e); + } + } + + private String exchangeGetLongRead(String url, String authorization) { + HttpHeaders headers = (authorization == null) ? buildForwardHeaders() : buildForwardHeaders(authorization); + HttpEntity entity = new HttpEntity<>(headers); + URI uri = URI.create(url); + return longReadRestTemplate.exchange(uri, HttpMethod.GET, entity, String.class).getBody(); + } + + private RestTemplate buildLongReadRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(30_000); + factory.setReadTimeout(300_000); // 5 分钟 + return new RestTemplate(factory); + } + + private boolean isEmptyRegression(Map m) { + if (m == null || m.isEmpty()) return true; + Object linesObj = m.get("lines"); + if (!(linesObj instanceof List)) return true; + return ((List) linesObj).isEmpty(); + } + + private PerformanceLoss calcPerformanceLossMinutes(String lineId, LocalDate start, LocalDate end, BigDecimal idealCycleTimeMinPerUnit) { + PerformanceLoss r = new PerformanceLoss(); + if (idealCycleTimeMinPerUnit == null || idealCycleTimeMinPerUnit.compareTo(BigDecimal.ZERO) <= 0) { + return r; + } + // 复用 summary 的聚合规则:runTimeSum + totalOutputSum + int runSum = 0; + BigDecimal outSum = BigDecimal.ZERO; + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + int planned = (int) ChronoUnit.MINUTES.between(d.atStartOfDay(), d.plusDays(1).atStartOfDay()); + List events = fetchEvents(lineId, d, d); + int plannedDowntime = events.stream().filter(this::isPlannedDowntime).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int downtime = events.stream().filter(e -> !isPlannedDowntime(e)).mapToInt(e -> safeInt(e.getDurationMin())).sum(); + int loading = Math.max(0, planned - plannedDowntime); + int run = Math.max(0, loading - downtime); + runSum += run; + + if (isAcidLine(lineId)) { + PocketProductionStats stats = fetchPocketProductionStats(d, d); + outSum = outSum.add(nvl(stats.totalExitWeight)); + } else if (isGalvanizeLine(lineId)) { + FizzReportSummary stats = fetchFizzReportSummary(d.atStartOfDay(), d.plusDays(1).atStartOfDay().minusSeconds(1)); + outSum = outSum.add(nvl(stats.totalActualWeight)); + } + } + + BigDecimal idealTimeMin = idealCycleTimeMinPerUnit.multiply(nvl(outSum)); + BigDecimal runTimeMin = BigDecimal.valueOf(runSum); + BigDecimal loss = runTimeMin.subtract(idealTimeMin); + int lossMin = loss.compareTo(BigDecimal.ZERO) <= 0 ? 0 : loss.setScale(0, RoundingMode.HALF_UP).intValue(); + + r.runTimeMin = runSum; + r.idealTimeMin = idealTimeMin.setScale(2, RoundingMode.HALF_UP); + r.performanceLossMin = lossMin; return r; } + private static class CachedRegression { + final long ts; + final Map data; + final Map meta; + + private CachedRegression(long ts, Map data, Map meta) { + this.ts = ts; + this.data = data; + this.meta = meta; + } + } + + private static class PerformanceLoss { + int runTimeMin = 0; + BigDecimal idealTimeMin = BigDecimal.ZERO; + int performanceLossMin = 0; + } + + private int safeInt(Integer v) { + return v == null ? 0 : v; + } + + private boolean isPlannedDowntime(OeeEventVo e) { + String s = (e == null) ? null : (e.getRawReasonName() != null ? e.getRawReasonName() : e.getRemark()); + if (s == null) return false; + String t = s.trim(); + return t.contains("计划停机"); + } + + private String reasonKey(OeeEventVo e) { + if (e == null) return "UNKNOWN"; + String name = e.getRawReasonName(); + if (name == null || name.trim().isEmpty()) { + name = e.getRemark(); + } + if (name == null || name.trim().isEmpty()) { + name = e.getRawReasonCode(); + } + return (name == null || name.trim().isEmpty()) ? "UNKNOWN" : name.trim(); + } + + private LossCategory mapLossCategory(OeeEventVo e) { + String reason = reasonKey(e); + // 计划停机不算损失(在上层已过滤),这里兜底 + if (reason.contains("计划停机")) { + return LossCategory.UNKNOWN; + } + // 换辊:更接近“刀具交换/换辊换刀” + if (reason.contains("换辊")) { + return LossCategory.TOOL_CHANGE; + } + // 故障类 + if (reason.contains("机械故障") || reason.contains("电气故障") || reason.contains("计算机") || reason.contains("网络")) { + return LossCategory.BREAKDOWN; + } + // 来料缺陷:先归到“短暂停机/待料” + if (reason.contains("来料缺陷")) { + return LossCategory.MINOR_STOPS; + } + // 外部干扰:待料、停电等 + if (reason.contains("外部干扰") || reason.contains("待料") || reason.contains("停电")) { + return LossCategory.MINOR_STOPS; + } + // 设备检修:通常更接近计划性停机;若你们口径上算停机损失,可调整为 BREAKDOWN / SETUP + if (reason.contains("设备检修") || reason.contains("检修")) { + return LossCategory.SETUP_ADJUSTMENT; + } + // 其他 + if (reason.contains("其他")) { + return LossCategory.UNKNOWN; + } + return LossCategory.UNKNOWN; + } + + private enum LossCategory { + BREAKDOWN("1", "故障"), + SETUP_ADJUSTMENT("2", "换模换线/调整"), + TOOL_CHANGE("3", "刀具交换"), + WARM_UP("4", "暖机"), + MINOR_STOPS("5", "空转/短暂停机"), + UNKNOWN("UNKNOWN", "未分类"); + + final String code; + final String name; + + LossCategory(String code, String name) { + this.code = code; + this.name = name; + } + + static LossCategory byCode(String code) { + for (LossCategory c : values()) { + if (Objects.equals(c.code, code)) return c; + } + return UNKNOWN; + } + } + private BigDecimal toBigDecimal(JsonNode node, String field) { if (node == null) return BigDecimal.ZERO; JsonNode v = node.get(field); @@ -672,13 +1359,11 @@ public class OeeReportServiceImpl implements IOeeReportService { private static class PocketProductionStats { BigDecimal totalEntryWeight = BigDecimal.ZERO; BigDecimal totalExitWeight = BigDecimal.ZERO; - BigDecimal yieldRate = BigDecimal.ZERO; } private static class FizzReportSummary { BigDecimal totalEntryWeight = BigDecimal.ZERO; BigDecimal totalActualWeight = BigDecimal.ZERO; - BigDecimal yieldRate = BigDecimal.ZERO; } } diff --git a/klp-da/src/main/java/com/klp/da/service/impl/OeeSummaryJobServiceImpl.java b/klp-da/src/main/java/com/klp/da/service/impl/OeeSummaryJobServiceImpl.java new file mode 100644 index 00000000..1021aede --- /dev/null +++ b/klp-da/src/main/java/com/klp/da/service/impl/OeeSummaryJobServiceImpl.java @@ -0,0 +1,155 @@ +package com.klp.da.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.klp.da.domain.bo.OeeQueryBo; +import com.klp.da.domain.vo.OeeLineSummaryVo; +import com.klp.da.service.IOeeReportService; +import com.klp.da.service.OeeSummaryJobService; +import com.klp.framework.websocket.TypeWebSocketUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OeeSummaryJobServiceImpl implements OeeSummaryJobService { + + public static final String WS_TYPE_PREFIX = "oee_summary:"; + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r); + t.setName("oee-summary-job"); + t.setDaemon(true); + return t; + }); + + private final IOeeReportService oeeReportService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Map createAndStart(OeeQueryBo queryBo) { + String jobId = UUID.randomUUID().toString().replace("-", ""); + String wsType = WS_TYPE_PREFIX + jobId; + + String auth = captureAuthorization(); + log.info("[OEE][summary][job] created jobId={}, wsType={}, window={}~{}, lines={}, authPresent={}", + jobId, + wsType, + queryBo == null ? null : queryBo.getStartDate(), + queryBo == null ? null : queryBo.getEndDate(), + queryBo == null ? null : queryBo.getLineIds(), + auth != null && !auth.trim().isEmpty()); + + push(jobId, wsType, "running", 5, "任务已创建,准备生成汇总…", null, null); + + OeeQueryBo qb = copyQueryBo(queryBo); + EXECUTOR.submit(() -> run(jobId, wsType, qb, auth)); + + Map resp = new HashMap<>(); + resp.put("jobId", jobId); + resp.put("wsType", wsType); + return resp; + } + + private void run(String jobId, String wsType, OeeQueryBo queryBo, String authorization) { + long t0 = System.currentTimeMillis(); + try { + System.out.println("[OEE][summary][job] start jobId=" + jobId + ", wsType=" + wsType + + ", thread=" + Thread.currentThread().getName() + + ", authPresent=" + (authorization != null && !authorization.trim().isEmpty())); + log.info("[OEE][summary][job] start jobId={}, wsType={}, thread={}, authPresent={}", + jobId, wsType, Thread.currentThread().getName(), authorization != null && !authorization.trim().isEmpty()); + push(jobId, wsType, "running", 20, "正在聚合 KPI 与趋势…", null, null); + OeeReportServiceImpl.setAuthOverride(authorization); + List lines = oeeReportService.summary(queryBo); + push(jobId, wsType, "running", 90, "正在整理汇总结果…", null, null); + + Map data = new HashMap<>(); + data.put("lines", lines); + + push(jobId, wsType, "success", 100, "汇总生成完成", data, null); + System.out.println("[OEE][summary][job] success jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0) + + ", lineCount=" + (lines == null ? 0 : lines.size())); + log.info("[OEE][summary][job] success jobId={}, costMs={}, lineCount={}", + jobId, (System.currentTimeMillis() - t0), lines == null ? 0 : lines.size()); + } catch (Exception e) { + log.warn("summary job failed, jobId={}", jobId, e); + push(jobId, wsType, "failed", 100, "汇总生成失败", null, e.getMessage()); + System.out.println("[OEE][summary][job] failed jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0) + + ", msg=" + e.getMessage()); + log.warn("[OEE][summary][job] failed jobId={}, costMs={}, msg={}", + jobId, (System.currentTimeMillis() - t0), e.getMessage()); + } finally { + OeeReportServiceImpl.clearAuthOverride(); + System.out.println("[OEE][summary][job] end jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0)); + log.info("[OEE][summary][job] end jobId={}, costMs={}", jobId, (System.currentTimeMillis() - t0)); + } + } + + private void push(String jobId, String wsType, String status, int progress, String text, + Map data, String errorMsg) { + try { + Map msg = new HashMap<>(); + msg.put("jobId", jobId); + msg.put("status", status); + msg.put("progress", progress); + msg.put("text", text); + if (data != null) { + msg.put("data", data); + } + if (errorMsg != null && !errorMsg.trim().isEmpty()) { + msg.put("errorMsg", errorMsg); + } + TypeWebSocketUtil.sendToType(wsType, objectMapper.writeValueAsString(msg)); + } catch (Exception ignore) { + } + } + + private OeeQueryBo copyQueryBo(OeeQueryBo queryBo) { + OeeQueryBo qb = new OeeQueryBo(); + if (queryBo == null) { + return qb; + } + qb.setStartDate(queryBo.getStartDate()); + qb.setEndDate(queryBo.getEndDate()); + qb.setLineIds(queryBo.getLineIds()); + qb.setStartTime(queryBo.getStartTime()); + qb.setEndTime(queryBo.getEndTime()); + qb.setTopN(queryBo.getTopN()); + qb.setLossCategoryCode(queryBo.getLossCategoryCode()); + qb.setKeyword(queryBo.getKeyword()); + return qb; + } + + private String captureAuthorization() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null || attrs.getRequest() == null) { + return null; + } + HttpServletRequest req = attrs.getRequest(); + String auth = req.getHeader("Authorization"); + if (auth != null && !auth.trim().isEmpty()) { + return auth.trim(); + } + String token = req.getHeader("token"); + if (token != null && !token.trim().isEmpty()) { + return "Bearer " + token.trim(); + } + } catch (Exception ignore) { + } + return null; + } +} + diff --git a/klp-da/src/main/java/com/klp/da/service/impl/OeeTheoryCycleJobServiceImpl.java b/klp-da/src/main/java/com/klp/da/service/impl/OeeTheoryCycleJobServiceImpl.java new file mode 100644 index 00000000..172411b2 --- /dev/null +++ b/klp-da/src/main/java/com/klp/da/service/impl/OeeTheoryCycleJobServiceImpl.java @@ -0,0 +1,182 @@ +package com.klp.da.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.klp.da.domain.bo.OeeQueryBo; +import com.klp.da.service.IOeeReportService; +import com.klp.da.service.OeeTheoryCycleJobService; +import com.klp.framework.websocket.TypeWebSocketUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OeeTheoryCycleJobServiceImpl implements OeeTheoryCycleJobService { + + /** + * 推送 type 前缀:前端用 ws://host/websocket?type=oee_theory_cycle_regression:{jobId} 订阅。 + */ + public static final String WS_TYPE_PREFIX = "oee_theory_cycle_regression:"; + + private static final long TTL_MS = 10 * 60_000L; + private static final ConcurrentHashMap JOBS = new ConcurrentHashMap<>(); + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r); + t.setName("oee-theory-cycle-job"); + t.setDaemon(true); + return t; + }); + + private final IOeeReportService oeeReportService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Map createAndStart(OeeQueryBo queryBo) { + String jobId = UUID.randomUUID().toString().replace("-", ""); + String wsType = WS_TYPE_PREFIX + jobId; + + JobState st = new JobState(); + st.updatedAt = System.currentTimeMillis(); + JOBS.put(jobId, st); + + String auth = captureAuthorization(); + log.info("[OEE][theoryCycle][job] created jobId={}, wsType={}, window={}~{}, lines={}, authPresent={}", + jobId, + wsType, + queryBo == null ? null : queryBo.getStartTime(), + queryBo == null ? null : queryBo.getEndTime(), + queryBo == null ? null : queryBo.getLineIds(), + auth != null && !auth.trim().isEmpty()); + + // 立即推送“已创建” + push(jobId, wsType, "running", 5, "任务已创建,准备计算…", null, null); + + // 异步执行 + OeeQueryBo qb = copyQueryBo(queryBo); + EXECUTOR.submit(() -> run(jobId, wsType, qb, auth)); + + Map resp = new HashMap<>(); + resp.put("jobId", jobId); + resp.put("wsType", wsType); + return resp; + } + + private void run(String jobId, String wsType, OeeQueryBo queryBo, String authorization) { + long t0 = System.currentTimeMillis(); + try { + cleanupExpired(); + System.out.println("[OEE][theoryCycle][job] start jobId=" + jobId + ", wsType=" + wsType + + ", thread=" + Thread.currentThread().getName() + + ", authPresent=" + (authorization != null && !authorization.trim().isEmpty())); + log.info("[OEE][theoryCycle][job] start jobId={}, wsType={}, thread={}, authPresent={}", + jobId, wsType, Thread.currentThread().getName(), authorization != null && !authorization.trim().isEmpty()); + push(jobId, wsType, "running", 20, "开始计算理论节拍回归…", null, null); + + // 这里复用现有 WMS 同步回归聚合逻辑(内部会走 WMS 接口) + push(jobId, wsType, "running", 50, "正在拉取样本并拟合回归…", null, null); + OeeReportServiceImpl.setAuthOverride(authorization); + Map data = oeeReportService.theoryCycleRegression(queryBo); + + push(jobId, wsType, "running", 90, "正在整理回归结果…", null, null); + JobState st = JOBS.get(jobId); + if (st != null) { + st.updatedAt = System.currentTimeMillis(); + } + push(jobId, wsType, "success", 100, "回归计算完成", data, null); + System.out.println("[OEE][theoryCycle][job] success jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0)); + log.info("[OEE][theoryCycle][job] success jobId={}, costMs={}", jobId, (System.currentTimeMillis() - t0)); + } catch (Exception e) { + log.warn("theoryCycle job failed, jobId={}", jobId, e); + JobState st = JOBS.get(jobId); + if (st != null) { + st.updatedAt = System.currentTimeMillis(); + } + push(jobId, wsType, "failed", 100, "回归计算失败", null, e.getMessage()); + System.out.println("[OEE][theoryCycle][job] failed jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0) + + ", msg=" + e.getMessage()); + log.warn("[OEE][theoryCycle][job] failed jobId={}, costMs={}, msg={}", + jobId, (System.currentTimeMillis() - t0), e.getMessage()); + } finally { + OeeReportServiceImpl.clearAuthOverride(); + System.out.println("[OEE][theoryCycle][job] end jobId=" + jobId + ", costMs=" + (System.currentTimeMillis() - t0)); + log.info("[OEE][theoryCycle][job] end jobId={}, costMs={}", jobId, (System.currentTimeMillis() - t0)); + } + } + + private void push(String jobId, String wsType, String status, int progress, String text, + Map data, String errorMsg) { + try { + Map msg = new HashMap<>(); + msg.put("jobId", jobId); + msg.put("status", status); + msg.put("progress", progress); + msg.put("text", text); + if (data != null) { + msg.put("data", data); + } + if (errorMsg != null && !errorMsg.trim().isEmpty()) { + msg.put("errorMsg", errorMsg); + } + TypeWebSocketUtil.sendToType(wsType, objectMapper.writeValueAsString(msg)); + } catch (Exception ignore) { + } + } + + private void cleanupExpired() { + long now = System.currentTimeMillis(); + JOBS.entrySet().removeIf(e -> e.getValue() == null || (now - e.getValue().updatedAt) > TTL_MS); + } + + private OeeQueryBo copyQueryBo(OeeQueryBo queryBo) { + OeeQueryBo qb = new OeeQueryBo(); + if (queryBo == null) { + return qb; + } + qb.setStartDate(queryBo.getStartDate()); + qb.setEndDate(queryBo.getEndDate()); + qb.setLineIds(queryBo.getLineIds()); + qb.setStartTime(queryBo.getStartTime()); + qb.setEndTime(queryBo.getEndTime()); + qb.setTopN(queryBo.getTopN()); + qb.setLossCategoryCode(queryBo.getLossCategoryCode()); + qb.setKeyword(queryBo.getKeyword()); + return qb; + } + + private String captureAuthorization() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null || attrs.getRequest() == null) { + return null; + } + HttpServletRequest req = attrs.getRequest(); + String auth = req.getHeader("Authorization"); + if (auth != null && !auth.trim().isEmpty()) { + return auth.trim(); + } + String token = req.getHeader("token"); + if (token != null && !token.trim().isEmpty()) { + return "Bearer " + token.trim(); + } + } catch (Exception ignore) { + } + return null; + } + + private static class JobState { + long updatedAt; + } +} + diff --git a/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketConfig.java b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketConfig.java new file mode 100644 index 00000000..70ca290a --- /dev/null +++ b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketConfig.java @@ -0,0 +1,30 @@ +package com.klp.framework.websocket; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * 基于 Spring WebSocket 的 type 分流端点(兼容前端 ws://host/websocket?type=xxx 用法) + * + * 注意:项目中同时存在 javax.websocket 的 {@link WebSocketServer}(/websocket/message),互不冲突。 + */ +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class TypeWebSocketConfig implements WebSocketConfigurer { + + private final TypeWebSocketHandler typeWebSocketHandler; + private final TypeWebSocketInterceptor typeWebSocketInterceptor; + + @Override + public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) { + registry.addHandler(typeWebSocketHandler, "/websocket") + .addInterceptors(typeWebSocketInterceptor) + .setAllowedOrigins("*"); + } +} + diff --git a/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketHandler.java b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketHandler.java new file mode 100644 index 00000000..fa69ead2 --- /dev/null +++ b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketHandler.java @@ -0,0 +1,93 @@ +package com.klp.framework.websocket; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * /websocket?type=xxx 连接处理器:按 type 维护会话集合。 + */ +@Slf4j +@Component +public class TypeWebSocketHandler extends TextWebSocketHandler { + + /** + * type -> (sessionId -> session) + */ + private final Map> clients = new ConcurrentHashMap<>(); + + public Map> getClients() { + return clients; + } + + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) { + String sid = session.getId(); + String type = (String) session.getAttributes().get(TypeWebSocketInterceptor.ATTR_TYPE); + if (type == null || type.trim().isEmpty()) { + type = "DEFAULT"; + } + clients.computeIfAbsent(type, k -> new ConcurrentHashMap<>()).put(sid, session); + log.info("[websocket]建立连接: {}-{}", type, sid); + try { + session.sendMessage(new TextMessage("{\"event\":\"connected\",\"type\":\"" + type + "\"}")); + } catch (IOException ignore) { + } + } + + @Override + protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) throws Exception { + String payload = message.getPayload(); + // 兼容前端心跳 + if (Objects.equals(payload, "ping")) { + session.sendMessage(new TextMessage("pong")); + return; + } + // 默认不做业务处理(目前主要用于服务端推送) + log.debug("[websocket]收到客户端消息: {} -> {}", session.getId(), payload); + } + + @Override + public void handleTransportError(@NonNull WebSocketSession session, @NonNull Throwable exception) throws Exception { + log.warn("[websocket]连接异常: {}", session.getId(), exception); + onClose(session); + } + + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { + onClose(session); + } + + private void onClose(WebSocketSession session) { + try { + String sid = session.getId(); + String type = (String) session.getAttributes().get(TypeWebSocketInterceptor.ATTR_TYPE); + if (type == null || type.trim().isEmpty()) { + type = "DEFAULT"; + } + Map map = clients.get(type); + if (map != null) { + map.remove(sid); + } + try { + if (session.isOpen()) { + session.close(); + } + } catch (Exception ignore) { + } + log.info("[websocket]连接关闭: {}-{}", type, sid); + } catch (Exception e) { + log.warn("[websocket]连接关闭处理异常", e); + } + } +} + diff --git a/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketInterceptor.java b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketInterceptor.java new file mode 100644 index 00000000..f600691f --- /dev/null +++ b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketInterceptor.java @@ -0,0 +1,47 @@ +package com.klp.framework.websocket; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.Map; + +/** + * 从 ws://host/websocket?type=xxx 提取 type,写入 session attributes。 + */ +@Component +public class TypeWebSocketInterceptor implements HandshakeInterceptor { + + public static final String ATTR_TYPE = "type"; + + @Override + public boolean beforeHandshake(@NonNull ServerHttpRequest request, + @NonNull ServerHttpResponse response, + @NonNull WebSocketHandler wsHandler, + @NonNull Map attributes) { + try { + URI uri = request.getURI(); + String type = UriComponentsBuilder.fromUri(uri).build().getQueryParams().getFirst("type"); + if (type != null && !type.trim().isEmpty()) { + attributes.put(ATTR_TYPE, type.trim()); + } + } catch (Exception ignore) { + } + return true; + } + + @Override + public void afterHandshake(@NonNull ServerHttpRequest request, + @NonNull ServerHttpResponse response, + @NonNull WebSocketHandler wsHandler, + @Nullable Exception exception) { + // no-op + } +} + diff --git a/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketUtil.java b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketUtil.java new file mode 100644 index 00000000..2ee5f807 --- /dev/null +++ b/klp-framework/src/main/java/com/klp/framework/websocket/TypeWebSocketUtil.java @@ -0,0 +1,43 @@ +package com.klp.framework.websocket; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +/** + * type 分流 websocket 推送工具:向订阅了指定 type 的客户端广播消息。 + */ +@Slf4j +@Component +public class TypeWebSocketUtil { + + private static TypeWebSocketHandler handler; + + public TypeWebSocketUtil(TypeWebSocketHandler handler) { + TypeWebSocketUtil.handler = handler; + } + + public static void sendToType(String type, String text) { + if (handler == null || type == null) { + return; + } + Map sessions = handler.getClients().get(type); + if (sessions == null || sessions.isEmpty()) { + return; + } + TextMessage msg = new TextMessage(text == null ? "" : text); + sessions.values().forEach(s -> { + try { + if (s != null && s.isOpen()) { + s.sendMessage(msg); + } + } catch (Exception e) { + log.warn("[websocket]推送消息失败 type={}, sid={}", type, s == null ? null : s.getId(), e); + } + }); + } +} + diff --git a/klp-ui/src/api/da/oee.js b/klp-ui/src/api/da/oee.js index c232d00e..83ce3002 100644 --- a/klp-ui/src/api/da/oee.js +++ b/klp-ui/src/api/da/oee.js @@ -1,32 +1,5 @@ import request from '@/utils/request' -// OEE 汇总(两条产线 KPI + 日趋势) -export function fetchOeeSummary(query) { - return request({ - url: '/oee/line/summary', - method: 'get', - params: query - }) -} - -// 7 大损失汇总 -export function fetchOeeLoss7(query) { - return request({ - url: '/oee/line/loss7', - method: 'get', - params: query - }) -} - -// 停机/损失事件明细 -export function fetchOeeEvents(query) { - return request({ - url: '/oee/line/events', - method: 'get', - params: query - }) -} - // 导出 Word 报表 export function exportOeeWord(query) { return request({ @@ -37,3 +10,62 @@ export function exportOeeWord(query) { }) } +// OEE 产线 KPI + 趋势汇总 +export function fetchOeeSummary(query) { + return request({ + url: '/oee/line/summary', + method: 'get', + params: query, + timeout: 120000 + }) +} + +// OEE 产线 KPI + 趋势汇总(异步任务接口暂保留,当前前端不使用) +export function createOeeSummaryJob(query) { + return request({ + url: '/oee/line/summary/job', + method: 'get', + params: query, + timeout: 120000 + }) +} + +// 7 大损失汇总 +export function fetchOeeLoss7(query) { + return request({ + url: '/oee/line/loss7', + method: 'get', + params: query, + timeout: 120000 + }) +} + +// 停机/损失事件明细 +export function fetchOeeEvents(query) { + return request({ + url: '/oee/line/events', + method: 'get', + params: query, + timeout: 120000 + }) +} + +// 理论节拍回归结果(含散点与拟合线) +export function fetchOeeTheoryCycleRegression(query) { + return request({ + url: '/oee/line/theoryCycle/regression', + method: 'get', + params: query, + timeout: 120000 + }) +} + +// 理论节拍回归:创建异步任务,返回 jobId + wsType,通过 WebSocket 推送进度/结果 +export function createOeeTheoryCycleRegressionJob(query) { + return request({ + url: '/oee/line/theoryCycle/regression/job', + method: 'get', + params: query, + timeout: 120000 + }) +} diff --git a/klp-ui/src/views/da/oee/index.vue b/klp-ui/src/views/da/oee/index.vue index 6bedc913..37576b48 100644 --- a/klp-ui/src/views/da/oee/index.vue +++ b/klp-ui/src/views/da/oee/index.vue @@ -1,384 +1,873 @@ - + + + + .oee-loading-main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + } + .oee-loading-card { + max-width: 520px; + padding: 24px 28px; + background: #ffffff; + border-radius: 4px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12); + text-align: left; + } + + .oee-loading-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + } + + .oee-loading-text { + font-size: 13px; + color: #606266; + line-height: 1.6; + margin-bottom: 12px; + } + + .oee-report-header { + border-bottom: 1px solid #ebeef5; + padding-bottom: 12px; + margin-bottom: 16px; + } + + .oee-report-meta { + display: flex; + justify-content: flex-start; + column-gap: 24px; + font-size: 12px; + color: #606266; + margin-bottom: 6px; + } + + .oee-report-query-form { + display: flex; + justify-content: flex-end; + } + + .oee-section { + margin-top: 18px; + } + + .oee-section-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 8px; + } + + .oee-charts { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + } + + .oee-chart-card { + border: 1px solid #ebeef5; + border-radius: 4px; + padding: 10px 12px; + } + + .oee-chart-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + } + + .oee-chart { + width: 100%; + height: 260px; + } + + .oee-paragraph { + font-size: 13px; + line-height: 1.6; + color: #606266; + margin-bottom: 8px; + } + + .oee-subsection { + margin-top: 10px; + } + + .oee-subsection-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + } + + .oee-kpi-table, + .oee-daily-table, + .oee-loss-table, + .oee-events-table { + font-size: 12px; + } + + .oee-report-aside { + width: 320px; + position: sticky; + top: 72px; + align-self: flex-start; + } + + .oee-formula-card { + background: #ffffff; + padding: 16px 18px; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + .oee-formula-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + } + + .oee-formula-body { + font-size: 12px; + color: #606266; + } + + .oee-formula-caption { + margin-top: 10px; + margin-bottom: 2px; + font-weight: 500; + } + + .math-block { + margin: 4px 0; + } + + .oee-formula-footnote { + margin-top: 10px; + font-size: 11px; + color: #909399; + } + + .oee-bg-progress { + margin-top: 8px; + width: 100%; + } + + .oee-bg-progress-text { + margin-top: 4px; + font-size: 12px; + color: #909399; + } + + /* 让进度数字在条外侧显示,避免被遮挡 */ + .oee-bg-progress :deep(.el-progress__text) { + margin-left: 8px; + color: #606266; + font-weight: 500; + } + + .oee-regression-item { + padding: 8px 0; + border-top: 1px dashed #ebeef5; + } + + .oee-regression-title { + font-weight: 600; + margin-bottom: 4px; + } + + .oee-regression-row { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + } + + + \ No newline at end of file diff --git a/klp-wms/src/main/java/com/klp/controller/WmsCoilPendingActionController.java b/klp-wms/src/main/java/com/klp/controller/WmsCoilPendingActionController.java index b066c20b..887190ff 100644 --- a/klp-wms/src/main/java/com/klp/controller/WmsCoilPendingActionController.java +++ b/klp-wms/src/main/java/com/klp/controller/WmsCoilPendingActionController.java @@ -18,9 +18,13 @@ import com.klp.common.core.validate.EditGroup; import com.klp.common.enums.BusinessType; import com.klp.common.utils.poi.ExcelUtil; import com.klp.domain.vo.WmsCoilPendingActionVo; +import com.klp.domain.vo.TheoryCycleRegressionResultVo; import com.klp.domain.bo.WmsCoilPendingActionBo; import com.klp.service.IWmsCoilPendingActionService; import com.klp.common.core.page.TableDataInfo; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; /** * 钢卷待操作 @@ -133,5 +137,17 @@ public class WmsCoilPendingActionController extends BaseController { public R cancelAction(@PathVariable("actionId") Long actionId) { return toAjax(iWmsCoilPendingActionService.cancelAction(actionId)); } + + /** + * 计算理论节拍回归(默认近6个月),并返回散点+拟合线 + */ + @GetMapping("/theoryCycle/regression") + public R theoryCycleRegression( + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date startTime, + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date endTime, + @RequestParam(value = "includePoints", required = false, defaultValue = "false") Boolean includePoints, + @RequestParam(value = "maxPoints", required = false, defaultValue = "2000") Integer maxPoints) { + return R.ok(iWmsCoilPendingActionService.calcTheoryCycleRegression(startTime, endTime, includePoints, maxPoints)); + } } diff --git a/klp-wms/src/main/java/com/klp/domain/vo/TheoryCyclePointVo.java b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCyclePointVo.java new file mode 100644 index 00000000..9305d915 --- /dev/null +++ b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCyclePointVo.java @@ -0,0 +1,33 @@ +package com.klp.domain.vo; + +import lombok.Data; + +import java.util.Date; + +/** + * 理论节拍散点 + */ +@Data +public class TheoryCyclePointVo { + + /** + * X:产量(吨) + */ + private Double weightTon; + + /** + * Y:耗时(分钟) + */ + private Double durationMin; + + /** + * 对应的动作ID + */ + private Long actionId; + + /** + * 创建时间(便于前端提示) + */ + private Date createTime; +} + diff --git a/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionResultVo.java b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionResultVo.java new file mode 100644 index 00000000..ed07c3ef --- /dev/null +++ b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionResultVo.java @@ -0,0 +1,18 @@ +package com.klp.domain.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 理论节拍回归结果集合 + */ +@Data +public class TheoryCycleRegressionResultVo { + + /** + * 产线回归结果列表 + */ + private List lines; +} + diff --git a/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionVo.java b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionVo.java new file mode 100644 index 00000000..a6a74d65 --- /dev/null +++ b/klp-wms/src/main/java/com/klp/domain/vo/TheoryCycleRegressionVo.java @@ -0,0 +1,49 @@ +package com.klp.domain.vo; + +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * 单条产线的理论节拍线性回归结果 + */ +@Data +public class TheoryCycleRegressionVo { + + private String lineId; + + private String lineName; + + /** + * 斜率:分钟/吨 + */ + private Double slopeMinPerTon; + + /** + * 截距:分钟 + */ + private Double interceptMin; + + /** + * 拟合优度 + */ + private Double r2; + + private Integer sampleCount; + + private Date startTime; + + private Date endTime; + + /** + * 散点数据 + */ + private List points; + + /** + * 拟合线两个端点(前端可直接画线) + */ + private List linePoints; +} + diff --git a/klp-wms/src/main/java/com/klp/service/IWmsCoilPendingActionService.java b/klp-wms/src/main/java/com/klp/service/IWmsCoilPendingActionService.java index 528ef962..3a3f09d9 100644 --- a/klp-wms/src/main/java/com/klp/service/IWmsCoilPendingActionService.java +++ b/klp-wms/src/main/java/com/klp/service/IWmsCoilPendingActionService.java @@ -7,6 +7,8 @@ import com.klp.common.core.page.TableDataInfo; import java.util.Collection; import java.util.List; +import java.util.Date; +import com.klp.domain.vo.TheoryCycleRegressionResultVo; /** * 钢卷待操作Service接口 @@ -65,5 +67,18 @@ public interface IWmsCoilPendingActionService { * 取消操作 */ Boolean cancelAction(Long actionId); + + /** + * 计算理论节拍线性回归(默认近6个月),同时返回散点用于前端绘图并将结果缓存。 + */ + TheoryCycleRegressionResultVo calcTheoryCycleRegression(Date startTime, Date endTime); + + /** + * 计算理论节拍线性回归(可选择是否返回散点;并对散点数量进行上限控制)。 + * + * @param includePoints 是否返回散点 points(默认 false,避免结果过大) + * @param maxPoints 最大散点数(includePoints=true 时生效) + */ + TheoryCycleRegressionResultVo calcTheoryCycleRegression(Date startTime, Date endTime, Boolean includePoints, Integer maxPoints); } diff --git a/klp-wms/src/main/java/com/klp/service/impl/WmsCoilPendingActionServiceImpl.java b/klp-wms/src/main/java/com/klp/service/impl/WmsCoilPendingActionServiceImpl.java index 6f679aac..ed845630 100644 --- a/klp-wms/src/main/java/com/klp/service/impl/WmsCoilPendingActionServiceImpl.java +++ b/klp-wms/src/main/java/com/klp/service/impl/WmsCoilPendingActionServiceImpl.java @@ -2,28 +2,36 @@ package com.klp.service.impl; import cn.hutool.core.bean.BeanUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.klp.common.core.domain.PageQuery; -import com.klp.common.core.domain.entity.SysUser; -import com.klp.common.core.page.TableDataInfo; -import com.klp.common.utils.StringUtils; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.klp.common.core.domain.PageQuery; +import com.klp.common.core.page.TableDataInfo; import com.klp.common.helper.LoginHelper; +import com.klp.common.utils.StringUtils; +import com.klp.domain.WmsCoilPendingAction; import com.klp.domain.WmsMaterialCoil; -import com.klp.domain.vo.WmsMaterialCoilVo; +import com.klp.domain.bo.WmsCoilPendingActionBo; +import com.klp.domain.vo.TheoryCyclePointVo; +import com.klp.domain.vo.TheoryCycleRegressionResultVo; +import com.klp.domain.vo.TheoryCycleRegressionVo; +import com.klp.domain.vo.WmsCoilPendingActionVo; +import com.klp.mapper.WmsCoilPendingActionMapper; import com.klp.mapper.WmsMaterialCoilMapper; -import com.klp.service.IWmsMaterialCoilService; +import com.klp.service.IWmsCoilPendingActionService; import com.klp.system.service.ISysUserService; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import com.klp.domain.bo.WmsCoilPendingActionBo; -import com.klp.domain.vo.WmsCoilPendingActionVo; -import com.klp.domain.WmsCoilPendingAction; -import com.klp.mapper.WmsCoilPendingActionMapper; -import com.klp.service.IWmsCoilPendingActionService; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -40,6 +48,9 @@ public class WmsCoilPendingActionServiceImpl implements IWmsCoilPendingActionSer private final ISysUserService userService; private final WmsMaterialCoilMapper materialCoilMapper; + private final StringRedisTemplate stringRedisTemplate; + + private static final String REDIS_KEY_IDEAL_CYCLE = "oee:ideal-cycle-time"; /** * 查询钢卷待操作 @@ -248,5 +259,262 @@ public class WmsCoilPendingActionServiceImpl implements IWmsCoilPendingActionSer action.setActionStatus(3); // 已取消 return baseMapper.updateById(action) > 0; } + + @Override + public TheoryCycleRegressionResultVo calcTheoryCycleRegression(Date startTime, Date endTime) { + return calcTheoryCycleRegression(startTime, endTime, true, 2000); + } + + @Override + public TheoryCycleRegressionResultVo calcTheoryCycleRegression(Date startTime, Date endTime, Boolean includePoints, Integer maxPoints) { + LocalDateTime end = endTime == null ? LocalDateTime.now() : toLocalDateTime(endTime); + LocalDateTime start = startTime == null ? end.minusMonths(6) : toLocalDateTime(startTime); + + boolean inc = includePoints != null && includePoints; + int limit = (maxPoints == null || maxPoints <= 0) ? 2000 : maxPoints; + TheoryCycleRegressionVo sy = buildRegression("SY", "酸轧线", 11, start, end, false, inc, limit); + TheoryCycleRegressionVo dx1 = buildRegression("DX1", "镀锌一线", 501, start, end, true, inc, limit); + + cacheIdealCycle(sy); + cacheIdealCycle(dx1); + + TheoryCycleRegressionResultVo result = new TheoryCycleRegressionResultVo(); + result.setLines(Arrays.asList(sy, dx1)); + return result; + } + + private TheoryCycleRegressionVo buildRegression(String lineId, String lineName, int actionType, + LocalDateTime start, LocalDateTime end, + boolean parseRemarkIds, + boolean includePoints, int maxPoints) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(WmsCoilPendingAction::getActionType, actionType) + .eq(WmsCoilPendingAction::getDelFlag, 0) + .eq(WmsCoilPendingAction::getActionStatus, 2) + .ge(WmsCoilPendingAction::getCreateTime, Date.from(start.atZone(ZoneId.systemDefault()).toInstant())) + .le(WmsCoilPendingAction::getCreateTime, Date.from(end.atZone(ZoneId.systemDefault()).toInstant())); + + List actions = baseMapper.selectList(lqw); + if (actions == null || actions.isEmpty()) { + TheoryCycleRegressionVo vo = new TheoryCycleRegressionVo(); + vo.setLineId(lineId); + vo.setLineName(lineName); + vo.setStartTime(Date.from(start.atZone(ZoneId.systemDefault()).toInstant())); + vo.setEndTime(Date.from(end.atZone(ZoneId.systemDefault()).toInstant())); + vo.setSampleCount(0); + vo.setPoints(Collections.emptyList()); + vo.setLinePoints(Collections.emptyList()); + return vo; + } + + // 预先收集所有需要的钢卷 ID,一次性批量查询,避免在循环中逐条访问数据库 + Set allCoilIds = new HashSet<>(); + for (WmsCoilPendingAction action : actions) { + if (action.getCreateTime() == null || action.getCompleteTime() == null) { + continue; + } + if (parseRemarkIds) { + List ids = parseIdsFromRemark(action.getRemark()); + allCoilIds.addAll(ids); + } else { + if (action.getCoilId() != null) { + allCoilIds.add(action.getCoilId()); + } + } + } + + Map weightTonMap = new HashMap<>(); + if (!allCoilIds.isEmpty()) { + List coils = materialCoilMapper.selectBatchIds(allCoilIds); + if (coils != null) { + for (WmsMaterialCoil coil : coils) { + if (coil == null || coil.getCoilId() == null) continue; + BigDecimal net = coil.getNetWeight(); + BigDecimal gross = coil.getGrossWeight(); + BigDecimal weightKg = net != null && net.compareTo(BigDecimal.ZERO) > 0 ? net : gross; + if (weightKg == null || weightKg.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + double ton = weightKg.divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP).doubleValue(); + weightTonMap.put(coil.getCoilId(), ton); + } + } + } + + List points = new ArrayList<>(); + for (WmsCoilPendingAction action : actions) { + if (action.getCreateTime() == null || action.getCompleteTime() == null) { + continue; + } + long minutes = Duration.between( + toLocalDateTime(action.getCreateTime()), + toLocalDateTime(action.getCompleteTime()) + ).toMinutes(); + if (minutes <= 0) { + continue; + } + double weightTon = 0D; + if (parseRemarkIds) { + List ids = parseIdsFromRemark(action.getRemark()); + for (Long id : ids) { + Double wt = weightTonMap.get(id); + if (wt != null && wt > 0D) { + weightTon += wt; + } + } + } else { + if (action.getCoilId() != null) { + Double wt = weightTonMap.get(action.getCoilId()); + if (wt != null && wt > 0D) { + weightTon = wt; + } + } + } + if (weightTon <= 0D) { + continue; + } + TheoryCyclePointVo p = new TheoryCyclePointVo(); + p.setActionId(action.getActionId()); + p.setCreateTime(action.getCreateTime()); + p.setDurationMin((double) minutes); + p.setWeightTon(weightTon); + points.add(p); + } + + TheoryCycleRegressionVo vo = new TheoryCycleRegressionVo(); + vo.setLineId(lineId); + vo.setLineName(lineName); + vo.setStartTime(Date.from(start.atZone(ZoneId.systemDefault()).toInstant())); + vo.setEndTime(Date.from(end.atZone(ZoneId.systemDefault()).toInstant())); + vo.setSampleCount(points.size()); + if (includePoints) { + vo.setPoints(samplePoints(points, maxPoints)); + } else { + vo.setPoints(Collections.emptyList()); + } + + RegressionStat stat = linearRegression(points); + vo.setSlopeMinPerTon(stat.slope); + vo.setInterceptMin(stat.intercept); + vo.setR2(stat.r2); + vo.setLinePoints(stat.linePoints); + return vo; + } + + /** + * 散点抽样:避免返回体过大导致网络/序列化问题。 + */ + private List samplePoints(List points, int maxPoints) { + if (points == null || points.isEmpty()) { + return Collections.emptyList(); + } + if (maxPoints <= 0 || points.size() <= maxPoints) { + return points; + } + int n = points.size(); + double step = (double) n / (double) maxPoints; + List sampled = new ArrayList<>(maxPoints); + for (int i = 0; i < maxPoints; i++) { + int idx = (int) Math.floor(i * step); + if (idx < 0) idx = 0; + if (idx >= n) idx = n - 1; + sampled.add(points.get(idx)); + } + return sampled; + } + + private void cacheIdealCycle(TheoryCycleRegressionVo vo) { + if (vo == null || vo.getSlopeMinPerTon() == null) { + return; + } + String field = vo.getLineId() == null ? "UNKNOWN" : vo.getLineId().toUpperCase(); + stringRedisTemplate.opsForHash().put(REDIS_KEY_IDEAL_CYCLE, field, vo.getSlopeMinPerTon().toString()); + if (vo.getInterceptMin() != null) { + stringRedisTemplate.opsForHash().put(REDIS_KEY_IDEAL_CYCLE + ":intercept", field, vo.getInterceptMin().toString()); + } + } + + private RegressionStat linearRegression(List points) { + RegressionStat stat = new RegressionStat(); + if (points == null || points.size() < 2) { + return stat; + } + double sumX = 0, sumY = 0, sumXX = 0, sumXY = 0; + for (TheoryCyclePointVo p : points) { + double x = p.getWeightTon(); + double y = p.getDurationMin(); + sumX += x; + sumY += y; + sumXX += x * x; + sumXY += x * y; + } + int n = points.size(); + double denominator = n * sumXX - sumX * sumX; + if (denominator == 0) { + return stat; + } + double slope = (n * sumXY - sumX * sumY) / denominator; + double intercept = (sumY - slope * sumX) / n; + + double ssTot = 0, ssRes = 0; + double meanY = sumY / n; + for (TheoryCyclePointVo p : points) { + double y = p.getDurationMin(); + double yHat = slope * p.getWeightTon() + intercept; + ssTot += Math.pow(y - meanY, 2); + ssRes += Math.pow(y - yHat, 2); + } + double r2 = ssTot == 0 ? 0 : 1 - ssRes / ssTot; + + stat.slope = slope; + stat.intercept = intercept; + stat.r2 = r2; + stat.linePoints = buildLinePoints(points, slope, intercept); + return stat; + } + + private List buildLinePoints(List points, double slope, double intercept) { + if (points == null || points.isEmpty()) { + return Collections.emptyList(); + } + double minX = points.stream().mapToDouble(TheoryCyclePointVo::getWeightTon).min().orElse(0D); + double maxX = points.stream().mapToDouble(TheoryCyclePointVo::getWeightTon).max().orElse(0D); + List line = new ArrayList<>(); + TheoryCyclePointVo p1 = new TheoryCyclePointVo(); + p1.setWeightTon(minX); + p1.setDurationMin(slope * minX + intercept); + TheoryCyclePointVo p2 = new TheoryCyclePointVo(); + p2.setWeightTon(maxX); + p2.setDurationMin(slope * maxX + intercept); + line.add(p1); + line.add(p2); + return line; + } + + private List parseIdsFromRemark(String remark) { + if (StringUtils.isBlank(remark)) { + return Collections.emptyList(); + } + Matcher matcher = Pattern.compile("\\d+").matcher(remark); + List ids = new ArrayList<>(); + while (matcher.find()) { + try { + ids.add(Long.parseLong(matcher.group())); + } catch (NumberFormatException ignore) { + } + } + return ids; + } + + private LocalDateTime toLocalDateTime(Date date) { + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); + } + + private static class RegressionStat { + Double slope; + Double intercept; + Double r2; + List linePoints = Collections.emptyList(); + } } diff --git a/klp-wms/src/main/java/com/klp/service/impl/WmsMaterialCoilServiceImpl.java b/klp-wms/src/main/java/com/klp/service/impl/WmsMaterialCoilServiceImpl.java index 96725f70..dfb7781b 100644 --- a/klp-wms/src/main/java/com/klp/service/impl/WmsMaterialCoilServiceImpl.java +++ b/klp-wms/src/main/java/com/klp/service/impl/WmsMaterialCoilServiceImpl.java @@ -394,15 +394,6 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService { qw.eq(StringUtils.isNotBlank(bo.getTemperGrade()), "mc.temper_grade", bo.getTemperGrade()); // 独占状态 qw.eq(bo.getExclusiveStatus() != null, "mc.exclusive_status", bo.getExclusiveStatus()); - //逻辑删除 - qw.eq("mc.del_flag", 0); - // 按创建时间范围筛选 - if (bo.getByCreateTimeStart() != null) { - qw.ge("mc.create_time", bo.getByCreateTimeStart()); - } - if (bo.getByCreateTimeEnd() != null) { - qw.le("mc.create_time", bo.getByCreateTimeEnd()); - } // 统一处理 warehouseId 与 warehouseIds: List warehouseIdList = new ArrayList<>(); if (bo.getWarehouseId() != null) { @@ -571,13 +562,17 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService { // "WHERE dp.del_flag = 0 AND dp.coil IS NOT NULL AND dp.coil <> '' " + // "AND FIND_IN_SET(CAST(mc.coil_id AS CHAR), dp.coil))"); // } - + //逻辑删除 + qw.eq("mc.del_flag", 0); //把team字段作为筛选条件 qw.eq(StringUtils.isNotBlank(bo.getTeam()), "mc.team", bo.getTeam()); //根据开始时间和结束时间筛选修改时间 qw.ge(bo.getStartTime() != null, "mc.update_time", bo.getStartTime()); qw.le(bo.getEndTime() != null, "mc.update_time", bo.getEndTime()); + qw.ge(bo.getByCreateTimeStart() != null, "mc.create_time", bo.getByCreateTimeStart()); + qw.le(bo.getByCreateTimeEnd() != null, "mc.create_time", bo.getByCreateTimeEnd()); + // 处理发货时间筛选逻辑(核心修改部分) if (bo.getByExportTimeStart() != null || bo.getByExportTimeEnd() != null) { // 开启OR条件分组:满足情况1 或 情况2 @@ -618,50 +613,69 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService { @Override public Map getDuplicateCoilGroups() { - // 使用优化的数据库查询方法,直接获取重复入场卷号的钢卷信息 - List enterDuplicates = baseMapper.selectDuplicateEnterCoilNoList(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(WmsMaterialCoil::getDataType, 1); + lqw.eq(WmsMaterialCoil::getDelFlag, 0); + List all = baseMapper.selectList(lqw); - // 使用优化的数据库查询方法,直接获取重复当前卷号的钢卷信息 - List currentDuplicates = baseMapper.selectDuplicateCurrentCoilNoList(); - - // 按入场卷号分组重复项 - Map> enterGrouped = enterDuplicates.stream() + Map> enterGrouped = all.stream() .filter(e -> StringUtils.isNotBlank(e.getEnterCoilNo())) - .collect(Collectors.groupingBy(WmsMaterialCoilVo::getEnterCoilNo)); - - // 按当前卷号分组重复项 - Map> currentGrouped = currentDuplicates.stream() + .collect(Collectors.groupingBy(WmsMaterialCoil::getEnterCoilNo)); + Map> currentGrouped = all.stream() .filter(e -> StringUtils.isNotBlank(e.getCurrentCoilNo())) - .collect(Collectors.groupingBy(WmsMaterialCoilVo::getCurrentCoilNo)); + .collect(Collectors.groupingBy(WmsMaterialCoil::getCurrentCoilNo)); - // 构建入场卷号重复组 List> enterGroups = enterGrouped.entrySet().stream() - .filter(entry -> entry.getValue() != null && entry.getValue().size() > 1) - .map(entry -> { - Map group = new HashMap<>(); - group.put("enterCoilNo", entry.getKey()); - group.put("coils", entry.getValue()); - return group; + .filter(en -> en.getValue() != null && en.getValue().size() > 1) + .map(en -> { + List vos = en.getValue().stream().map(this::toVoBasic).collect(Collectors.toList()); + Map m = new HashMap<>(); + m.put("enterCoilNo", en.getKey()); + m.put("coils", vos); + return m; }) .collect(Collectors.toList()); - // 构建当前卷号重复组 List> currentGroups = currentGrouped.entrySet().stream() - .filter(entry -> entry.getValue() != null && entry.getValue().size() > 1) - .map(entry -> { - Map group = new HashMap<>(); - group.put("currentCoilNo", entry.getKey()); - group.put("coils", entry.getValue()); - return group; + .filter(en -> en.getValue() != null && en.getValue().size() > 1) + .map(en -> { + List vos = en.getValue().stream().map(this::toVoBasic).collect(Collectors.toList()); + Map m = new HashMap<>(); + m.put("currentCoilNo", en.getKey()); + m.put("coils", vos); + return m; }) .collect(Collectors.toList()); + // 可选:批量填充关联对象信息 + List allVos = new ArrayList<>(); + for (Map g : enterGroups) { + Object list = g.get("coils"); + if (list instanceof List) { + allVos.addAll((List) list); + } + } + for (Map g : currentGroups) { + Object list = g.get("coils"); + if (list instanceof List) { + allVos.addAll((List) list); + } + } + if (!allVos.isEmpty()) { + fillRelatedObjectsBatch(allVos); + } + Map result = new HashMap<>(); result.put("enterGroups", enterGroups); result.put("currentGroups", currentGroups); return result; } + private WmsMaterialCoilVo toVoBasic(WmsMaterialCoil e) { + WmsMaterialCoilVo vo = new WmsMaterialCoilVo(); + BeanUtils.copyProperties(e, vo); + return vo; + } /** * 构建 OR 连接的 LIKE 子句,使用 MyBatis-Plus apply 的 {index} 占位符并将参数加入 args。