二级系统联合寻找数据

This commit is contained in:
2026-02-04 15:22:34 +08:00
parent d42b8ffef2
commit 5b3938e13f
19 changed files with 2167 additions and 352 deletions

View File

@@ -5,14 +5,23 @@ 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.OeeWordAiAnalysisService;
import com.klp.da.service.OeeReportJobService;
import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
import com.klp.pocket.acid.domain.vo.AcidOeeIdealCycleVo;
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.apache.poi.xwpf.usermodel.ParagraphAlignment;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.springframework.http.HttpHeaders;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,11 +32,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -48,6 +63,7 @@ public class OeeReportController extends BaseController {
private final IAcidOeeService acidOeeService;
private final StringRedisTemplate stringRedisTemplate;
private final OeeReportJobService oeeReportJobService;
private final OeeWordAiAnalysisService oeeWordAiAnalysisService;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@@ -177,21 +193,233 @@ public class OeeReportController extends BaseController {
}
/**
* 酸轧线理论节拍回归数据
* 酸轧线理论节拍(统计口径:历史优良日中位数)
*
* 路由GET /oee/line/acid/regression
* 说明:
* - {@code startDate} / {@code endDate} 可选,若为空则由 pocket 按“近6个月”默认处理。
* 路由GET /oee/line/acid/idealCycle
*/
@GetMapping("/acid/regression")
public R<AcidOeeRegressionVo> getAcidRegression(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate
@GetMapping("/acid/idealCycle")
public R<AcidOeeIdealCycleVo> getAcidIdealCycle(
@RequestParam String startDate,
@RequestParam String endDate
) {
AcidOeeRegressionVo data = acidOeeService.getRegressionData(startDate, endDate);
AcidOeeIdealCycleVo data = acidOeeService.getIdealCycle(startDate, endDate);
return R.ok(data);
}
/**
* 导出酸轧线 OEE 报表 Worddocx
*
* 路由GET /oee/line/acid/exportWord?startDate=yyyy-MM-dd&endDate=yyyy-MM-dd
* 说明:
* - 文档结构尽量贴近前端页面(表格为主,图表不嵌入);
* - 将右侧“公式与口径说明”置于文档最上方。
*/
@GetMapping("/acid/exportWord")
public void exportAcidWord(
@RequestParam String startDate,
@RequestParam String endDate,
HttpServletResponse response
) throws IOException {
String[] range = resolveDateRange(startDate, endDate);
String start = range[0];
String end = range[1];
// 取数:按选择范围实时拉取(避免 summary/loss7 的“当月固定”逻辑)
List<AcidOeeDailySummaryVo> summary = acidOeeService.getDailySummary(start, end);
List<AcidOeeLoss7Vo> loss7 = acidOeeService.getLoss7Summary(start, end);
List<Klptcm1ProStoppageVo> events = acidOeeService.getStoppageEvents(start, end);
AcidOeeIdealCycleVo idealCycle = acidOeeService.getIdealCycle(start, end);
XWPFDocument doc = new XWPFDocument();
// 标题
addTitle(doc, String.format("酸轧线OEE报表%s ~ %s", start, end));
// 口径说明(置顶)
addSectionTitle(doc, "公式与口径说明");
addParagraph(doc, "OEE 总公式OEE = A × P × Q", true);
addBullet(doc, "A时间稼动率 = (负荷时间 停机时间) / 负荷时间");
addBullet(doc, "P性能稼动率吨维度 = (理论节拍 × 产量吨) / 实际运转时间");
addBullet(doc, "Q良品率 = 良品吨 / 总产量吨");
addParagraph(doc, "关键字段定义:", true);
addBullet(doc, "负荷时间:计划生产时间扣除计划停机后的时间");
addBullet(doc, "停机时间:所有停机/中断(按 stop_type 汇总)的总时长");
addBullet(doc, "实际运转时间:负荷时间 停机时间");
addBullet(doc, "理论节拍:按“优良日统计口径”得到的稳定节拍(分钟/吨)");
addBullet(doc, "良品/次品:按 WMS quality_status 判断C+/C/C-/D+/D/D- 为次品");
if (idealCycle != null) {
addParagraph(doc, "当前理论节拍(统计口径):", true);
XWPFTable t = doc.createTable(5, 2);
setTableHeaderRow(t.getRow(0), "指标", "");
setTableRow(t.getRow(1), "理论节拍 (min/吨)", fmtNum(getIdealCycleTime(idealCycle), 2));
setTableRow(t.getRow(2), "生产节拍中位数 (min/吨)", fmtNum(getMedianCycleTime(idealCycle), 2));
setTableRow(t.getRow(3), "样本天数", String.valueOf(getSampleDays(idealCycle)));
setTableRow(t.getRow(4), "吨数下限 (吨)", fmtNum(getMinWeightTon(idealCycle), 2));
}
// 一、KPI 总览
addSectionTitle(doc, "一、KPI 总览(酸轧线 SY");
addParagraph(doc, "展示所选期间酸轧线整体 OEE 及 A/P/Q 等关键指标,用于快速判断本期综合表现好坏。", false);
AcidKpiAgg kpi = calcKpi(summary);
XWPFTable kpiTable = doc.createTable(2, 11);
setTableRow(kpiTable.getRow(0),
"OEE (%)",
"时间稼动率 A (%)",
"性能稼动率 P_ton (%)",
"良品率 Q (%)",
"负荷时间 (min)",
"停机时间 (min)",
"运转时间 (min)",
"总产量 (吨)",
"总产量 (卷)",
"良品量 (吨)",
"次品量 (吨)"
);
setTableRow(kpiTable.getRow(1),
fmtPercent1(kpi.oee),
fmtPercent1(kpi.availability),
fmtPercent1(kpi.performanceTon),
fmtPercent1(kpi.quality),
fmtInt(kpi.loadingTimeMin),
fmtInt(kpi.downtimeMin),
fmtInt(kpi.runTimeMin),
fmtNum(kpi.totalOutputTon, 2),
fmtInt(kpi.totalOutputCoil),
fmtNum(kpi.goodOutputTon, 2),
fmtNum(kpi.defectOutputTon, 2)
);
addAiAnalysisIfEnabled(doc, "KPI 总览", buildMarkdownTable(
new String[]{"OEE(%)", "A(%)", "P_ton(%)", "Q(%)", "负荷(min)", "停机(min)", "运转(min)", "总产(吨)", "总产(卷)", "良品(吨)", "次品(吨)"},
new String[][]{{
fmtPercent1(kpi.oee),
fmtPercent1(kpi.availability),
fmtPercent1(kpi.performanceTon),
fmtPercent1(kpi.quality),
fmtInt(kpi.loadingTimeMin),
fmtInt(kpi.downtimeMin),
fmtInt(kpi.runTimeMin),
fmtNum(kpi.totalOutputTon, 2),
fmtInt(kpi.totalOutputCoil),
fmtNum(kpi.goodOutputTon, 2),
fmtNum(kpi.defectOutputTon, 2)
}}), "区间:" + start + " ~ " + end);
// 二、日明细
addSectionTitle(doc, "二、日明细(用于趋势分析)");
addParagraph(doc, "按天拆分 A/P/Q 及产量等指标,用于观察趋势、波动点以及与重大事件的对应关系。", false);
XWPFTable dailyTable = doc.createTable(Math.max(1, summary.size()) + 1, 11);
setTableRow(dailyTable.getRow(0),
"日期",
"OEE (%)",
"A (%)",
"P_ton (%)",
"Q (%)",
"负荷 (min)",
"停机 (min)",
"运转 (min)",
"总产量 (吨)",
"总产量 (卷)",
"良品 (吨)"
);
for (int i = 0; i < summary.size(); i++) {
AcidOeeDailySummaryVo row = summary.get(i);
setTableRow(dailyTable.getRow(i + 1),
safeStr(row.getStatDate()),
fmtPercent1(row.getOee()),
fmtPercent1(row.getAvailability()),
fmtPercent1(row.getPerformanceTon()),
fmtPercent1(row.getQuality()),
fmtInt(row.getLoadingTimeMin()),
fmtInt(row.getDowntimeMin()),
fmtInt(row.getRunTimeMin()),
fmtNum(row.getTotalOutputTon(), 2),
fmtInt(row.getTotalOutputCoil()),
fmtNum(row.getGoodOutputTon(), 2)
);
}
addAiAnalysisIfEnabled(doc, "日明细", buildDailySummaryMarkdown(summary, 31), "区间:" + start + " ~ " + end);
// 三、理论节拍(说明 + 表)
addSectionTitle(doc, "三、理论节拍(统计口径)");
addParagraph(doc, "基于历史“优良日”统计得到的理论节拍(中位数),用于作为性能稼动率计算的稳定标尺。", false);
if (idealCycle == null) {
addParagraph(doc, "(本区间未获取到理论节拍数据)", false);
}
// 四、7 大损失
addSectionTitle(doc, "四、7 大损失汇总(按 stop_type 分类)");
addParagraph(doc, "将所有停机事件按 stop_type 归类,统计时间占比和次数,用于确定“先从哪几类损失下手改善”。", false);
XWPFTable loss7Table = doc.createTable(Math.max(1, loss7.size()) + 1, 5);
setTableRow(loss7Table.getRow(0),
"损失类别",
"损失时间 (min)",
"占比 (%)",
"次数",
"平均时长 (min)"
);
for (int i = 0; i < loss7.size(); i++) {
AcidOeeLoss7Vo row = loss7.get(i);
setTableRow(loss7Table.getRow(i + 1),
safeStr(row.getLossCategoryName()),
fmtNum(row.getLossTimeMin(), 2),
fmtPercent1(row.getLossTimeRate()),
fmtInt(row.getCount()),
fmtNum(row.getAvgDurationMin(), 2)
);
}
addAiAnalysisIfEnabled(doc, "7 大损失汇总", buildLoss7Markdown(loss7, 30), "区间:" + start + " ~ " + end);
// 五、停机/损失事件明细
addSectionTitle(doc, "五、停机/损失事件明细");
addParagraph(doc, "罗列每一条停机/损失事件,包含时间段、区域、机组和备注,方便对照现场记录进行原因分析。", false);
int eventRows = Math.max(1, events.size()) + 1;
XWPFTable eventTable = doc.createTable(eventRows, 9);
setTableRow(eventTable.getRow(0),
"开始时间",
"结束时间",
"时长 (s)",
"时长 (min)",
"停机类型 (stop_type)",
"区域",
"机组",
"班次",
"备注"
);
for (int i = 0; i < events.size(); i++) {
Klptcm1ProStoppageVo e = events.get(i);
long durationSec = safeLong(e.getDuration());
double durationMin = durationSec / 60d;
setTableRow(eventTable.getRow(i + 1),
safeStr(e.getStartDate()),
safeStr(e.getEndDate()),
String.valueOf(durationSec),
fmtNum(durationMin, 3),
safeStr(e.getStopType()),
safeStr(e.getArea()),
safeStr(e.getUnit()),
safeStr(e.getShift()),
safeStr(e.getRemark())
);
}
addAiAnalysisIfEnabled(doc, "停机/损失事件明细", buildEventsMarkdown(events, 40), "区间:" + start + " ~ " + end);
String filename = String.format("酸轧线OEE报表_%s_%s.docx", start, end);
String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8.name());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encoded);
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store, no-cache");
try (ServletOutputStream os = response.getOutputStream()) {
doc.write(os);
os.flush();
} finally {
doc.close();
}
}
/**
* 若未显式传入日期范围,则默认当前月 [1号, 今天]。
*/
@@ -219,6 +447,315 @@ public class OeeReportController extends BaseController {
return isStart ? firstDay.format(DATE_FORMATTER) : today.format(DATE_FORMATTER);
}
// ======================== Word 生成辅助方法 ========================
private void addTitle(XWPFDocument doc, String text) {
XWPFParagraph p = doc.createParagraph();
p.setAlignment(ParagraphAlignment.CENTER);
XWPFRun r = p.createRun();
r.setBold(true);
r.setFontSize(16);
r.setText(text);
}
private void addSectionTitle(XWPFDocument doc, String text) {
XWPFParagraph p = doc.createParagraph();
p.setSpacingBefore(200);
XWPFRun r = p.createRun();
r.setBold(true);
r.setFontSize(12);
r.setText(text);
}
private void addParagraph(XWPFDocument doc, String text, boolean bold) {
XWPFParagraph p = doc.createParagraph();
XWPFRun r = p.createRun();
r.setBold(bold);
r.setFontSize(11);
r.setText(text);
}
private void addBullet(XWPFDocument doc, String text) {
XWPFParagraph p = doc.createParagraph();
XWPFRun r = p.createRun();
r.setFontSize(11);
r.setText("" + text);
}
private void addAiAnalysisIfEnabled(XWPFDocument doc, String tableTitle, String tableMarkdown, String extraContext) {
String analysis = null;
try {
analysis = oeeWordAiAnalysisService.analyzeTable(tableTitle, tableMarkdown, extraContext);
} catch (Exception e) {
// 不影响导出
}
if (StringUtils.isNotBlank(analysis)) {
addParagraph(doc, "AI 分析:", true);
// 多行按段落输出
String[] lines = analysis.split("\\r?\\n");
for (String line : lines) {
if (StringUtils.isNotBlank(line)) {
addBullet(doc, line.trim().replaceFirst("^[\\-•\\*\\d\\.\\s]+", ""));
}
}
}
}
private String buildMarkdownTable(String[] headers, String[][] rows) {
StringBuilder sb = new StringBuilder();
sb.append("|");
for (String h : headers) sb.append(escapePipe(h)).append("|");
sb.append("\n|");
for (int i = 0; i < headers.length; i++) sb.append("---|");
sb.append("\n");
for (String[] r : rows) {
sb.append("|");
for (String c : r) sb.append(escapePipe(c)).append("|");
sb.append("\n");
}
return sb.toString();
}
private String escapePipe(String s) {
if (s == null) return "";
return s.replace("|", "\\|");
}
private String buildDailySummaryMarkdown(List<AcidOeeDailySummaryVo> list, int maxRows) {
List<AcidOeeDailySummaryVo> src = list == null ? new ArrayList<>() : list;
int n = Math.min(src.size(), Math.max(1, maxRows));
String[] headers = new String[]{"日期", "OEE(%)", "A(%)", "P_ton(%)", "Q(%)", "负荷(min)", "停机(min)", "运转(min)", "总产(吨)", "良品(吨)"};
String[][] rows = new String[n][headers.length];
for (int i = 0; i < n; i++) {
AcidOeeDailySummaryVo r = src.get(i);
rows[i] = new String[]{
safeStr(r.getStatDate()),
fmtPercent1(r.getOee()),
fmtPercent1(r.getAvailability()),
fmtPercent1(r.getPerformanceTon()),
fmtPercent1(r.getQuality()),
fmtInt(r.getLoadingTimeMin()),
fmtInt(r.getDowntimeMin()),
fmtInt(r.getRunTimeMin()),
fmtNum(r.getTotalOutputTon(), 2),
fmtNum(r.getGoodOutputTon(), 2)
};
}
String md = buildMarkdownTable(headers, rows);
if (src.size() > n) {
md += "\n仅展示前 " + n + " 行,已截断)\n";
}
return md;
}
private String buildLoss7Markdown(List<AcidOeeLoss7Vo> list, int maxRows) {
List<AcidOeeLoss7Vo> src = list == null ? new ArrayList<>() : list;
int n = Math.min(src.size(), Math.max(1, maxRows));
String[] headers = new String[]{"损失类别", "损失时间(min)", "占比(%)", "次数", "平均时长(min)"};
String[][] rows = new String[n][headers.length];
for (int i = 0; i < n; i++) {
AcidOeeLoss7Vo r = src.get(i);
rows[i] = new String[]{
safeStr(r.getLossCategoryName()),
fmtNum(r.getLossTimeMin(), 2),
fmtPercent1(r.getLossTimeRate()),
fmtInt(r.getCount()),
fmtNum(r.getAvgDurationMin(), 2)
};
}
String md = buildMarkdownTable(headers, rows);
if (src.size() > n) {
md += "\n仅展示前 " + n + " 行,已截断)\n";
}
return md;
}
private String buildEventsMarkdown(List<Klptcm1ProStoppageVo> list, int maxRows) {
List<Klptcm1ProStoppageVo> src = list == null ? new ArrayList<>() : list;
int n = Math.min(src.size(), Math.max(1, maxRows));
String[] headers = new String[]{"开始", "结束", "时长(s)", "时长(min)", "停机类型", "区域", "机组", "备注"};
String[][] rows = new String[n][headers.length];
for (int i = 0; i < n; i++) {
Klptcm1ProStoppageVo e = src.get(i);
long durationSec = safeLong(e.getDuration());
rows[i] = new String[]{
safeStr(e.getStartDate()),
safeStr(e.getEndDate()),
String.valueOf(durationSec),
fmtNum(durationSec / 60d, 3),
safeStr(e.getStopType()),
safeStr(e.getArea()),
safeStr(e.getUnit()),
safeStr(e.getRemark())
};
}
String md = buildMarkdownTable(headers, rows);
md += "\n总事件数" + src.size() + "\n";
if (src.size() > n) {
md += "(仅展示前 " + n + " 行,已截断)\n";
}
return md;
}
private void setTableHeaderRow(XWPFTableRow row, String c1, String c2) {
setCellText(row.getCell(0), c1, true);
setCellText(row.getCell(1), c2, true);
}
private void setTableRow(XWPFTableRow row, String... cols) {
for (int i = 0; i < cols.length; i++) {
XWPFTableCell cell = row.getCell(i);
if (cell == null) {
cell = row.createCell();
}
setCellText(cell, cols[i], false);
}
}
private void setCellText(XWPFTableCell cell, String text, boolean bold) {
cell.removeParagraph(0);
XWPFParagraph p = cell.addParagraph();
XWPFRun r = p.createRun();
r.setBold(bold);
r.setFontSize(10);
r.setText(text == null ? "" : text);
}
private String safeStr(Object o) {
return o == null ? "" : String.valueOf(o);
}
private long safeLong(Object o) {
if (o == null) return 0L;
try {
return Long.parseLong(String.valueOf(o));
} catch (Exception ex) {
return 0L;
}
}
private String fmtPercent1(Object value) {
Double v = toDouble(value);
if (v == null) return "-";
return String.format(Locale.ROOT, "%.1f", v);
}
private String fmtNum(Object value, int scale) {
Double v = toDouble(value);
if (v == null) return "-";
return String.format(Locale.ROOT, "%." + scale + "f", v);
}
private String fmtInt(Object value) {
Double v = toDouble(value);
if (v == null) return "0";
return String.valueOf((long) Math.round(v));
}
private Double toDouble(Object value) {
if (value == null) return null;
if (value instanceof Number) return ((Number) value).doubleValue();
String s = String.valueOf(value);
if (StringUtils.isBlank(s)) return null;
try {
return Double.parseDouble(s);
} catch (Exception e) {
return null;
}
}
@Data
private static class AcidKpiAgg {
private double oee;
private double availability;
private double performanceTon;
private double quality;
private double loadingTimeMin;
private double downtimeMin;
private double runTimeMin;
private double totalOutputTon;
private double totalOutputCoil;
private double goodOutputTon;
private double defectOutputTon;
}
private AcidKpiAgg calcKpi(List<AcidOeeDailySummaryVo> list) {
AcidKpiAgg k = new AcidKpiAgg();
if (list == null || list.isEmpty()) {
return k;
}
int n = list.size();
double sumOee = 0, sumA = 0, sumP = 0, sumQ = 0;
double sumLoading = 0, sumDown = 0, sumRun = 0;
double sumTotalTon = 0, sumGoodTon = 0, sumDefectTon = 0, sumCoil = 0;
for (AcidOeeDailySummaryVo r : list) {
sumLoading += safeNum(r.getLoadingTimeMin());
sumDown += safeNum(r.getDowntimeMin());
sumRun += safeNum(r.getRunTimeMin());
sumTotalTon += safeNum(r.getTotalOutputTon());
sumGoodTon += safeNum(r.getGoodOutputTon());
sumDefectTon += safeNum(r.getDefectOutputTon());
sumCoil += safeNum(r.getTotalOutputCoil());
sumOee += safeNum(r.getOee());
sumA += safeNum(r.getAvailability());
sumP += safeNum(r.getPerformanceTon());
sumQ += safeNum(r.getQuality());
}
double defectAgg = Math.max(0, sumTotalTon - sumGoodTon);
k.setOee(sumOee / n);
k.setAvailability(sumA / n);
k.setPerformanceTon(sumP / n);
k.setQuality(sumQ / n);
k.setLoadingTimeMin(sumLoading);
k.setDowntimeMin(sumDown);
k.setRunTimeMin(sumRun);
k.setTotalOutputTon(sumTotalTon);
k.setTotalOutputCoil(sumCoil);
k.setGoodOutputTon(sumGoodTon);
k.setDefectOutputTon(defectAgg > 0 ? defectAgg : sumDefectTon);
return k;
}
private double safeNum(Object v) {
Double d = toDouble(v);
return d == null ? 0d : d;
}
// idealCycle 字段做容错(避免 VO 字段名差异导致编译失败)
private Object getIdealCycleTime(AcidOeeIdealCycleVo vo) {
try {
return vo.getClass().getMethod("getIdealCycleTimeMinPerTon").invoke(vo);
} catch (Exception e) {
return null;
}
}
private Object getMedianCycleTime(AcidOeeIdealCycleVo vo) {
try {
return vo.getClass().getMethod("getMedianCycleTimeMinPerTon").invoke(vo);
} catch (Exception e) {
return null;
}
}
private int getSampleDays(AcidOeeIdealCycleVo vo) {
try {
Object v = vo.getClass().getMethod("getSampleDays").invoke(vo);
Double d = toDouble(v);
return d == null ? 0 : d.intValue();
} catch (Exception e) {
return 0;
}
}
private Object getMinWeightTon(AcidOeeIdealCycleVo vo) {
try {
return vo.getClass().getMethod("getMinWeightTon").invoke(vo);
} catch (Exception e) {
return null;
}
}
// ======================== 任意日期范围异步任务(酸轧线) ========================
/**

View File

@@ -0,0 +1,44 @@
package com.klp.da.service;
import com.klp.common.core.page.TableDataInfo;
import com.klp.da.domain.bo.OeeQueryBo;
import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
import com.klp.pocket.acid.domain.vo.Klptcm1ProStoppageVo;
import java.util.List;
/**
* OEE 聚合 Service 接口(酸轧线)
*
* @author klp
* @date 2026-01-31
*/
public interface IDaAcidOeeService {
/**
* 获取 OEE 日汇总(当月走缓存,历史查询走实时)
*
* @param bo 查询条件
* @return 日汇总列表
*/
List<AcidOeeDailySummaryVo> getDailySummary(OeeQueryBo bo);
/**
* 获取 7 大损失汇总(当月走缓存,历史查询走实时)
*
* @param bo 查询条件
* @return 7 大损失列表
*/
List<AcidOeeLoss7Vo> getLoss7Summary(OeeQueryBo bo);
/**
* 获取停机事件明细(实时分页查询)
*
* @param bo 查询条件
* @return 分页后的停机事件列表
*/
TableDataInfo<Klptcm1ProStoppageVo> getStoppageEvents(OeeQueryBo bo);
}

View File

@@ -0,0 +1,132 @@
package com.klp.da.service;
import com.klp.common.config.DeepseekConfig;
import com.klp.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* OEE Word 导出:表格内容大模型分析(可选)
*
* 默认关闭,通过配置开启:
* oee:
* word:
* ai:
* enabled: true
*
* 大模型连接配置复用 sales.script.aiDeepSeek
* sales:
* script:
* ai:
* api-key/base-url/model-name/max-retries/temperature
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class OeeWordAiAnalysisService {
@Value("${oee.word.ai.enabled:false}")
private boolean enabled;
@Qualifier("salesScriptRestTemplate")
private final RestTemplate restTemplate;
private final DeepseekConfig deepseekConfig;
public String analyzeTable(String tableTitle, String tableMarkdown, String extraContext) {
if (!enabled) {
return null;
}
if (StringUtils.isBlank(tableMarkdown)) {
return null;
}
if (deepseekConfig == null || StringUtils.isBlank(deepseekConfig.getApiKey())
|| StringUtils.isBlank(deepseekConfig.getBaseUrl())
|| StringUtils.isBlank(deepseekConfig.getModelName())) {
log.warn("OEE Word AI 已开启但 sales.script.ai 配置不完整,跳过分析");
return null;
}
String prompt = buildPrompt(tableTitle, tableMarkdown, extraContext);
return callAi(prompt);
}
private String buildPrompt(String tableTitle, String tableMarkdown, String extraContext) {
StringBuilder sb = new StringBuilder();
sb.append("你是一名制造业OEE报表分析专家。请基于下面表格数据给出简洁的分析结论。\n");
sb.append("输出要求:\n");
sb.append("1) 用中文输出;\n");
sb.append("2) 只输出 3~6 条要点(每条不超过 30 字),不要写长段落;\n");
sb.append("3) 可以指出异常/波动/占比最高项/可能原因与建议方向;\n");
sb.append("4) 不要复述表格标题不要输出Markdown表格。\n\n");
sb.append("【表格】").append(tableTitle).append("\n");
if (StringUtils.isNotBlank(extraContext)) {
sb.append("【上下文】").append(extraContext).append("\n");
}
sb.append("【数据】\n");
sb.append(tableMarkdown);
return sb.toString();
}
@SuppressWarnings("unchecked")
private String callAi(String prompt) {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", deepseekConfig.getModelName());
Map<String, String> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", "你是一个严谨的数据分析助手");
Map<String, String> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", prompt);
requestBody.put("messages", Arrays.asList(systemMessage, userMessage));
requestBody.put("temperature", deepseekConfig.getTemperature() == null ? 0.2 : deepseekConfig.getTemperature());
requestBody.put("max_tokens", 800);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(deepseekConfig.getApiKey());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
int retries = deepseekConfig.getMaxRetries() == null ? 1 : Math.max(1, deepseekConfig.getMaxRetries());
for (int i = 0; i < retries; i++) {
try {
ResponseEntity<Map> response = restTemplate.postForEntity(
deepseekConfig.getBaseUrl() + "/chat/completions", entity, Map.class);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
Map<String, Object> body = response.getBody();
List<Map<String, Object>> choices = (List<Map<String, Object>>) body.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> choice = choices.get(0);
Map<String, Object> message = (Map<String, Object>) choice.get("message");
String content = message == null ? null : (String) message.get("content");
if (StringUtils.isNotBlank(content)) {
return content.trim();
}
}
}
} catch (Exception e) {
log.warn("OEE Word AI 调用失败,重试 {}/{}{}", i + 1, retries, e.getMessage());
}
}
return null;
}
}

View File

@@ -0,0 +1,92 @@
package com.klp.da.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.TypeReference;
import com.github.pagehelper.PageInfo;
import com.klp.common.core.page.TableDataInfo;
import com.klp.common.utils.DateUtils;
import com.klp.common.utils.StringUtils;
import com.klp.da.domain.bo.OeeQueryBo;
import com.klp.da.service.IDaAcidOeeService;
import com.klp.pocket.acid.domain.vo.AcidOeeDailySummaryVo;
import com.klp.pocket.acid.domain.vo.AcidOeeLoss7Vo;
import com.klp.pocket.acid.domain.vo.Klptcm1ProStoppageVo;
import com.klp.pocket.acid.service.IAcidOeeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@Service
public class DaAcidOeeServiceImpl implements IDaAcidOeeService {
private static final String SUMMARY_KEY_PATTERN = "oee:report:month:summary:%s:SY";
private static final DateTimeFormatter YEAR_MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
private final IAcidOeeService acidOeeService;
private final StringRedisTemplate stringRedisTemplate;
@Override
public List<AcidOeeDailySummaryVo> getDailySummary(OeeQueryBo bo) {
// 检查是否查询当月,如果是,则尝试从 Redis 缓存获取
if (isCurrentMonthQuery(bo.getStartDate(), bo.getEndDate())) {
String yyyyMM = LocalDate.now().format(YEAR_MONTH_FMT);
String summaryKey = String.format(SUMMARY_KEY_PATTERN, yyyyMM);
try {
String summaryJson = stringRedisTemplate.opsForValue().get(summaryKey);
if (StringUtils.isNotBlank(summaryJson)) {
log.info("[DaAcidOeeService] Hit cache for acid summary, key={}", summaryKey);
return JSON.parseObject(summaryJson, new TypeReference<List<AcidOeeDailySummaryVo>>() {});
}
} catch (Exception e) {
log.error("[DaAcidOeeService] Failed to get acid summary from redis cache, key={}", summaryKey, e);
}
}
// 缓存未命中或查询历史数据,则实时调用 pocket service
log.info("[DaAcidOeeService] Cache miss for acid summary, calling pocket service...");
return acidOeeService.getDailySummary(
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getStartDate())),
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getEndDate()))
);
}
@Override
public List<AcidOeeLoss7Vo> getLoss7Summary(OeeQueryBo bo) {
// 7大损失目前没有预计算直接实时调用
return acidOeeService.getLoss7Summary(
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getStartDate())),
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getEndDate()))
);
}
@Override
public TableDataInfo<Klptcm1ProStoppageVo> getStoppageEvents(OeeQueryBo bo) {
// 实时分页查询
List<Klptcm1ProStoppageVo> list = acidOeeService.getStoppageEvents(
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getStartDate())),
DateUtils.parseDateToStr("yyyy-MM-dd", DateUtils.toDate(bo.getEndDate()))
);
return TableDataInfo.build(list);
}
/**
* 判断查询范围是否为当月
*/
private boolean isCurrentMonthQuery(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
return false;
}
LocalDate now = LocalDate.now();
LocalDate firstDayOfMonth = now.withDayOfMonth(1);
return !startDate.isBefore(firstDayOfMonth) && !endDate.isAfter(now);
}
}

View File

@@ -2,7 +2,6 @@ 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;
@@ -25,10 +24,9 @@ import java.util.concurrent.TimeUnit;
* - 项目启动完成后即计算当月 OEE 聚合结果并写入 Redis
* - 每天凌晨 04:00 重新计算当月数据并覆盖缓存。
*
* 当前仅实现酸轧线SY的当月日汇总 & 7 大损失预计算;
* 当前仅实现酸轧线SY的当月日汇总预计算
* key 约定:
* - 汇总结果oee:report:month:summary:{yyyyMM}:SY
* - 7 大损失oee:report:month:loss7:{yyyyMM}:SY
* - 元信息: oee:report:month:meta:{yyyyMM}:SY
*/
@Slf4j
@@ -39,9 +37,6 @@ 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";
@@ -96,20 +91,14 @@ public class AcidOeeMonthTask {
log.info("[AcidOeeMonthTask] trigger={}, computing acid OEE month summary for {} ({} ~ {})",
trigger, yyyyMM, startStr, endStr);
// 1. 调用 pocket 的 AcidOeeService 获取当月日汇总 & 7 大损失
// 1. 调用 pocket 的 AcidOeeService 获取当月日汇总
List<AcidOeeDailySummaryVo> dailySummaryList = acidOeeService.getDailySummary(startStr, endStr);
List<AcidOeeLoss7Vo> loss7List = acidOeeService.getLoss7Summary(startStr, endStr);
// 2. 写入 Redissummary
String summaryKey = String.format(SUMMARY_KEY_PATTERN, yyyyMM);
String summaryJson = JSON.toJSONString(dailySummaryList);
stringRedisTemplate.opsForValue().set(summaryKey, summaryJson, 1, TimeUnit.DAYS);
// 2.1 写入 Redisloss7
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. 写入 Redismeta
@@ -123,8 +112,8 @@ public class AcidOeeMonthTask {
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);
log.info("[AcidOeeMonthTask] compute finish for {} dailySize={}, durationMs={}ms, summaryKey={}",
yyyyMM, dailySummaryList.size(), durationMs, summaryKey);
}
/**