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的临时字段中获取数据构建对象,避免单独查询数据库