From 872bdda2fc39dcd816b29a3efa2208c3e3c4b081 Mon Sep 17 00:00:00 2001
From: 86156 <823267011@qq.com>
Date: Fri, 30 Jan 2026 17:37:27 +0800
Subject: [PATCH] =?UTF-8?q?=E9=85=B8=E8=BD=A7OEE=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=E5=AE=8C=E6=88=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
klp-da/pom.xml | 6 +
.../da/controller/OeeReportController.java | 337 +++++++++++-
.../klp/da/service/OeeReportJobService.java | 137 +++++
.../com/klp/da/task/AcidOeeMonthTask.java | 148 ++++++
klp-pocket/pom.xml | 6 +
.../acid/domain/vo/AcidOeeDailySummaryVo.java | 81 +++
.../pocket/acid/domain/vo/AcidOeeLoss7Vo.java | 34 ++
.../acid/domain/vo/AcidOeeRegressionVo.java | 81 +++
.../klp/pocket/acid/mapper/AcidOeeMapper.java | 67 +++
.../pocket/acid/service/IAcidOeeService.java | 56 ++
.../acid/service/impl/AcidOeeServiceImpl.java | 503 ++++++++++++++++++
.../service/ICoilQualityJudgeService.java | 22 +
.../impl/CoilQualityJudgeServiceImpl.java | 41 ++
.../resources/mapper/pocket/AcidOeeMapper.xml | 119 +++++
.../klp/service/IWmsMaterialCoilService.java | 13 +
.../impl/WmsMaterialCoilServiceImpl.java | 19 +-
16 files changed, 1656 insertions(+), 14 deletions(-)
create mode 100644 klp-da/src/main/java/com/klp/da/service/OeeReportJobService.java
create mode 100644 klp-da/src/main/java/com/klp/da/task/AcidOeeMonthTask.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeDailySummaryVo.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeLoss7Vo.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeRegressionVo.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/mapper/AcidOeeMapper.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/service/IAcidOeeService.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/acid/service/impl/AcidOeeServiceImpl.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/common/service/ICoilQualityJudgeService.java
create mode 100644 klp-pocket/src/main/java/com/klp/pocket/common/service/impl/CoilQualityJudgeServiceImpl.java
create mode 100644 klp-pocket/src/main/resources/mapper/pocket/AcidOeeMapper.xml
diff --git a/klp-da/pom.xml b/klp-da/pom.xml
index d8a884c8..ced04771 100644
--- a/klp-da/pom.xml
+++ b/klp-da/pom.xml
@@ -19,6 +19,12 @@
com.klp
klp-framework
+
+
+ com.klp
+ klp-pocket
+ 0.8.3
+
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 4b16dcaa..cfe61e2c 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
@@ -1,26 +1,43 @@
package com.klp.da.controller;
-import com.klp.common.annotation.Log;
+import com.alibaba.fastjson2.JSON;
import com.klp.common.core.controller.BaseController;
-
+import com.klp.common.core.domain.R;
+import com.klp.common.core.page.TableDataInfo;
+import com.klp.common.utils.StringUtils;
+import com.klp.da.service.OeeReportJobService;
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
+import com.klp.pocket.acid.domain.vo.AcidOeeRegressionVo;
+import com.klp.pocket.acid.domain.vo.Klptcm1ProStoppageVo;
+import com.klp.pocket.acid.service.IAcidOeeService;
+import lombok.Data;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
-import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
/**
- * OEE 报表聚合 Controller(方式 A:后端统一聚合多服务)
+ * OEE 报表对外接口(聚合层)
*
- * 路由前缀与 docs/oee-report-design.md 设计文档保持一致:
- * - /api/ems/oee/line/summary
- * - /api/ems/oee/line/loss7
- * - /api/ems/oee/line/events
- * - /api/ems/oee/line/exportWord
+ * 当前阶段:主要暴露酸轧线相关接口,通过 `klp-pocket` 的 {@link IAcidOeeService} 取数。
+ * - 当月 summary / loss7 优先走 Redis 预计算缓存;
+ * - 任意日期范围通过异步任务接口实现。
*/
@Validated
@RequiredArgsConstructor
@@ -28,6 +45,304 @@ import java.util.Map;
@RequestMapping("/oee/line")
public class OeeReportController extends BaseController {
+ private final IAcidOeeService acidOeeService;
+ private final StringRedisTemplate stringRedisTemplate;
+ private final OeeReportJobService oeeReportJobService;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+
+ private static final String JOB_META_KEY_PATTERN = "oee:report:job:meta:%s";
+ private static final String JOB_RESULT_KEY_PATTERN = "oee:report:job:result:%s";
+
+ /**
+ * 酸轧线 OEE 日汇总(含趋势)
+ *
+ * 路由:GET /oee/line/acid/summary
+ * 说明:
+ * - 不接受 start/end 参数,固定返回“当前月份(1号~今天)”的当月预计算结果;
+ * - 优先从 Redis 当月缓存读取;若缓存缺失则实时计算一次当前月。
+ */
+ @GetMapping("/acid/summary")
+ public R> getAcidSummary() {
+ String yyyyMM = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
+ String summaryKey = String.format("oee:report:month:summary:%s:SY", yyyyMM);
+
+ // 1. 优先从 Redis 读取当月预计算结果
+ String json = stringRedisTemplate.opsForValue().get(summaryKey);
+ if (StringUtils.isNotBlank(json)) {
+ List cached =
+ JSON.parseArray(json, AcidOeeDailySummaryVo.class);
+ return R.ok(cached);
+ }
+
+ // 2. 缓存缺失时,回退为实时计算当前月
+ String[] range = resolveDateRange(null, null);
+ List dailyList =
+ acidOeeService.getDailySummary(range[0], range[1]);
+ return R.ok(dailyList);
+ }
+
+ /**
+ * 酸轧线 7 大损失汇总
+ *
+ * 路由:GET /oee/line/acid/loss7
+ * 说明:
+ * - 不接受 start/end 参数,固定返回“当前月份(1号~今天)”的当月预计算结果;
+ * - {@code topN} 用于限制返回的损失类别条数(按损失时间降序截取)。
+ */
+ @GetMapping("/acid/loss7")
+ public R> getAcidLoss7(
+ @RequestParam(required = false, defaultValue = "50") Integer topN
+ ) {
+ String yyyyMM = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
+ String loss7Key = String.format("oee:report:month:loss7:%s:SY", yyyyMM);
+
+ // 1. 优先从 Redis 读取当月 7 大损失预计算结果
+ String json = stringRedisTemplate.opsForValue().get(loss7Key);
+ List lossList;
+ if (StringUtils.isNotBlank(json)) {
+ lossList = JSON.parseArray(json, AcidOeeLoss7Vo.class);
+ } else {
+ // 2. 缓存缺失时,回退为实时计算当前月
+ String[] range = resolveDateRange(null, null);
+ lossList = acidOeeService.getLoss7Summary(range[0], range[1]);
+ }
+
+ if (topN != null && topN > 0 && lossList.size() > topN) {
+ lossList = new ArrayList<>(lossList.subList(0, topN));
+ }
+
+ return R.ok(lossList);
+ }
+
+ /**
+ * 酸轧线停机/损失事件明细(分页)
+ *
+ * 路由:GET /oee/line/acid/events
+ * 说明:
+ * - 若未传入开始/结束时间,则默认查询当月。
+ * - 当前实现为在内存中进行简单过滤与分页,后续可按需下沉到 pocket 或 Mapper。
+ */
+ @GetMapping("/acid/events")
+ public TableDataInfo getAcidEvents(
+ @RequestParam(required = false) String startTime,
+ @RequestParam(required = false) String endTime,
+ @RequestParam(required = false) String stopType,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+ @RequestParam(required = false, defaultValue = "10") Integer pageSize
+ ) {
+ // 事件明细底层按「日期」查询,这里从时间字符串中截取日期部分
+ String startDate = extractDateOrDefault(startTime, true);
+ String endDate = extractDateOrDefault(endTime, false);
+
+ List events =
+ acidOeeService.getStoppageEvents(startDate, endDate);
+
+ // 业务筛选:stopType、关键字(目前对 stopType / remark 做 contains 匹配)
+ List filtered = events.stream()
+ .filter(e -> {
+ if (StringUtils.isNotBlank(stopType) &&
+ !StringUtils.equals(stopType, e.getStopType())) {
+ return false;
+ }
+ if (StringUtils.isBlank(keyword)) {
+ return true;
+ }
+ String remark = e.getRemark();
+ String type = e.getStopType();
+ return (StringUtils.isNotBlank(remark) && remark.contains(keyword))
+ || (StringUtils.isNotBlank(type) && type.contains(keyword));
+ })
+ .collect(Collectors.toList());
+
+ long total = filtered.size();
+ int page = (pageNum == null || pageNum < 1) ? 1 : pageNum;
+ int size = (pageSize == null || pageSize < 1) ? 10 : pageSize;
+ int fromIndex = (page - 1) * size;
+ int toIndex = Math.min(fromIndex + size, filtered.size());
+
+ List pageList;
+ if (fromIndex >= filtered.size()) {
+ pageList = new ArrayList<>();
+ } else {
+ pageList = filtered.subList(fromIndex, toIndex);
+ }
+
+ TableDataInfo rsp = TableDataInfo.build();
+ rsp.setRows(pageList);
+ rsp.setTotal(total);
+ return rsp;
+ }
+
+ /**
+ * 酸轧线理论节拍回归数据
+ *
+ * 路由:GET /oee/line/acid/regression
+ * 说明:
+ * - {@code startDate} / {@code endDate} 可选,若为空则由 pocket 按“近6个月”默认处理。
+ */
+ @GetMapping("/acid/regression")
+ public R getAcidRegression(
+ @RequestParam(required = false) String startDate,
+ @RequestParam(required = false) String endDate
+ ) {
+ AcidOeeRegressionVo data = acidOeeService.getRegressionData(startDate, endDate);
+ return R.ok(data);
+ }
+
+ /**
+ * 若未显式传入日期范围,则默认当前月 [1号, 今天]。
+ */
+ private String[] resolveDateRange(String startDate, String endDate) {
+ if (StringUtils.isNotBlank(startDate) && StringUtils.isNotBlank(endDate)) {
+ return new String[]{startDate, endDate};
+ }
+ LocalDate today = LocalDate.now();
+ LocalDate firstDay = today.withDayOfMonth(1);
+ return new String[]{firstDay.format(DATE_FORMATTER), today.format(DATE_FORMATTER)};
+ }
+
+ /**
+ * 从完整时间字符串中截取 yyyy-MM-dd;若为空则回退到当月默认范围。
+ */
+ private String extractDateOrDefault(String dateTime, boolean isStart) {
+ if (StringUtils.isNotBlank(dateTime)) {
+ // 期望格式 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,统一截前 10 位
+ if (dateTime.length() >= 10) {
+ return dateTime.substring(0, 10);
+ }
+ }
+ LocalDate today = LocalDate.now();
+ LocalDate firstDay = today.withDayOfMonth(1);
+ return isStart ? firstDay.format(DATE_FORMATTER) : today.format(DATE_FORMATTER);
+ }
+
+ // ======================== 任意日期范围异步任务(酸轧线) ========================
+
+ /**
+ * 提交酸轧线 OEE 报表异步任务(任意日期范围)。
+ *
+ * 路由:POST /oee/line/acid/report-jobs
+ */
+ @PostMapping("/acid/report-jobs")
+ public R submitAcidReportJob(@RequestBody OeeReportJobSubmitBo body) {
+ if (body == null || StringUtils.isBlank(body.getStartDate()) || StringUtils.isBlank(body.getEndDate())) {
+ return R.fail("startDate/endDate 不能为空");
+ }
+
+ String jobId = UUID.randomUUID().toString().replaceAll("-", "");
+ LocalDateTime now = LocalDateTime.now();
+
+ OeeReportJobMeta meta = new OeeReportJobMeta();
+ meta.setJobId(jobId);
+ meta.setStatus("PENDING");
+ meta.setSubmittedAt(now.format(DATE_TIME_FORMATTER));
+ meta.setStartDate(body.getStartDate());
+ meta.setEndDate(body.getEndDate());
+
+ // 先写入 PENDING 状态
+ String metaKey = String.format(JOB_META_KEY_PATTERN, jobId);
+ stringRedisTemplate.opsForValue().set(metaKey, JSON.toJSONString(meta), 1, TimeUnit.DAYS);
+
+ boolean includeSummary = body.getIncludeSummary() == null || Boolean.TRUE.equals(body.getIncludeSummary());
+ boolean includeLoss7 = body.getIncludeLoss7() == null || Boolean.TRUE.equals(body.getIncludeLoss7());
+ String resultKey = String.format(JOB_RESULT_KEY_PATTERN, jobId);
+
+ // 异步执行实际计算
+ oeeReportJobService.executeAcidReportJob(
+ jobId,
+ metaKey,
+ resultKey,
+ body.getStartDate(),
+ body.getEndDate(),
+ includeSummary,
+ includeLoss7
+ );
+
+ return R.ok(meta);
+ }
+
+ /**
+ * 查询酸轧线 OEE 报表任务状态。
+ *
+ * 路由:GET /oee/line/acid/report-jobs/{jobId}
+ */
+ @GetMapping("/acid/report-jobs/{jobId}")
+ public R getAcidReportJob(@PathVariable String jobId) {
+ String metaKey = String.format(JOB_META_KEY_PATTERN, jobId);
+ String json = stringRedisTemplate.opsForValue().get(metaKey);
+ if (StringUtils.isBlank(json)) {
+ return R.fail("任务不存在或已过期");
+ }
+ OeeReportJobMeta meta = JSON.parseObject(json, OeeReportJobMeta.class);
+ return R.ok(meta);
+ }
+
+ /**
+ * 获取酸轧线 OEE 报表任务结果。
+ *
+ * 路由:GET /oee/line/acid/report-jobs/{jobId}/result
+ */
+ @GetMapping("/acid/report-jobs/{jobId}/result")
+ public R getAcidReportJobResult(@PathVariable String jobId) {
+ String metaKey = String.format(JOB_META_KEY_PATTERN, jobId);
+ String metaJson = stringRedisTemplate.opsForValue().get(metaKey);
+ if (StringUtils.isBlank(metaJson)) {
+ return R.fail("任务不存在或已过期");
+ }
+ OeeReportJobMeta meta = JSON.parseObject(metaJson, OeeReportJobMeta.class);
+ if (!"COMPLETED".equals(meta.getStatus())) {
+ return R.fail("结果未就绪,当前状态:" + meta.getStatus());
+ }
+
+ String resultKey = String.format(JOB_RESULT_KEY_PATTERN, jobId);
+ String resultJson = stringRedisTemplate.opsForValue().get(resultKey);
+ if (StringUtils.isBlank(resultJson)) {
+ return R.fail("任务结果不存在或已过期");
+ }
+
+ OeeReportJobResult result = JSON.parseObject(resultJson, OeeReportJobResult.class);
+ return R.ok(result);
+ }
+
+ // ======================== 内部 DTO ========================
+
+ @Data
+ private static class OeeReportJobSubmitBo {
+ /** 开始日期(yyyy-MM-dd) */
+ private String startDate;
+ /** 结束日期(yyyy-MM-dd) */
+ private String endDate;
+ /** 是否计算 summary(默认 true) */
+ private Boolean includeSummary;
+ /** 是否计算 loss7(默认 true) */
+ private Boolean includeLoss7;
+ /**
+ * 口径开关等扩展选项(JSON 文本或键值对),当前实现暂不解析,仅作为预留字段。
+ */
+ private String options;
+ }
+
+ @Data
+ private static class OeeReportJobMeta {
+ private String jobId;
+ /** PENDING / RUNNING / COMPLETED / FAILED / EXPIRED */
+ private String status;
+ private String submittedAt;
+ private String startedAt;
+ private String completedAt;
+ private String startDate;
+ private String endDate;
+ private String errorMessage;
+ }
+
+ @Data
+ private static class OeeReportJobResult {
+ /** 日汇总结果(用于 KPI + 趋势) */
+ private List summary;
+ /** 7 大损失结果 */
+ private List loss7;
+ }
}
-
diff --git a/klp-da/src/main/java/com/klp/da/service/OeeReportJobService.java b/klp-da/src/main/java/com/klp/da/service/OeeReportJobService.java
new file mode 100644
index 00000000..d7e2d386
--- /dev/null
+++ b/klp-da/src/main/java/com/klp/da/service/OeeReportJobService.java
@@ -0,0 +1,137 @@
+package com.klp.da.service;
+
+import com.alibaba.fastjson2.JSON;
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
+import com.klp.pocket.acid.service.IAcidOeeService;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * OEE 报表异步任务执行 Service(酸轧线)。
+ *
+ * 负责在后台线程中执行任意日期范围的 summary/loss7 计算,
+ * 并将任务状态与结果写入 Redis。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OeeReportJobService {
+
+ private final IAcidOeeService acidOeeService;
+ private final StringRedisTemplate stringRedisTemplate;
+
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+
+ /**
+ * 异步执行酸轧线报表任务。
+ *
+ * @param jobId 任务 ID
+ * @param metaKey 任务元信息 Redis key
+ * @param resultKey 任务结果 Redis key
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @param includeSummary 是否计算 summary
+ * @param includeLoss7 是否计算 loss7
+ */
+ @Async
+ public void executeAcidReportJob(
+ String jobId,
+ String metaKey,
+ String resultKey,
+ String startDate,
+ String endDate,
+ boolean includeSummary,
+ boolean includeLoss7
+ ) {
+ try {
+ // 更新状态为 RUNNING
+ OeeReportJobMeta meta = readMeta(metaKey);
+ if (meta == null) {
+ meta = new OeeReportJobMeta();
+ meta.setJobId(jobId);
+ meta.setStartDate(startDate);
+ meta.setEndDate(endDate);
+ }
+ meta.setStatus("RUNNING");
+ meta.setStartedAt(LocalDateTime.now().format(DATE_TIME_FORMATTER));
+ writeMeta(metaKey, meta);
+
+ // 实际计算
+ OeeReportJobResult result = new OeeReportJobResult();
+ if (includeSummary) {
+ List summary =
+ acidOeeService.getDailySummary(startDate, endDate);
+ result.setSummary(summary);
+ }
+ if (includeLoss7) {
+ List loss7 =
+ acidOeeService.getLoss7Summary(startDate, endDate);
+ result.setLoss7(loss7);
+ }
+
+ stringRedisTemplate.opsForValue()
+ .set(resultKey, JSON.toJSONString(result), 1, TimeUnit.DAYS);
+
+ // 标记为 COMPLETED
+ meta.setStatus("COMPLETED");
+ meta.setCompletedAt(LocalDateTime.now().format(DATE_TIME_FORMATTER));
+ writeMeta(metaKey, meta);
+ } catch (Exception e) {
+ log.error("[OeeReportJobService] executeAcidReportJob error, jobId={}", jobId, e);
+ OeeReportJobMeta meta = readMeta(metaKey);
+ if (meta == null) {
+ meta = new OeeReportJobMeta();
+ meta.setJobId(jobId);
+ meta.setStartDate(startDate);
+ meta.setEndDate(endDate);
+ }
+ meta.setStatus("FAILED");
+ meta.setCompletedAt(LocalDateTime.now().format(DATE_TIME_FORMATTER));
+ meta.setErrorMessage(e.getMessage());
+ writeMeta(metaKey, meta);
+ }
+ }
+
+ private OeeReportJobMeta readMeta(String metaKey) {
+ String json = stringRedisTemplate.opsForValue().get(metaKey);
+ if (json == null || json.isEmpty()) {
+ return null;
+ }
+ return JSON.parseObject(json, OeeReportJobMeta.class);
+ }
+
+ private void writeMeta(String metaKey, OeeReportJobMeta meta) {
+ stringRedisTemplate.opsForValue()
+ .set(metaKey, JSON.toJSONString(meta), 1, TimeUnit.DAYS);
+ }
+
+ @Data
+ private static class OeeReportJobMeta {
+ private String jobId;
+ private String status;
+ private String submittedAt;
+ private String startedAt;
+ private String completedAt;
+ private String startDate;
+ private String endDate;
+ private String errorMessage;
+ }
+
+ @Data
+ private static class OeeReportJobResult {
+ private List summary;
+ private List loss7;
+ }
+}
+
+
diff --git a/klp-da/src/main/java/com/klp/da/task/AcidOeeMonthTask.java b/klp-da/src/main/java/com/klp/da/task/AcidOeeMonthTask.java
new file mode 100644
index 00000000..c31cd34f
--- /dev/null
+++ b/klp-da/src/main/java/com/klp/da/task/AcidOeeMonthTask.java
@@ -0,0 +1,148 @@
+package com.klp.da.task;
+
+import com.alibaba.fastjson2.JSON;
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
+import com.klp.pocket.acid.service.IAcidOeeService;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 酸轧线 OEE 当月预计算任务
+ *
+ * 需求对应 docs/oee-report-design.md 第 12.2 节:
+ * - 项目启动完成后即计算当月 OEE 聚合结果并写入 Redis;
+ * - 每天凌晨 04:00 重新计算当月数据并覆盖缓存。
+ *
+ * 当前仅实现酸轧线(SY)的当月日汇总 & 7 大损失预计算;
+ * key 约定:
+ * - 汇总结果:oee:report:month:summary:{yyyyMM}:SY
+ * - 7 大损失:oee:report:month:loss7:{yyyyMM}:SY
+ * - 元信息: oee:report:month:meta:{yyyyMM}:SY
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class AcidOeeMonthTask {
+
+ /** Redis 缓存 key 模板:当月 OEE 汇总(酸轧线) */
+ private static final String SUMMARY_KEY_PATTERN = "oee:report:month:summary:%s:SY";
+
+ /** Redis 缓存 key 模板:当月 7 大损失(酸轧线) */
+ private static final String LOSS7_KEY_PATTERN = "oee:report:month:loss7:%s:SY";
+
+ /** Redis 缓存 key 模板:当月元信息(酸轧线) */
+ private static final String META_KEY_PATTERN = "oee:report:month:meta:%s:SY";
+
+ private static final DateTimeFormatter YEAR_MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
+ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_DATE;
+ private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+
+ private final IAcidOeeService acidOeeService;
+ private final StringRedisTemplate stringRedisTemplate;
+
+ /**
+ * 项目启动完成后立即计算一次当月酸轧 OEE 汇总并写入 Redis。
+ */
+ @PostConstruct
+ public void init() {
+ try {
+ computeCurrentMonth("startup");
+ } catch (Exception e) {
+ log.error("[AcidOeeMonthTask] startup compute failed", e);
+ }
+ }
+
+ /**
+ * 每天凌晨 04:00 重新计算当月酸轧 OEE 汇总并覆盖 Redis 缓存。
+ */
+ @Scheduled(cron = "0 0 4 * * ?")
+ public void scheduleDaily() {
+ try {
+ computeCurrentMonth("schedule-04");
+ } catch (Exception e) {
+ log.error("[AcidOeeMonthTask] 4am compute failed", e);
+ }
+ }
+
+ /**
+ * 计算当前月份(从当月1号到今天)的酸轧 OEE 日汇总,并写入 Redis。
+ *
+ * @param trigger 触发来源标记(startup / schedule-04 等)
+ */
+ private void computeCurrentMonth(String trigger) {
+ long startNs = System.nanoTime();
+
+ LocalDate now = LocalDate.now();
+ String yyyyMM = now.format(YEAR_MONTH_FMT);
+
+ LocalDate startDate = now.withDayOfMonth(1);
+ LocalDate endDate = now;
+
+ String startStr = startDate.format(DATE_FMT);
+ String endStr = endDate.format(DATE_FMT);
+
+ log.info("[AcidOeeMonthTask] trigger={}, computing acid OEE month summary for {} ({} ~ {})",
+ trigger, yyyyMM, startStr, endStr);
+
+ // 1. 调用 pocket 的 AcidOeeService 获取当月日汇总 & 7 大损失
+ List dailySummaryList = acidOeeService.getDailySummary(startStr, endStr);
+ List loss7List = acidOeeService.getLoss7Summary(startStr, endStr);
+
+ // 2. 写入 Redis(summary)
+ String summaryKey = String.format(SUMMARY_KEY_PATTERN, yyyyMM);
+ String summaryJson = JSON.toJSONString(dailySummaryList);
+ stringRedisTemplate.opsForValue().set(summaryKey, summaryJson, 1, TimeUnit.DAYS);
+
+ // 2.1 写入 Redis(loss7)
+ String loss7Key = String.format(LOSS7_KEY_PATTERN, yyyyMM);
+ String loss7Json = JSON.toJSONString(loss7List);
+ stringRedisTemplate.opsForValue().set(loss7Key, loss7Json, 1, TimeUnit.DAYS);
+
+ long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
+
+ // 3. 写入 Redis(meta)
+ Meta meta = new Meta();
+ meta.setComputedAt(LocalDateTime.now().format(DATE_TIME_FMT));
+ meta.setDurationMs(durationMs);
+ meta.setStartDate(startStr);
+ meta.setEndDate(endStr);
+ meta.setTrigger(trigger);
+
+ String metaKey = String.format(META_KEY_PATTERN, yyyyMM);
+ stringRedisTemplate.opsForValue().set(metaKey, JSON.toJSONString(meta), 1, TimeUnit.DAYS);
+
+ log.info("[AcidOeeMonthTask] compute finish for {} dailySize={}, loss7Size={}, durationMs={}ms, summaryKey={}",
+ yyyyMM, dailySummaryList.size(), loss7List.size(), durationMs, summaryKey);
+ }
+
+ /**
+ * 当月预计算元信息
+ */
+ @Data
+ private static class Meta {
+ /** 计算完成时间(ISO-8601 字符串) */
+ private String computedAt;
+ /** 计算耗时(毫秒) */
+ private long durationMs;
+ /** 统计起始日期(yyyy-MM-dd) */
+ private String startDate;
+ /** 统计结束日期(yyyy-MM-dd) */
+ private String endDate;
+ /** 触发来源(startup / schedule-04 等) */
+ private String trigger;
+ }
+}
+
+
diff --git a/klp-pocket/pom.xml b/klp-pocket/pom.xml
index c25f61d9..973e7daf 100644
--- a/klp-pocket/pom.xml
+++ b/klp-pocket/pom.xml
@@ -24,5 +24,11 @@
klp-system
0.8.3
+
+
+ com.klp
+ klp-wms
+ 0.8.3
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeDailySummaryVo.java b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeDailySummaryVo.java
new file mode 100644
index 00000000..abac929f
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeDailySummaryVo.java
@@ -0,0 +1,81 @@
+package com.klp.pocket.acid.domain.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 酸轧线OEE日汇总视图对象
+ * 用于按日、按产线聚合的KPI与趋势数据
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+@Data
+public class AcidOeeDailySummaryVo {
+
+ /** 统计日期(yyyy-MM-dd) */
+ private String statDate;
+
+ /** 产线ID(固定为 SY) */
+ private String lineId;
+
+ /** 产线名称(酸轧线) */
+ private String lineName;
+
+ /** 计划时间(min,可选) */
+ private Long plannedTimeMin;
+
+ /** 计划停机(min,可选;若无则置 0) */
+ private Long plannedDowntimeMin;
+
+ /** 负荷时间(min)= planned_time_min - planned_downtime_min */
+ private Long loadingTimeMin;
+
+ /** 停机时间(min,来自停机事件汇总) */
+ private Long downtimeMin;
+
+ /** 实际运转时间(min)= loading_time_min - downtime_min */
+ private Long runTimeMin;
+
+ /** 总产量(吨) */
+ private BigDecimal totalOutputTon;
+
+ /** 总产量(卷) */
+ private Long totalOutputCoil;
+
+ /** 良品量(吨) */
+ private BigDecimal goodOutputTon;
+
+ /** 良品量(卷) */
+ private Long goodOutputCoil;
+
+ /** 不良量(吨)= total_output_ton - good_output_ton */
+ private BigDecimal defectOutputTon;
+
+ /** 不良量(卷)= total_output_coil - good_output_coil */
+ private Long defectOutputCoil;
+
+ /** 理论节拍(min/吨;回归斜率) */
+ private BigDecimal idealCycleTimeMinPerTon;
+
+ /** 理论节拍(min/卷;回归斜率) */
+ private BigDecimal idealCycleTimeMinPerCoil;
+
+ /** 派生指标:时间稼动率(0~1 或 0~100) */
+ private BigDecimal availability;
+
+ /** 派生指标:性能稼动率(吨维度) */
+ private BigDecimal performanceTon;
+
+ /** 派生指标:性能稼动率(卷维度) */
+ private BigDecimal performanceCoil;
+
+ /** 派生指标:良品率 */
+ private BigDecimal quality;
+
+ /** 派生指标:OEE(建议以吨维度为主) */
+ private BigDecimal oee;
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeLoss7Vo.java b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeLoss7Vo.java
new file mode 100644
index 00000000..ddbef824
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeLoss7Vo.java
@@ -0,0 +1,34 @@
+package com.klp.pocket.acid.domain.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 酸轧线OEE 7大损失汇总视图对象
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+@Data
+public class AcidOeeLoss7Vo {
+
+ /** 损失类别编码(1-7,或直接使用 stop_type 名称) */
+ private String lossCategoryCode;
+
+ /** 损失类别名称 */
+ private String lossCategoryName;
+
+ /** 损失时间(分钟) */
+ private Long lossTimeMin;
+
+ /** 损失时间占比(%) */
+ private BigDecimal lossTimeRate;
+
+ /** 发生次数(部分分类可能为空) */
+ private Integer count;
+
+ /** 平均时长(分钟,部分分类可能为空) */
+ private BigDecimal avgDurationMin;
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeRegressionVo.java b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeRegressionVo.java
new file mode 100644
index 00000000..b7312f43
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/domain/vo/AcidOeeRegressionVo.java
@@ -0,0 +1,81 @@
+package com.klp.pocket.acid.domain.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 酸轧线OEE回归数据视图对象
+ * 用于理论节拍计算和前端散点图展示
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+@Data
+public class AcidOeeRegressionVo {
+
+ /** 产线ID(固定为 SY) */
+ private String lineId;
+
+ /** 产线名称(酸轧线) */
+ private String lineName;
+
+ /** 回归斜率:分钟/吨(核心值,可作为理论节拍) */
+ private BigDecimal slopeMinPerTon;
+
+ /** 回归斜率:分钟/卷(核心值,可作为理论节拍) */
+ private BigDecimal slopeMinPerCoil;
+
+ /** 截距(分钟) */
+ private BigDecimal interceptMin;
+
+ /** 拟合优度(R²) */
+ private BigDecimal r2;
+
+ /** 参与回归样本数 */
+ private Integer sampleCount;
+
+ /** 回归数据开始时间 */
+ private String startTime;
+
+ /** 回归数据结束时间 */
+ private String endTime;
+
+ /** 散点列表 */
+ private List points;
+
+ /** 拟合线两个端点(前端可直接画线) */
+ private List linePoints;
+
+ /**
+ * 散点数据
+ */
+ @Data
+ public static class RegressionPointVo {
+ /** 重量(吨,X轴) */
+ private BigDecimal weightTon;
+ /** 卷数(X轴) */
+ private Long coilCount;
+ /** 时长(分钟,Y轴) */
+ private Long durationMin;
+ /** 关联的actionId(可选) */
+ private String actionId;
+ /** 创建时间 */
+ private String createTime;
+ }
+
+ /**
+ * 拟合线端点
+ */
+ @Data
+ public static class RegressionLinePointVo {
+ /** 重量(吨,X轴) */
+ private BigDecimal weightTon;
+ /** 卷数(X轴) */
+ private Long coilCount;
+ /** 时长(分钟,Y轴) */
+ private BigDecimal durationMin;
+ }
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/mapper/AcidOeeMapper.java b/klp-pocket/src/main/java/com/klp/pocket/acid/mapper/AcidOeeMapper.java
new file mode 100644
index 00000000..1a36ef42
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/mapper/AcidOeeMapper.java
@@ -0,0 +1,67 @@
+package com.klp.pocket.acid.mapper;
+
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeRegressionVo;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酸轧线OEE Mapper接口
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+@Mapper
+public interface AcidOeeMapper {
+
+ /**
+ * 查询OEE日汇总(按日期范围)
+ * 聚合产量(吨/卷)、停机时间等
+ *
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @return 日汇总列表
+ */
+ List selectDailySummary(@Param("startDate") String startDate,
+ @Param("endDate") String endDate);
+
+ /**
+ * 查询回归数据散点(用于理论节拍计算)
+ * 返回:重量(吨)、卷数、时长(分钟)等
+ *
+ * @param startDate 开始日期(yyyy-MM-dd,可选)
+ * @param endDate 结束日期(yyyy-MM-dd,可选)
+ * @return 散点列表
+ */
+ List selectRegressionPoints(@Param("startDate") String startDate,
+ @Param("endDate") String endDate);
+
+ /**
+ * 查询每日的钢卷号和重量(用于良品/次品判定)
+ *
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @return Map列表,key为日期,value为卷号和重量信息
+ */
+ List selectCoilInfoByDate(@Param("startDate") String startDate,
+ @Param("endDate") String endDate);
+
+ /**
+ * 卷号信息内部类(用于Mapper返回)
+ */
+ class CoilInfoByDate {
+ private String statDate;
+ private String coilNo;
+ private java.math.BigDecimal weight;
+
+ public String getStatDate() { return statDate; }
+ public void setStatDate(String statDate) { this.statDate = statDate; }
+ public String getCoilNo() { return coilNo; }
+ public void setCoilNo(String coilNo) { this.coilNo = coilNo; }
+ public java.math.BigDecimal getWeight() { return weight; }
+ public void setWeight(java.math.BigDecimal weight) { this.weight = weight; }
+ }
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/service/IAcidOeeService.java b/klp-pocket/src/main/java/com/klp/pocket/acid/service/IAcidOeeService.java
new file mode 100644
index 00000000..545d0a91
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/service/IAcidOeeService.java
@@ -0,0 +1,56 @@
+package com.klp.pocket.acid.service;
+
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
+import com.klp.pocket.acid.domain.vo.AcidOeeRegressionVo;
+import com.klp.pocket.acid.domain.vo.Klptcm1ProStoppageVo;
+
+import java.util.List;
+
+/**
+ * 酸轧线OEE Service接口
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+public interface IAcidOeeService {
+
+ /**
+ * 查询OEE日汇总(按日期范围)
+ * 包含:产量(吨/卷)、停机时间、良品/次品、派生指标等
+ *
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @return 日汇总列表(按日期排序)
+ */
+ List getDailySummary(String startDate, String endDate);
+
+ /**
+ * 查询停机事件列表(用于OEE明细和7大损失)
+ *
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @return 停机事件列表
+ */
+ List getStoppageEvents(String startDate, String endDate);
+
+ /**
+ * 查询理论节拍回归数据(吨和卷两个维度)
+ * 用于性能稼动率计算和前端散点图展示
+ *
+ * @param startDate 开始日期(yyyy-MM-dd,可选,默认近6个月)
+ * @param endDate 结束日期(yyyy-MM-dd,可选)
+ * @return 回归数据(包含斜率、截距、散点等)
+ */
+ AcidOeeRegressionVo getRegressionData(String startDate, String endDate);
+
+ /**
+ * 查询7大损失汇总(按日期范围)
+ *
+ * @param startDate 开始日期(yyyy-MM-dd)
+ * @param endDate 结束日期(yyyy-MM-dd)
+ * @return 7大损失汇总列表
+ */
+ List getLoss7Summary(String startDate, String endDate);
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/acid/service/impl/AcidOeeServiceImpl.java b/klp-pocket/src/main/java/com/klp/pocket/acid/service/impl/AcidOeeServiceImpl.java
new file mode 100644
index 00000000..ab8fe2a0
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/acid/service/impl/AcidOeeServiceImpl.java
@@ -0,0 +1,503 @@
+package com.klp.pocket.acid.service.impl;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.klp.common.utils.StringUtils;
+import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
+import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
+import com.klp.pocket.acid.domain.vo.AcidOeeRegressionVo;
+import com.klp.pocket.acid.domain.vo.Klptcm1ProStoppageVo;
+import com.klp.pocket.acid.domain.bo.Klptcm1ProStoppageBo;
+import com.klp.pocket.acid.mapper.AcidOeeMapper;
+import com.klp.pocket.acid.service.IAcidOeeService;
+import com.klp.pocket.acid.service.IKlptcm1ProStoppageService;
+import com.klp.pocket.common.service.ICoilQualityJudgeService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 酸轧线OEE Service实现类
+ *
+ * @author klp
+ * @date 2026-01-30
+ */
+@Slf4j
+@RequiredArgsConstructor
+@DS("acid")
+@Service
+public class AcidOeeServiceImpl implements IAcidOeeService {
+
+ /** 酸轧成品库库区ID */
+ private static final Long ACID_FINISHED_WAREHOUSE_ID = 1988150099140866050L;
+
+ private final AcidOeeMapper acidOeeMapper;
+ private final IKlptcm1ProStoppageService stoppageService;
+ private final ICoilQualityJudgeService coilQualityJudgeService;
+
+ @Override
+ public List getDailySummary(String startDate, String endDate) {
+ // 1. 查询基础日汇总(产量、停机时间等)
+ List summaries = acidOeeMapper.selectDailySummary(startDate, endDate);
+
+ if (summaries == null || summaries.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 2. 查询停机事件,按日期聚合停机时间
+ Map downtimeByDate = aggregateDowntimeByDate(startDate, endDate);
+
+ // 3. 查询产量明细,用于良品/次品判定
+ Map> coilInfoByDate = getCoilNosByDate(startDate, endDate);
+
+ // 4. 填充每个日汇总的完整数据
+ for (AcidOeeDailySummaryVo summary : summaries) {
+ String statDate = summary.getStatDate();
+ summary.setLineId("SY");
+ summary.setLineName("酸轧线");
+
+ // 填充停机时间
+ Long downtime = downtimeByDate.getOrDefault(statDate, 0L);
+ summary.setDowntimeMin(downtime);
+
+ // 计算运转时间
+ Long loadingTime = summary.getLoadingTimeMin() != null ? summary.getLoadingTimeMin() : 0L;
+ Long runTime = Math.max(0, loadingTime - downtime);
+ summary.setRunTimeMin(runTime);
+
+ // 良品/次品判定(通过WMS)
+ if (coilInfoByDate.containsKey(statDate)) {
+ List coilInfos = coilInfoByDate.get(statDate);
+ calculateQualityOutput(summary, coilInfos);
+ } else {
+ // 如果没有卷号,默认全部为良品(或根据业务规则处理)
+ summary.setGoodOutputTon(summary.getTotalOutputTon());
+ summary.setGoodOutputCoil(summary.getTotalOutputCoil());
+ summary.setDefectOutputTon(BigDecimal.ZERO);
+ summary.setDefectOutputCoil(0L);
+ }
+
+ // 填充理论节拍(从回归数据或缓存获取,这里暂时留空,由调用方填充)
+ // summary.setIdealCycleTimeMinPerTon(...);
+ // summary.setIdealCycleTimeMinPerCoil(...);
+
+ // 计算派生指标
+ calculateDerivedMetrics(summary);
+ }
+
+ return summaries;
+ }
+
+ @Override
+ public List getStoppageEvents(String startDate, String endDate) {
+ Klptcm1ProStoppageBo bo = new Klptcm1ProStoppageBo();
+ bo.setStartDate(parseDate(startDate));
+ bo.setEndDate(parseDate(endDate));
+ return stoppageService.queryList(bo);
+ }
+
+ @Override
+ public AcidOeeRegressionVo getRegressionData(String startDate, String endDate) {
+ // 1. 查询散点数据
+ List points = acidOeeMapper.selectRegressionPoints(startDate, endDate);
+
+ AcidOeeRegressionVo result = new AcidOeeRegressionVo();
+ result.setLineId("SY");
+ result.setLineName("酸轧线");
+ result.setStartTime(startDate);
+ result.setEndTime(endDate);
+ result.setPoints(points);
+ result.setSampleCount(points != null ? points.size() : 0);
+
+ if (points == null || points.isEmpty()) {
+ // 没有数据时返回空结果
+ return result;
+ }
+
+ // 2. 计算回归(吨维度)
+ RegressionResult tonResult = calculateRegression(
+ points.stream().map(AcidOeeRegressionVo.RegressionPointVo::getWeightTon).filter(Objects::nonNull).collect(Collectors.toList()),
+ points.stream().map(AcidOeeRegressionVo.RegressionPointVo::getDurationMin).filter(Objects::nonNull).collect(Collectors.toList())
+ );
+ if (tonResult != null) {
+ result.setSlopeMinPerTon(tonResult.slope);
+ result.setInterceptMin(tonResult.intercept);
+ result.setR2(tonResult.r2);
+ }
+
+ // 3. 计算回归(卷维度)
+ RegressionResult coilResult = calculateRegression(
+ points.stream().map(p -> p.getCoilCount() != null ? BigDecimal.valueOf(p.getCoilCount()) : null).filter(Objects::nonNull).collect(Collectors.toList()),
+ points.stream().map(AcidOeeRegressionVo.RegressionPointVo::getDurationMin).filter(Objects::nonNull).collect(Collectors.toList())
+ );
+ if (coilResult != null) {
+ result.setSlopeMinPerCoil(coilResult.slope);
+ }
+
+ // 4. 生成拟合线端点(用于前端画线)
+ if (tonResult != null && !points.isEmpty()) {
+ List linePoints = generateLinePoints(points, tonResult);
+ result.setLinePoints(linePoints);
+ }
+
+ return result;
+ }
+
+ @Override
+ public List getLoss7Summary(String startDate, String endDate) {
+ // 1. 查询停机事件(含 stopType、duration 等)
+ List events = getStoppageEvents(startDate, endDate);
+ if (events == null || events.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 2. 按 stopType 分组汇总
+ Map statsByType = new HashMap<>();
+ long totalLossMin = 0L;
+
+ for (Klptcm1ProStoppageVo event : events) {
+ String stopType = event.getStopType();
+ if (StringUtils.isBlank(stopType)) {
+ // 没有类型的记录暂时忽略
+ continue;
+ }
+ Long durationSec = event.getDuration();
+ if (durationSec == null || durationSec <= 0) {
+ continue;
+ }
+ long durationMin = durationSec / 60;
+ if (durationMin <= 0) {
+ durationMin = 1; // 最小记 1 分钟,避免全为 0
+ }
+
+ LossStats stats = statsByType.computeIfAbsent(stopType, k -> new LossStats());
+ stats.totalMin += durationMin;
+ stats.count++;
+ totalLossMin += durationMin;
+ }
+
+ if (statsByType.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 3. 组装 VO 列表
+ List result = new ArrayList<>();
+ for (Map.Entry entry : statsByType.entrySet()) {
+ String stopType = entry.getKey();
+ LossStats stats = entry.getValue();
+
+ AcidOeeLoss7Vo vo = new AcidOeeLoss7Vo();
+ vo.setLossCategoryCode(stopType);
+ vo.setLossCategoryName(stopType);
+ vo.setLossTimeMin(stats.totalMin);
+
+ if (totalLossMin > 0) {
+ BigDecimal rate = BigDecimal.valueOf(stats.totalMin)
+ .divide(BigDecimal.valueOf(totalLossMin), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100));
+ vo.setLossTimeRate(rate);
+ } else {
+ vo.setLossTimeRate(BigDecimal.ZERO);
+ }
+
+ vo.setCount(stats.count);
+ if (stats.count > 0) {
+ BigDecimal avg = BigDecimal.valueOf(stats.totalMin)
+ .divide(BigDecimal.valueOf(stats.count), 2, RoundingMode.HALF_UP);
+ vo.setAvgDurationMin(avg);
+ } else {
+ vo.setAvgDurationMin(BigDecimal.ZERO);
+ }
+
+ result.add(vo);
+ }
+
+ // 4. 按损失时间从大到小排序
+ result.sort(Comparator.comparingLong(AcidOeeLoss7Vo::getLossTimeMin).reversed());
+
+ return result;
+ }
+
+ /**
+ * 按日期聚合停机时间
+ */
+ private Map aggregateDowntimeByDate(String startDate, String endDate) {
+ List events = getStoppageEvents(startDate, endDate);
+ Map downtimeMap = new HashMap<>();
+
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+ for (Klptcm1ProStoppageVo event : events) {
+ if (event.getStartDate() != null && event.getDuration() != null) {
+ String date = dateFormat.format(event.getStartDate());
+ // duration单位是秒,转换为分钟
+ Long minutes = event.getDuration() / 60;
+ downtimeMap.merge(date, minutes, Long::sum);
+ }
+ }
+
+ return downtimeMap;
+ }
+
+ /**
+ * 获取每日的钢卷号和重量(用于良品/次品判定)
+ */
+ private Map> getCoilNosByDate(String startDate, String endDate) {
+ List coilInfoList = acidOeeMapper.selectCoilInfoByDate(startDate, endDate);
+ Map> result = new HashMap<>();
+
+ for (AcidOeeMapper.CoilInfoByDate info : coilInfoList) {
+ String date = info.getStatDate();
+ result.computeIfAbsent(date, k -> new ArrayList<>())
+ .add(new CoilInfo(info.getCoilNo(), info.getWeight()));
+ }
+
+ return result;
+ }
+
+ /**
+ * 卷号信息内部类
+ */
+ private static class CoilInfo {
+ final String coilNo;
+ final BigDecimal weight;
+
+ CoilInfo(String coilNo, BigDecimal weight) {
+ this.coilNo = coilNo;
+ this.weight = weight;
+ }
+ }
+
+ /**
+ * 计算良品/次品产量
+ */
+ private void calculateQualityOutput(AcidOeeDailySummaryVo summary, List coilInfos) {
+ BigDecimal goodTon = BigDecimal.ZERO;
+ long goodCoil = 0L;
+ BigDecimal defectTon = BigDecimal.ZERO;
+ long defectCoil = 0L;
+
+ for (CoilInfo coilInfo : coilInfos) {
+ String coilNo = coilInfo.coilNo;
+ BigDecimal coilWeight = coilInfo.weight != null ? coilInfo.weight : BigDecimal.ZERO;
+
+ // 通过WMS判定良品/次品
+ Boolean isScrap = coilQualityJudgeService.isScrap(ACID_FINISHED_WAREHOUSE_ID, coilNo);
+ if (isScrap == null) {
+ // 匹配不到,忽略不计
+ continue;
+ }
+
+ if (Boolean.TRUE.equals(isScrap)) {
+ // 次品
+ defectTon = defectTon.add(coilWeight);
+ defectCoil++;
+ } else {
+ // 良品
+ goodTon = goodTon.add(coilWeight);
+ goodCoil++;
+ }
+ }
+
+ summary.setGoodOutputTon(goodTon);
+ summary.setGoodOutputCoil(goodCoil);
+ summary.setDefectOutputTon(defectTon);
+ summary.setDefectOutputCoil(defectCoil);
+ }
+
+ /**
+ * 计算派生指标(时间稼动率、性能稼动率、良品率、OEE)
+ */
+ private void calculateDerivedMetrics(AcidOeeDailySummaryVo summary) {
+ // 时间稼动率
+ Long loadingTime = summary.getLoadingTimeMin() != null ? summary.getLoadingTimeMin() : 0L;
+ Long downtime = summary.getDowntimeMin() != null ? summary.getDowntimeMin() : 0L;
+ if (loadingTime > 0) {
+ BigDecimal availability = BigDecimal.valueOf(loadingTime - downtime)
+ .divide(BigDecimal.valueOf(loadingTime), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100));
+ summary.setAvailability(availability);
+ }
+
+ // 性能稼动率(吨维度)
+ Long runTime = summary.getRunTimeMin() != null ? summary.getRunTimeMin() : 0L;
+ BigDecimal idealCycleTon = summary.getIdealCycleTimeMinPerTon();
+ BigDecimal totalOutputTon = summary.getTotalOutputTon();
+ if (runTime > 0 && idealCycleTon != null && totalOutputTon != null && totalOutputTon.compareTo(BigDecimal.ZERO) > 0) {
+ BigDecimal idealTime = idealCycleTon.multiply(totalOutputTon);
+ BigDecimal performanceTon = idealTime.divide(BigDecimal.valueOf(runTime), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100));
+ summary.setPerformanceTon(performanceTon);
+ }
+
+ // 性能稼动率(卷维度)
+ Long totalOutputCoil = summary.getTotalOutputCoil() != null ? summary.getTotalOutputCoil() : 0L;
+ BigDecimal idealCycleCoil = summary.getIdealCycleTimeMinPerCoil();
+ if (runTime > 0 && idealCycleCoil != null && totalOutputCoil > 0) {
+ BigDecimal idealTime = idealCycleCoil.multiply(BigDecimal.valueOf(totalOutputCoil));
+ BigDecimal performanceCoil = idealTime.divide(BigDecimal.valueOf(runTime), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100));
+ summary.setPerformanceCoil(performanceCoil);
+ }
+
+ // 良品率
+ if (totalOutputTon != null && totalOutputTon.compareTo(BigDecimal.ZERO) > 0) {
+ BigDecimal goodOutputTon = summary.getGoodOutputTon() != null ? summary.getGoodOutputTon() : BigDecimal.ZERO;
+ BigDecimal quality = goodOutputTon.divide(totalOutputTon, 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100));
+ summary.setQuality(quality);
+ }
+
+ // OEE(以吨维度为主)
+ BigDecimal availability = summary.getAvailability();
+ BigDecimal performanceTon = summary.getPerformanceTon();
+ BigDecimal quality = summary.getQuality();
+ if (availability != null && performanceTon != null && quality != null) {
+ BigDecimal oee = availability.multiply(performanceTon).multiply(quality)
+ .divide(BigDecimal.valueOf(10000), 4, RoundingMode.HALF_UP);
+ summary.setOee(oee);
+ }
+ }
+
+ /**
+ * 计算线性回归(最小二乘法)
+ */
+ private RegressionResult calculateRegression(List xValues, List yValues) {
+ if (xValues.size() != yValues.size() || xValues.isEmpty()) {
+ return null;
+ }
+
+ int n = xValues.size();
+ BigDecimal sumX = BigDecimal.ZERO;
+ BigDecimal sumY = BigDecimal.ZERO;
+ BigDecimal sumXY = BigDecimal.ZERO;
+ BigDecimal sumX2 = BigDecimal.ZERO;
+
+ for (int i = 0; i < n; i++) {
+ BigDecimal x = xValues.get(i);
+ BigDecimal y = BigDecimal.valueOf(yValues.get(i));
+ sumX = sumX.add(x);
+ sumY = sumY.add(y);
+ sumXY = sumXY.add(x.multiply(y));
+ sumX2 = sumX2.add(x.multiply(x));
+ }
+
+ BigDecimal nDecimal = BigDecimal.valueOf(n);
+ BigDecimal denominator = nDecimal.multiply(sumX2).subtract(sumX.multiply(sumX));
+ if (denominator.compareTo(BigDecimal.ZERO) == 0) {
+ return null;
+ }
+
+ // slope = (n*ΣXY - ΣX*ΣY) / (n*ΣX² - (ΣX)²)
+ BigDecimal slope = nDecimal.multiply(sumXY).subtract(sumX.multiply(sumY))
+ .divide(denominator, 6, RoundingMode.HALF_UP);
+
+ // intercept = (ΣY - slope*ΣX) / n
+ BigDecimal intercept = sumY.subtract(slope.multiply(sumX))
+ .divide(nDecimal, 6, RoundingMode.HALF_UP);
+
+ // 计算R²
+ BigDecimal meanY = sumY.divide(nDecimal, 6, RoundingMode.HALF_UP);
+ BigDecimal ssTotal = BigDecimal.ZERO;
+ BigDecimal ssResidual = BigDecimal.ZERO;
+
+ for (int i = 0; i < n; i++) {
+ BigDecimal x = xValues.get(i);
+ BigDecimal y = BigDecimal.valueOf(yValues.get(i));
+ BigDecimal predictedY = slope.multiply(x).add(intercept);
+ BigDecimal diff = y.subtract(meanY);
+ ssTotal = ssTotal.add(diff.multiply(diff));
+ BigDecimal residual = y.subtract(predictedY);
+ ssResidual = ssResidual.add(residual.multiply(residual));
+ }
+
+ BigDecimal r2 = BigDecimal.ONE;
+ if (ssTotal.compareTo(BigDecimal.ZERO) > 0) {
+ r2 = BigDecimal.ONE.subtract(ssResidual.divide(ssTotal, 6, RoundingMode.HALF_UP));
+ }
+
+ return new RegressionResult(slope, intercept, r2);
+ }
+
+ /**
+ * 生成拟合线端点
+ */
+ private List generateLinePoints(
+ List points,
+ RegressionResult result) {
+ if (points.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 找到X轴的最小值和最大值
+ BigDecimal minX = points.stream()
+ .map(AcidOeeRegressionVo.RegressionPointVo::getWeightTon)
+ .filter(Objects::nonNull)
+ .min(BigDecimal::compareTo)
+ .orElse(BigDecimal.ZERO);
+
+ BigDecimal maxX = points.stream()
+ .map(AcidOeeRegressionVo.RegressionPointVo::getWeightTon)
+ .filter(Objects::nonNull)
+ .max(BigDecimal::compareTo)
+ .orElse(BigDecimal.ZERO);
+
+ // 计算对应的Y值
+ BigDecimal y1 = result.slope.multiply(minX).add(result.intercept);
+ BigDecimal y2 = result.slope.multiply(maxX).add(result.intercept);
+
+ AcidOeeRegressionVo.RegressionLinePointVo p1 = new AcidOeeRegressionVo.RegressionLinePointVo();
+ p1.setWeightTon(minX);
+ p1.setDurationMin(y1);
+
+ AcidOeeRegressionVo.RegressionLinePointVo p2 = new AcidOeeRegressionVo.RegressionLinePointVo();
+ p2.setWeightTon(maxX);
+ p2.setDurationMin(y2);
+
+ return Arrays.asList(p1, p2);
+ }
+
+ /**
+ * 解析日期字符串为Date对象
+ */
+ private Date parseDate(String dateStr) {
+ if (StringUtils.isBlank(dateStr)) {
+ return null;
+ }
+ try {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.parse(dateStr);
+ } catch (Exception e) {
+ log.warn("解析日期失败: {}", dateStr, e);
+ return null;
+ }
+ }
+
+ /**
+ * 回归结果内部类
+ */
+ private static class RegressionResult {
+ final BigDecimal slope;
+ final BigDecimal intercept;
+ final BigDecimal r2;
+
+ RegressionResult(BigDecimal slope, BigDecimal intercept, BigDecimal r2) {
+ this.slope = slope;
+ this.intercept = intercept;
+ this.r2 = r2;
+ }
+ }
+
+ /**
+ * 内部统计类:某一 stopType 的总损失时间与次数
+ */
+ private static class LossStats {
+ long totalMin = 0L;
+ int count = 0;
+ }
+}
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/common/service/ICoilQualityJudgeService.java b/klp-pocket/src/main/java/com/klp/pocket/common/service/ICoilQualityJudgeService.java
new file mode 100644
index 00000000..2ac77e73
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/common/service/ICoilQualityJudgeService.java
@@ -0,0 +1,22 @@
+package com.klp.pocket.common.service;
+
+/**
+ * 钢卷良品/次品判定(OEE 口径用)。
+ *
+ * 规则:
+ * - 以 WMS 的 wms_material_coil 为准
+ * - 按“当前钢卷号(current_coil_no) + 所在库区ID(warehouse_id)”精确匹配
+ * - quality_status 命中 {"C+","C","C-","D+","D","D-"} => 次品;否则 => 良品
+ * - 若匹配不到(WMS 无记录),返回 null,调用方按“忽略不计”处理
+ */
+public interface ICoilQualityJudgeService {
+
+ /**
+ * @param warehouseId 所在库区ID(成品库库区ID)
+ * @param currentCoilNo 当前钢卷号(注意:pocket 侧查询到的“钢卷id/卷号”口径等同于该字段)
+ * @return Boolean:true=次品,false=良品,null=匹配不到(忽略不计)
+ */
+ Boolean isScrap(Long warehouseId, String currentCoilNo);
+}
+
+
diff --git a/klp-pocket/src/main/java/com/klp/pocket/common/service/impl/CoilQualityJudgeServiceImpl.java b/klp-pocket/src/main/java/com/klp/pocket/common/service/impl/CoilQualityJudgeServiceImpl.java
new file mode 100644
index 00000000..43761248
--- /dev/null
+++ b/klp-pocket/src/main/java/com/klp/pocket/common/service/impl/CoilQualityJudgeServiceImpl.java
@@ -0,0 +1,41 @@
+package com.klp.pocket.common.service.impl;
+
+import com.klp.common.utils.StringUtils;
+import com.klp.pocket.common.service.ICoilQualityJudgeService;
+import com.klp.service.IWmsMaterialCoilService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+@RequiredArgsConstructor
+@Service
+public class CoilQualityJudgeServiceImpl implements ICoilQualityJudgeService {
+
+ /**
+ * 次品状态枚举:命中这些 quality_status 则判定为次品,否则判定为良品。
+ */
+ private static final Set SCRAP_QUALITY_STATUS = new HashSet<>(
+ Arrays.asList("C+", "C", "C-", "D+", "D", "D-")
+ );
+
+ private final IWmsMaterialCoilService wmsMaterialCoilService;
+
+ @Override
+ public Boolean isScrap(Long warehouseId, String currentCoilNo) {
+ if (warehouseId == null || StringUtils.isBlank(currentCoilNo)) {
+ return null;
+ }
+ String qualityStatus = wmsMaterialCoilService
+ .queryQualityStatusByWarehouseIdAndCurrentCoilNo(warehouseId, currentCoilNo);
+ if (StringUtils.isBlank(qualityStatus)) {
+ // WMS 匹配不到或字段为空:按“忽略不计”
+ return null;
+ }
+ return SCRAP_QUALITY_STATUS.contains(qualityStatus.trim());
+ }
+}
+
+
diff --git a/klp-pocket/src/main/resources/mapper/pocket/AcidOeeMapper.xml b/klp-pocket/src/main/resources/mapper/pocket/AcidOeeMapper.xml
new file mode 100644
index 00000000..35d15297
--- /dev/null
+++ b/klp-pocket/src/main/resources/mapper/pocket/AcidOeeMapper.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/klp-wms/src/main/java/com/klp/service/IWmsMaterialCoilService.java b/klp-wms/src/main/java/com/klp/service/IWmsMaterialCoilService.java
index 7a4fe42b..e21263a1 100644
--- a/klp-wms/src/main/java/com/klp/service/IWmsMaterialCoilService.java
+++ b/klp-wms/src/main/java/com/klp/service/IWmsMaterialCoilService.java
@@ -108,6 +108,19 @@ public interface IWmsMaterialCoilService {
*/
List queryDeliveryExportList(WmsMaterialCoilBo bo);
+ /**
+ * 按“所在库区 + 当前钢卷号”精确查询钢卷质量状态(用于良品/次品口径判定)。
+ *
+ * 说明:
+ * - 仅匹配 data_type=1 且 del_flag=0 的当前有效数据
+ * - 若匹配不到,返回 null(调用方按“忽略不计”处理)
+ *
+ * @param warehouseId 所在库区ID(wms_material_coil.warehouse_id)
+ * @param currentCoilNo 当前钢卷号(wms_material_coil.current_coil_no)
+ * @return qualityStatus(如 "C+","C","C-","D+","D","D-"),或 null
+ */
+ String queryQualityStatusByWarehouseIdAndCurrentCoilNo(Long warehouseId, String currentCoilNo);
+
int exportCoil(@NotEmpty(message = "主键不能为空") Long coilId);
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 5738cbab..cac5bbec 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
@@ -3,7 +3,6 @@ package com.klp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
-import com.esotericsoftware.minlog.Log;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.klp.common.core.domain.entity.SysUser;
import com.klp.common.core.page.TableDataInfo;
@@ -12,11 +11,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import com.klp.common.exception.ServiceException;
import com.klp.common.helper.LoginHelper;
import com.klp.common.utils.DateUtils;
import com.klp.common.utils.StringUtils;
-import com.klp.common.utils.spring.SpringUtils;
import com.klp.domain.*;
import com.klp.domain.bo.*;
import com.klp.domain.vo.*;
@@ -842,6 +839,22 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
return list;
}
+ @Override
+ public String queryQualityStatusByWarehouseIdAndCurrentCoilNo(Long warehouseId, String currentCoilNo) {
+ if (warehouseId == null || StringUtils.isBlank(currentCoilNo)) {
+ return null;
+ }
+ LambdaQueryWrapper lqw = Wrappers.lambdaQuery();
+ lqw.select(WmsMaterialCoil::getQualityStatus);
+ lqw.eq(WmsMaterialCoil::getDelFlag, 0);
+ lqw.eq(WmsMaterialCoil::getDataType, 1);
+ lqw.eq(WmsMaterialCoil::getWarehouseId, warehouseId);
+ lqw.eq(WmsMaterialCoil::getCurrentCoilNo, currentCoilNo);
+ lqw.last("LIMIT 1");
+ WmsMaterialCoil one = baseMapper.selectOne(lqw);
+ return one == null ? null : one.getQualityStatus();
+ }
+
/**
* 从联查结果中构建物品对象(产品或原材料)
* 直接从VO的临时字段中获取数据构建对象,避免单独查询数据库