Merge remote-tracking branch 'origin/0.8.X' into 0.8.X

This commit is contained in:
2026-02-04 17:56:32 +08:00
20 changed files with 2337 additions and 491 deletions

View File

@@ -47,5 +47,10 @@
<artifactId>fastjson2</artifactId>
<version>2.0.35</version>
</dependency>
<!-- Word 导出docx -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
</dependencies>
</project>

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);
}
/**

View File

@@ -0,0 +1,54 @@
package com.klp.pocket.acid.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 酸轧线理论节拍(统计口径)视图对象
* 通过历史“优良日”统计得到,替代不稳定的回归结果。
*
* @author klp
* @date 2026-02-03
*/
@Data
public class AcidOeeIdealCycleVo {
/** 产线ID固定为 SY */
private String lineId;
/** 产线名称(酸轧线) */
private String lineName;
/** 本次计算使用的数据区间yyyy-MM-dd */
private String startDate;
private String endDate;
/** 理论节拍min/吨)- 固定值0.47 */
private BigDecimal idealCycleTimeMinPerTon;
/** 中位数理论节拍min/吨)- 从卷级节拍计算的中位数 */
private BigDecimal medianCycleTimeMinPerTon;
/** 参与统计的"优良日"天数 */
private Integer sampleDays;
/** 统计口径:吨数下限(吨) */
private BigDecimal minWeightTon;
/** 统计口径停机占比上限0~1 */
private BigDecimal maxDowntimeRate;
/** 日粒度对比点:理论耗时 vs 实际运转时间 */
private List<DailyComparePointVo> dailyComparePoints;
@Data
public static class DailyComparePointVo {
private String statDate;
private Long actualRunTimeMin;
private BigDecimal theoreticalTimeMin;
}
}

View File

@@ -1,81 +0,0 @@
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;
/** 拟合优度 */
private BigDecimal r2;
/** 参与回归样本数 */
private Integer sampleCount;
/** 回归数据开始时间 */
private String startTime;
/** 回归数据结束时间 */
private String endTime;
/** 散点列表 */
private List<RegressionPointVo> points;
/** 拟合线两个端点(前端可直接画线) */
private List<RegressionLinePointVo> 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;
}
}

View File

@@ -1,7 +1,6 @@
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;
@@ -27,17 +26,6 @@ public interface AcidOeeMapper {
List<AcidOeeDailySummaryVo> selectDailySummary(@Param("startDate") String startDate,
@Param("endDate") String endDate);
/**
* 查询回归数据散点(用于理论节拍计算)
* 返回:重量(吨)、卷数、时长(分钟)等
*
* @param startDate 开始日期yyyy-MM-dd可选
* @param endDate 结束日期yyyy-MM-dd可选
* @return 散点列表
*/
List<AcidOeeRegressionVo.RegressionPointVo> selectRegressionPoints(@Param("startDate") String startDate,
@Param("endDate") String endDate);
/**
* 查询每日的钢卷号和重量(用于良品/次品判定)
*
@@ -48,6 +36,16 @@ public interface AcidOeeMapper {
List<CoilInfoByDate> selectCoilInfoByDate(@Param("startDate") String startDate,
@Param("endDate") String endDate);
/**
* 查询卷级生产节拍min/吨),用于理论节拍计算。
*
* @param startDate 开始日期yyyy-MM-dd
* @param endDate 结束日期yyyy-MM-dd
* @return 每卷的生产节拍列表min/吨)
*/
List<java.math.BigDecimal> selectCoilCycleMinPerTon(@Param("startDate") String startDate,
@Param("endDate") String endDate);
/**
* 卷号信息内部类用于Mapper返回
*/

View File

@@ -1,8 +1,8 @@
package com.klp.pocket.acid.service;
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 java.util.List;
@@ -34,16 +34,6 @@ public interface IAcidOeeService {
*/
List<Klptcm1ProStoppageVo> getStoppageEvents(String startDate, String endDate);
/**
* 查询理论节拍回归数据(吨和卷两个维度)
* 用于性能稼动率计算和前端散点图展示
*
* @param startDate 开始日期yyyy-MM-dd可选默认近6个月
* @param endDate 结束日期yyyy-MM-dd可选
* @return 回归数据(包含斜率、截距、散点等)
*/
AcidOeeRegressionVo getRegressionData(String startDate, String endDate);
/**
* 查询7大损失汇总按日期范围
*
@@ -52,5 +42,14 @@ public interface IAcidOeeService {
* @return 7大损失汇总列表
*/
List<AcidOeeLoss7Vo> getLoss7Summary(String startDate, String endDate);
/**
* 查询理论节拍(统计口径:历史优良日中位数)
*
* @param startDate 开始日期yyyy-MM-dd
* @param endDate 结束日期yyyy-MM-dd
* @return 理论节拍与对比数据
*/
AcidOeeIdealCycleVo getIdealCycle(String startDate, String endDate);
}

View File

@@ -3,8 +3,8 @@ 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.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.domain.bo.Klptcm1ProStoppageBo;
import com.klp.pocket.acid.mapper.AcidOeeMapper;
@@ -19,7 +19,7 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
import java.util.Calendar;
/**
* 酸轧线OEE Service实现类
@@ -35,7 +35,8 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
/** 酸轧成品库库区ID */
private static final Long ACID_FINISHED_WAREHOUSE_ID = 1988150099140866050L;
/** 固定理论节拍min/吨) */
private static final BigDecimal FIXED_IDEAL_CYCLE = BigDecimal.valueOf(0.47);
private final AcidOeeMapper acidOeeMapper;
private final IKlptcm1ProStoppageService stoppageService;
private final ICoilQualityJudgeService coilQualityJudgeService;
@@ -55,7 +56,10 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
// 3. 查询产量明细,用于良品/次品判定
Map<String, List<CoilInfo>> coilInfoByDate = getCoilNosByDate(startDate, endDate);
// 4. 填充每个日汇总的完整数据
// 4. 理论节拍使用固定值0.47
BigDecimal idealCycleTon = FIXED_IDEAL_CYCLE;
// 5. 填充每个日汇总的完整数据
for (AcidOeeDailySummaryVo summary : summaries) {
String statDate = summary.getStatDate();
summary.setLineId("SY");
@@ -70,6 +74,11 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
Long runTime = Math.max(0, loadingTime - downtime);
summary.setRunTimeMin(runTime);
// 理论节拍:若尚未填充,则统一使用“优良日统计”得到的节拍
if (summary.getIdealCycleTimeMinPerTon() == null && idealCycleTon != null) {
summary.setIdealCycleTimeMinPerTon(idealCycleTon);
}
// 良品/次品判定通过WMS
if (coilInfoByDate.containsKey(statDate)) {
List<CoilInfo> coilInfos = coilInfoByDate.get(statDate);
@@ -101,57 +110,84 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
return stoppageService.queryList(bo);
}
/**
* 查询停机事件(可选:是否包含短停机 <5min
*/
private List<Klptcm1ProStoppageVo> getStoppageEvents(String startDate, String endDate, boolean includeShortDuration) {
Klptcm1ProStoppageBo bo = new Klptcm1ProStoppageBo();
bo.setStartDate(parseDate(startDate));
bo.setEndDate(parseDate(endDate));
if (includeShortDuration) {
// BaseEntity.params 用于透传查询开关
bo.getParams().put("includeShortDuration", true);
}
return stoppageService.queryList(bo);
}
@Override
public AcidOeeRegressionVo getRegressionData(String startDate, String endDate) {
// 1. 查询散点数据
List<AcidOeeRegressionVo.RegressionPointVo> points = acidOeeMapper.selectRegressionPoints(startDate, endDate);
public AcidOeeIdealCycleVo getIdealCycle(String startDate, String endDate) {
// 1) 取基础日汇总(产量、负荷时间等)
List<AcidOeeDailySummaryVo> daily = acidOeeMapper.selectDailySummary(startDate, endDate);
AcidOeeIdealCycleVo rsp = new AcidOeeIdealCycleVo();
rsp.setLineId("SY");
rsp.setLineName("酸轧线");
rsp.setStartDate(startDate);
rsp.setEndDate(endDate);
// 这里的 minWeightTon / maxDowntimeRate 字段暂不使用,可保留为前端说明字段
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;
if (daily == null || daily.isEmpty()) {
rsp.setIdealCycleTimeMinPerTon(null);
rsp.setSampleDays(0);
rsp.setDailyComparePoints(Collections.emptyList());
return rsp;
}
// 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);
// 2) 聚合停机,补齐 runTime
Map<String, Long> downtimeByDate = aggregateDowntimeByDate(startDate, endDate);
for (AcidOeeDailySummaryVo d : daily) {
Long downtime = downtimeByDate.getOrDefault(d.getStatDate(), 0L);
d.setDowntimeMin(downtime);
Long loading = d.getLoadingTimeMin() != null ? d.getLoadingTimeMin() : 0L;
d.setRunTimeMin(Math.max(0, loading - downtime));
}
// 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);
}
// 3) 卷级节拍 = (END_DATE - START_DATE)/出口重量计算中位数用于展示不用于OEE计算
List<BigDecimal> coilCycles = acidOeeMapper.selectCoilCycleMinPerTon(startDate, endDate);
coilCycles.removeIf(c -> c == null || c.compareTo(BigDecimal.ZERO) <= 0);
coilCycles.sort(BigDecimal::compareTo);
BigDecimal medianCycle = median(coilCycles);
// 理论节拍使用固定值0.47用于OEE计算
rsp.setIdealCycleTimeMinPerTon(FIXED_IDEAL_CYCLE);
// 中位数理论节拍(用于展示)
rsp.setMedianCycleTimeMinPerTon(medianCycle);
// 样本天数:当前查询区间内有产量的自然日数量(与传入的日期范围一一对应)
rsp.setSampleDays(daily.size());
// 4. 生成拟合线端点(用于前端画线
if (tonResult != null && !points.isEmpty()) {
List<AcidOeeRegressionVo.RegressionLinePointVo> linePoints = generateLinePoints(points, tonResult);
result.setLinePoints(linePoints);
// 4) 日粒度对比数据:理论耗时 vs 实际运转时间(用于前端展示"有效性"
// 使用固定值0.47计算理论耗时
List<AcidOeeIdealCycleVo.DailyComparePointVo> compare = new ArrayList<>();
if (FIXED_IDEAL_CYCLE != null) {
for (AcidOeeDailySummaryVo d : daily) {
BigDecimal ton = d.getTotalOutputTon();
Long run = d.getRunTimeMin();
if (ton == null || run == null) continue;
AcidOeeIdealCycleVo.DailyComparePointVo p = new AcidOeeIdealCycleVo.DailyComparePointVo();
p.setStatDate(d.getStatDate());
p.setActualRunTimeMin(run);
p.setTheoreticalTimeMin(FIXED_IDEAL_CYCLE.multiply(ton));
compare.add(p);
}
}
return result;
rsp.setDailyComparePoints(compare);
return rsp;
}
@Override
public List<AcidOeeLoss7Vo> getLoss7Summary(String startDate, String endDate) {
// 1. 查询停机事件(含 stopType、duration 等)
List<Klptcm1ProStoppageVo> events = getStoppageEvents(startDate, endDate);
// 损失统计也建议包含短停机,避免损失时间与停机总时间口径不一致
List<Klptcm1ProStoppageVo> events = getStoppageEvents(startDate, endDate, true);
if (events == null || events.isEmpty()) {
return Collections.emptyList();
}
@@ -162,9 +198,9 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
for (Klptcm1ProStoppageVo event : events) {
String stopType = event.getStopType();
// stopType 为空时归入“未分类”,避免因为未录入原因导致损失时间被漏算
if (StringUtils.isBlank(stopType)) {
// 没有类型的记录暂时忽略
continue;
stopType = "未分类";
}
Long durationSec = event.getDuration();
if (durationSec == null || durationSec <= 0) {
@@ -225,18 +261,64 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
/**
* 按日期聚合停机时间
* 修复:如果停机事件跨天,需要按实际跨天的分钟数分配到对应的日期
*/
private Map<String, Long> aggregateDowntimeByDate(String startDate, String endDate) {
List<Klptcm1ProStoppageVo> events = getStoppageEvents(startDate, endDate);
// 性能稼动率/运转时间口径:停机时间需要包含短停机(<5min否则 runTime 被高估
List<Klptcm1ProStoppageVo> events = getStoppageEvents(startDate, endDate, true);
Map<String, Long> downtimeMap = new HashMap<>();
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
Calendar cal = Calendar.getInstance();
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);
if (event.getStartDate() == null || event.getDuration() == null || event.getDuration() <= 0) {
continue;
}
Date eventStart = event.getStartDate();
long durationSec = event.getDuration();
long durationMin = (durationSec + 59) / 60; // 向上取整,避免丢失秒数
// 计算停机结束时间
cal.setTime(eventStart);
cal.add(Calendar.SECOND, (int) durationSec);
Date eventEnd = cal.getTime();
// 如果停机事件在同一天,直接累加
String startDateStr = dateFormat.format(eventStart);
String endDateStr = dateFormat.format(eventEnd);
if (startDateStr.equals(endDateStr)) {
// 同一天,直接累加
downtimeMap.merge(startDateStr, durationMin, Long::sum);
} else {
// 跨天:按实际跨天的分钟数分配到对应的日期
cal.setTime(eventStart);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
Date dayStart = cal.getTime();
Date currentDayStart = dayStart;
while (currentDayStart.before(eventEnd)) {
cal.setTime(currentDayStart);
cal.add(Calendar.DAY_OF_MONTH, 1);
Date nextDayStart = cal.getTime();
// 计算当前天的停机分钟数
Date dayEnd = nextDayStart.before(eventEnd) ? nextDayStart : eventEnd;
long dayMinutes = Math.max(0, (dayEnd.getTime() - Math.max(currentDayStart.getTime(), eventStart.getTime())) / (1000 * 60));
if (dayMinutes > 0) {
String dateKey = dateFormat.format(currentDayStart);
downtimeMap.merge(dateKey, dayMinutes, Long::sum);
}
currentDayStart = nextDayStart;
}
}
}
@@ -327,7 +409,11 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
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) {
if (runTime > 0
&& idealCycleTon != null
&& idealCycleTon.compareTo(BigDecimal.ZERO) > 0
&& 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));
@@ -337,7 +423,10 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
// 性能稼动率(卷维度)
Long totalOutputCoil = summary.getTotalOutputCoil() != null ? summary.getTotalOutputCoil() : 0L;
BigDecimal idealCycleCoil = summary.getIdealCycleTimeMinPerCoil();
if (runTime > 0 && idealCycleCoil != null && totalOutputCoil > 0) {
if (runTime > 0
&& idealCycleCoil != null
&& idealCycleCoil.compareTo(BigDecimal.ZERO) > 0
&& totalOutputCoil > 0) {
BigDecimal idealTime = idealCycleCoil.multiply(BigDecimal.valueOf(totalOutputCoil));
BigDecimal performanceCoil = idealTime.divide(BigDecimal.valueOf(runTime), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
@@ -363,102 +452,15 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
}
}
/**
* 计算线性回归(最小二乘法)
*/
private RegressionResult calculateRegression(List<BigDecimal> xValues, List<Long> yValues) {
if (xValues.size() != yValues.size() || xValues.isEmpty()) {
return null;
private BigDecimal median(List<BigDecimal> values) {
if (values == null || values.isEmpty()) return null;
int n = values.size();
if (n % 2 == 1) {
return values.get(n / 2);
}
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<AcidOeeRegressionVo.RegressionLinePointVo> generateLinePoints(
List<AcidOeeRegressionVo.RegressionPointVo> 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);
BigDecimal a = values.get(n / 2 - 1);
BigDecimal b = values.get(n / 2);
return a.add(b).divide(BigDecimal.valueOf(2), 6, RoundingMode.HALF_UP);
}
/**
@@ -477,20 +479,7 @@ public class AcidOeeServiceImpl implements IAcidOeeService {
}
}
/**
* 回归结果内部类
*/
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 的总损失时间与次数

View File

@@ -89,8 +89,12 @@ public class Klptcm1ProStoppageServiceImpl implements IKlptcm1ProStoppageService
lqw.le(Klptcm1ProStoppage::getStartDate, endDateWithTime);
}
lqw.eq(bo.getDURATION() != null, Klptcm1ProStoppage::getDuration, bo.getDURATION());
// 只查询持续时间大于等于5分钟300秒的停机记录
lqw.ge(Klptcm1ProStoppage::getDuration, 300);
// 默认只统计持续时间大于等于5分钟300秒的停机记录
// 若 params.includeShortDuration=true则放开短停机用于 OEE 性能稼动率的“完整停机时间”统计。
boolean includeShort = params != null && Boolean.TRUE.equals(params.get("includeShortDuration"));
if (!includeShort) {
lqw.ge(Klptcm1ProStoppage::getDuration, 300);
}
lqw.eq(bo.getInsDate() != null, Klptcm1ProStoppage::getInsDate, bo.getInsDate());
lqw.eq(StringUtils.isNotBlank(bo.getStopType()), Klptcm1ProStoppage::getStopType, bo.getStopType());
//倒序

View File

@@ -54,51 +54,6 @@
ORDER BY stat_date ASC
</select>
<!-- 回归数据散点结果映射 -->
<resultMap id="RegressionPointResultMap" type="com.klp.pocket.acid.domain.vo.AcidOeeRegressionVo$RegressionPointVo">
<result column="weight_ton" property="weightTon" jdbcType="DECIMAL"/>
<result column="coil_count" property="coilCount" jdbcType="BIGINT"/>
<result column="duration_min" property="durationMin" jdbcType="BIGINT"/>
<result column="action_id" property="actionId" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="VARCHAR"/>
</resultMap>
<!-- 查询回归数据散点 -->
<select id="selectRegressionPoints" resultMap="RegressionPointResultMap">
SELECT
-- 重量(吨):出口重量
e.EXIT_WEIGHT AS weight_ton,
-- 卷数固定为1每条记录代表一卷
1 AS coil_count,
-- 时长(分钟):结束时间 - 开始时间,转换为分钟
CASE
WHEN e.START_DATE IS NOT NULL AND e.END_DATE IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, e.START_DATE, e.END_DATE)
ELSE NULL
END AS duration_min,
-- 关联ID使用卷号作为标识
e.ENCOILID AS action_id,
-- 创建时间
DATE_FORMAT(e.INSDATE, '%Y-%m-%d %H:%i:%s') AS create_time
FROM klptcm1_pdo_excoil e
WHERE 1=1
<if test="startDate != null and startDate != ''">
AND DATE(e.INSDATE) &gt;= #{startDate}
</if>
<if test="endDate != null and endDate != ''">
AND DATE(e.INSDATE) &lt;= #{endDate}
</if>
-- 过滤掉无效数据必须有开始和结束时间且时长大于0
AND e.START_DATE IS NOT NULL
AND e.END_DATE IS NOT NULL
AND e.END_DATE &gt; e.START_DATE
AND e.EXIT_WEIGHT IS NOT NULL
AND e.EXIT_WEIGHT &gt; 0
ORDER BY e.INSDATE ASC
-- 限制最多返回最近6个月的数据避免数据量过大
LIMIT 10000
</select>
<!-- 查询每日的钢卷号和重量(用于良品/次品判定) -->
<select id="selectCoilInfoByDate" resultType="com.klp.pocket.acid.mapper.AcidOeeMapper$CoilInfoByDate">
SELECT
@@ -115,5 +70,26 @@
ORDER BY e.INSDATE ASC, e.ENCOILID ASC
</select>
<!-- 查询卷级生产节拍min/吨):(END_DATE - START_DATE)/EXIT_WEIGHT -->
<select id="selectCoilCycleMinPerTon" resultType="java.math.BigDecimal">
SELECT
-- 生产节拍(分钟/吨)
TIMESTAMPDIFF(MINUTE, e.START_DATE, e.END_DATE) / e.EXIT_WEIGHT AS cycle_min_per_ton
FROM klptcm1_pdo_excoil e
WHERE 1 = 1
<if test="startDate != null and startDate != ''">
AND DATE(e.INSDATE) &gt;= #{startDate}
</if>
<if test="endDate != null and endDate != ''">
AND DATE(e.INSDATE) &lt;= #{endDate}
</if>
AND e.START_DATE IS NOT NULL
AND e.END_DATE IS NOT NULL
AND e.END_DATE &gt; e.START_DATE
AND e.EXIT_WEIGHT IS NOT NULL
AND e.EXIT_WEIGHT &gt; 0
AND TIMESTAMPDIFF(MINUTE, e.START_DATE, e.END_DATE) &gt; 0
</select>
</mapper>

View File

@@ -1,19 +1,19 @@
import request from '@/utils/request'
// 导出 Word 报表
// 导出 Word 报表(酸轧线)
export function exportOeeWord(query) {
return request({
url: '/oee/line/exportWord',
url: '/oee/line/acid/exportWord',
method: 'get',
params: query,
responseType: 'blob'
})
}
// OEE 产线 KPI + 趋势汇总
// OEE 产线 KPI + 趋势汇总(酸轧线)
export function fetchOeeSummary(query) {
return request({
url: '/oee/line/summary',
url: '/oee/line/acid/summary',
method: 'get',
params: query,
timeout: 120000
@@ -23,37 +23,37 @@ export function fetchOeeSummary(query) {
// OEE 产线 KPI + 趋势汇总(异步任务接口暂保留,当前前端不使用)
export function createOeeSummaryJob(query) {
return request({
url: '/oee/line/summary/job',
url: '/oee/line/acid/summary/job',
method: 'get',
params: query,
timeout: 120000
})
}
// 7 大损失汇总
// 7 大损失汇总(酸轧线)
export function fetchOeeLoss7(query) {
return request({
url: '/oee/line/loss7',
url: '/oee/line/acid/loss7',
method: 'get',
params: query,
timeout: 120000
})
}
// 停机/损失事件明细
// 停机/损失事件明细(酸轧线)
export function fetchOeeEvents(query) {
return request({
url: '/oee/line/events',
url: '/oee/line/acid/events',
method: 'get',
params: query,
timeout: 120000
})
}
// 理论节拍回归结果(含散点与拟合线
export function fetchOeeTheoryCycleRegression(query) {
// 理论节拍(统计口径:优良日中位数
export function fetchOeeIdealCycle(query) {
return request({
url: '/oee/line/theoryCycle/regression',
url: '/oee/line/acid/idealCycle',
method: 'get',
params: query,
timeout: 120000
@@ -63,7 +63,7 @@ export function fetchOeeTheoryCycleRegression(query) {
// 理论节拍回归:创建异步任务,返回 jobId + wsType通过 WebSocket 推送进度/结果
export function createOeeTheoryCycleRegressionJob(query) {
return request({
url: '/oee/line/theoryCycle/regression/job',
url: '/oee/line/acid/theoryCycle/regression/job',
method: 'get',
params: query,
timeout: 120000

View File

@@ -0,0 +1,127 @@
<template>
<div class="oee-chart" ref="chartRef"></div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'OeeLossPareto',
props: {
data: {
type: Array,
default: () => []
}
},
data() {
return {
chart: null
}
},
mounted() {
this.initChart()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
watch: {
data: {
deep: true,
immediate: true,
handler() {
this.render()
}
}
},
methods: {
toFixed3(val) {
const n = Number(val)
return Number.isFinite(n) ? n.toFixed(3) : '-'
},
initChart() {
if (!this.$refs.chartRef) return
this.chart = echarts.init(this.$refs.chartRef)
},
render() {
if (!this.chart) this.initChart()
if (!this.chart) return
const list = Array.isArray(this.data) ? this.data : []
const names = list.map(d => d.lossCategoryName)
const mins = list.map(d => Number(d.lossTimeMin || 0))
const percents = list.map(d => Number(d.lossTimeRate || 0))
const cum = []
percents.reduce((acc, cur) => {
const v = acc + cur
cum.push(Math.min(100, v))
return v
}, 0)
const option = {
tooltip: {
trigger: 'axis',
formatter: params => {
const title = (params && params[0] && params[0].axisValue) || ''
const lines = (params || []).map(p => {
const suffix = p.seriesName === '累计占比' ? ' %' : ' min'
return `${p.marker}${p.seriesName}${this.toFixed3(p.value)}${suffix}`
})
return [title, ...lines].join('<br/>')
}
},
grid: { left: 40, right: 60, top: 30, bottom: 80 },
xAxis: {
type: 'category',
data: names,
axisLabel: { interval: 0, rotate: 40, fontSize: 10 }
},
yAxis: [
{
type: 'value',
name: '损失时间 (min)',
axisLabel: { formatter: v => this.toFixed3(v) }
},
{
type: 'value',
name: '累计占比 (%)',
min: 0,
max: 100,
position: 'right',
axisLabel: { formatter: v => this.toFixed3(v) }
}
],
series: [
{
name: '损失时间',
type: 'bar',
data: mins,
itemStyle: { color: '#F56C6C' }
},
{
name: '累计占比',
type: 'line',
yAxisIndex: 1,
data: cum,
smooth: true,
itemStyle: { color: '#409EFF' }
}
]
}
this.chart.setOption(option)
},
handleResize() {
this.chart && this.chart.resize()
}
}
}
</script>
<style scoped>
.oee-chart {
width: 100%;
height: 260px;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="oee-chart" ref="chartRef"></div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'OeeStoppageTop',
props: {
data: {
type: Array,
default: () => []
},
topN: {
type: Number,
default: 10
}
},
data() {
return {
chart: null
}
},
mounted() {
this.initChart()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
watch: {
data: {
deep: true,
immediate: true,
handler() {
this.render()
}
}
},
methods: {
toFixed3(val) {
const n = Number(val)
return Number.isFinite(n) ? n.toFixed(3) : '-'
},
initChart() {
if (!this.$refs.chartRef) return
this.chart = echarts.init(this.$refs.chartRef)
},
render() {
if (!this.chart) this.initChart()
if (!this.chart) return
const list = Array.isArray(this.data) ? this.data.slice() : []
list.sort((a, b) => (b.duration || 0) - (a.duration || 0))
const top = list.slice(0, this.topN)
const names = top.map((d, idx) => `${idx + 1}. ${d.stopType || '未知'}`)
const mins = top.map(d => Number(((d.duration || 0) / 60).toFixed(3)))
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const p = params && params[0]
if (!p) return ''
return `${p.name}<br/>${p.marker}${p.seriesName}${this.toFixed3(p.value)} min`
}
},
grid: { left: 140, right: 20, top: 20, bottom: 20 },
xAxis: {
type: 'value',
name: '停机时间 (min)',
axisLabel: { formatter: v => this.toFixed3(v) }
},
yAxis: {
type: 'category',
data: names,
axisLabel: { fontSize: 11 }
},
series: [
{
name: '停机时间 (min)',
type: 'bar',
data: mins,
itemStyle: { color: '#67C23A' }
}
]
}
this.chart.setOption(option)
},
handleResize() {
this.chart && this.chart.resize()
}
}
}
</script>
<style scoped>
.oee-chart {
width: 100%;
height: 260px;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="oee-chart" ref="chartRef"></div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'OeeTrendChart',
props: {
data: {
type: Array,
default: () => []
}
},
data() {
return {
chart: null
}
},
mounted() {
this.initChart()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
watch: {
data: {
deep: true,
immediate: true,
handler() {
this.render()
}
}
},
methods: {
toFixed3(val) {
const n = Number(val)
return Number.isFinite(n) ? n.toFixed(3) : '-'
},
initChart() {
if (!this.$refs.chartRef) return
this.chart = echarts.init(this.$refs.chartRef)
},
render() {
if (!this.chart) this.initChart()
if (!this.chart) return
const list = Array.isArray(this.data) ? this.data : []
const x = list.map(d => d.statDate)
const oee = list.map(d => Number(d.oee || 0))
const a = list.map(d => Number(d.availability || 0))
const p = list.map(d => Number(d.performanceTon || 0))
const q = list.map(d => Number(d.quality || 0))
const option = {
tooltip: {
trigger: 'axis',
formatter: params => {
const title = (params && params[0] && params[0].axisValue) || ''
const lines = (params || []).map(p0 => `${p0.marker}${p0.seriesName}${this.toFixed3(p0.value)} %`)
return [title, ...lines].join('<br/>')
}
},
legend: { top: 0, left: 'center' },
grid: { left: 40, right: 20, top: 30, bottom: 40 },
xAxis: { type: 'category', data: x },
yAxis: {
type: 'value',
name: '百分比 (%)',
axisLabel: { formatter: v => this.toFixed3(v) }
},
series: [
{ name: 'OEE', type: 'line', data: oee, smooth: true },
{ name: 'A', type: 'line', data: a, smooth: true },
{ name: 'P_ton', type: 'line', data: p, smooth: true },
{ name: 'Q', type: 'line', data: q, smooth: true }
]
}
this.chart.setOption(option)
},
handleResize() {
this.chart && this.chart.resize()
}
}
}
</script>
<style scoped>
.oee-chart {
width: 100%;
height: 260px;
}
</style>

View File

@@ -0,0 +1,745 @@
<template>
<div class="oee-report-page">
<!-- 查询条件概览去掉大标题只保留区间提示 -->
<el-card class="oee-header-card" shadow="never">
<div class="oee-header">
<div class="oee-title-block">
<div class="oee-subtitle">
查询区间
<span v-if="queryRange && queryRange.length === 2">
{{ queryRange[0] }} ~ {{ queryRange[1] }}
</span>
</div>
</div>
<div class="oee-query-bar">
<el-date-picker
v-model="queryRange"
type="daterange"
unlink-panels
size="small"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
:picker-options="pickerOptions"
/>
<el-button
type="primary"
size="small"
icon="el-icon-search"
@click="handleSearch"
>
查询
</el-button>
<el-button
size="small"
icon="el-icon-refresh"
@click="handleReset"
>
重置
</el-button>
<el-button
size="small"
icon="el-icon-document"
@click="handleExportWord"
>
导出 Word
</el-button>
</div>
</div>
</el-card>
<el-row :gutter="16" class="oee-main-row">
<!-- 左侧报表主体Word 风格 -->
<el-col :span="18">
<el-card class="oee-word-card" shadow="never">
<!-- KPI 一览报表头部表格 -->
<div class="oee-section-title">KPI 总览酸轧线 SY</div>
<div class="oee-help-text">
展示所选期间酸轧线整体 OEE A/P/Q 等关键指标用于快速判断本期综合表现好坏
</div>
<el-table
:data="[kpi]"
border
size="mini"
class="oee-kpi-table"
v-loading="loading.summary"
>
<el-table-column prop="oee" label="OEE (%)" align="center">
<template slot-scope="scope">
<span>{{ formatPercent(scope.row.oee) }}</span>
</template>
</el-table-column>
<el-table-column prop="availability" label="时间稼动率 A (%)" align="center">
<template slot-scope="scope">
<span>{{ formatPercent(scope.row.availability) }}</span>
</template>
</el-table-column>
<el-table-column prop="performanceTon" label="性能稼动率 P_ton (%)" align="center">
<template slot-scope="scope">
<span>{{ formatPercent(scope.row.performanceTon) }}</span>
</template>
</el-table-column>
<el-table-column prop="quality" label="良品率 Q (%)" align="center">
<template slot-scope="scope">
<span>{{ formatPercent(scope.row.quality) }}</span>
</template>
</el-table-column>
<el-table-column prop="loadingTimeMin" label="负荷时间 (min)" align="center" />
<el-table-column prop="downtimeMin" label="停机时间 (min)" align="center" />
<el-table-column prop="runTimeMin" label="运转时间 (min)" align="center" />
<el-table-column prop="totalOutputTon" label="总产量 (吨)" align="center">
<template slot-scope="scope">
<span>{{ formatNumber(scope.row.totalOutputTon) }}</span>
</template>
</el-table-column>
<el-table-column prop="totalOutputCoil" label="总产量 (卷)" align="center" />
<el-table-column prop="goodOutputTon" label="良品量 (吨)" align="center">
<template slot-scope="scope">
<span>{{ formatNumber(scope.row.goodOutputTon) }}</span>
</template>
</el-table-column>
<el-table-column prop="defectOutputTon" label="次品量 (吨)" align="center">
<template slot-scope="scope">
<span>{{ formatNumber(scope.row.defectOutputTon) }}</span>
</template>
</el-table-column>
</el-table>
<!-- 日明细趋势表格风格方便导出 Word -->
<div class="oee-section-title">日明细用于趋势分析</div>
<div class="oee-help-text">
按天拆分 A/P/Q 及产量等指标用于观察本月趋势波动点以及与重大事件的对应关系
</div>
<el-table
:data="summaryList"
border
size="mini"
class="oee-daily-table"
v-loading="loading.summary"
>
<el-table-column prop="statDate" label="日期" />
<el-table-column prop="oee" label="OEE (%)" >
<template slot-scope="scope">
{{ formatPercent(scope.row.oee) }}
</template>
</el-table-column>
<el-table-column prop="availability" label="A (%)" >
<template slot-scope="scope">
{{ formatPercent(scope.row.availability) }}
</template>
</el-table-column>
<el-table-column prop="performanceTon" label="P_ton (%)">
<template slot-scope="scope">
{{ formatPercent(scope.row.performanceTon) }}
</template>
</el-table-column>
<el-table-column prop="quality" label="Q (%)">
<template slot-scope="scope">
{{ formatPercent(scope.row.quality) }}
</template>
</el-table-column>
<el-table-column prop="loadingTimeMin" label="负荷 (min)"/>
<el-table-column prop="downtimeMin" label="停机 (min)"/>
<el-table-column prop="runTimeMin" label="运转 (min)"/>
<el-table-column prop="totalOutputTon" label="总产量 (吨)">
<template slot-scope="scope">
{{ formatNumber(scope.row.totalOutputTon) }}
</template>
</el-table-column>
<el-table-column prop="totalOutputCoil" label="总产量 (卷)" />
<el-table-column prop="goodOutputTon" label="良品 (吨)">
<template slot-scope="scope">
{{ formatNumber(scope.row.goodOutputTon) }}
</template>
</el-table-column>
<el-table-column prop="defectOutputTon" label="次品 (吨)">
<template slot-scope="scope">
{{ formatNumber(scope.row.defectOutputTon) }}
</template>
</el-table-column>
</el-table>
<!-- OEE/A/P/Q 趋势图 -->
<oee-trend-chart :data="summaryList" />
<!-- 理论节拍统计口径 -->
<div class="oee-section-title">理论节拍统计口径</div>
<div class="oee-help-text">
基于历史优良日统计得到的理论节拍中位数用于作为性能稼动率计算的稳定标尺
</div>
<div
v-if="idealCycle && idealCycle.dailyComparePoints && idealCycle.dailyComparePoints.length"
class="oee-reg-section"
>
<div class="reg-chart-block">
<div class="reg-chart-title">日粒度理论耗时 vs 实际运转时间</div>
<div class="oee-help-text">
按天对比理想应耗时间实际运转时间可一眼看出哪几天效率偏低存在明显损失空间
</div>
<div ref="regDailyCompareChart" class="reg-chart"></div>
</div>
</div>
<!-- 7 大损失 -->
<div class="oee-section-title">7 大损失汇总 stop_type 分类</div>
<div class="oee-help-text">
将所有停机事件按 stop_type 归类统计时间占比和次数用于确定先从哪几类损失下手改善
</div>
<el-table
:data="loss7List"
border
size="mini"
class="oee-loss7-table"
v-loading="loading.loss7"
>
<el-table-column prop="lossCategoryName" label="损失类别" min-width="160" />
<el-table-column prop="lossTimeMin" label="损失时间 (min)" width="140" />
<el-table-column prop="lossTimeRate" label="占比 (%)" width="110">
<template slot-scope="scope">
{{ formatPercent(scope.row.lossTimeRate) }}
</template>
</el-table-column>
<el-table-column prop="count" label="次数" width="90" />
<el-table-column prop="avgDurationMin" label="平均时长 (min)" width="140">
<template slot-scope="scope">
{{ formatNumber(scope.row.avgDurationMin) }}
</template>
</el-table-column>
</el-table>
<!-- 7 大损失帕累托图 -->
<oee-loss-pareto :data="loss7List" />
<!-- 停机事件明细 -->
<div class="oee-section-title">停机/损失事件明细</div>
<div class="oee-help-text">
罗列每一条停机/损失事件包含时间段区域机组和备注方便班组和工艺人员对照现场记录进行原因分析
</div>
<el-table
:data="eventList"
border
size="mini"
class="oee-events-table"
v-loading="loading.events"
>
<el-table-column prop="startDate" label="开始时间" min-width="150">
<template slot-scope="scope">
{{ formatDateTime(scope.row.startDate) }}
</template>
</el-table-column>
<el-table-column prop="endDate" label="结束时间" min-width="150">
<template slot-scope="scope">
{{ formatDateTime(scope.row.endDate) }}
</template>
</el-table-column>
<el-table-column prop="duration" label="时长 (s)" width="100" />
<el-table-column prop="durationMin" label="时长 (min)" width="110">
<template slot-scope="scope">
{{ Math.max(1, Math.floor((scope.row.duration || 0) / 60)) }}
</template>
</el-table-column>
<el-table-column prop="stopType" label="停机类型 (stop_type)" min-width="160" />
<el-table-column prop="area" label="区域" width="100" />
<el-table-column prop="unit" label="机组" width="100" />
<el-table-column prop="shift" label="班次" width="80" />
<el-table-column prop="crew" label="班组" width="80" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
</el-table>
<!-- 停机 TopN 条形图 -->
<oee-stoppage-top :data="eventList" :top-n="10" />
</el-card>
</el-col>
<!-- 右侧公式与口径说明支撑材料 -->
<el-col :span="6">
<el-card class="oee-formula-card" shadow="never">
<div slot="header" class="clearfix">
<span>公式与口径说明</span>
</div>
<div class="formula-block">
<div class="formula-title">OEE 总公式</div>
<div class="formula-eq">OEE = A × P × Q</div>
<ul class="formula-list">
<li>A时间稼动率 = (负荷时间 停机时间) / 负荷时间</li>
<li>P性能稼动率吨维度 = (理论节拍 × 产量吨) / 实际运转时间</li>
<li>Q良品率 = 良品吨 / 总产量吨</li>
</ul>
</div>
<div class="formula-block">
<div class="formula-title">关键字段定义</div>
<ul class="formula-list">
<li><b>负荷时间</b>计划生产时间扣除计划停机后的时间</li>
<li><b>停机时间</b>所有停机/中断 stop_type 汇总的总时长</li>
<li><b>实际运转时间</b>负荷时间 停机时间</li>
<li><b>理论节拍</b>优良日统计口径得到的稳定节拍分钟/由理论节拍接口提供</li>
<li><b>良品/次品</b> WMS `quality_status` 判断C+/C/C-/D+/D/D- </li>
</ul>
</div>
<div class="formula-block" v-if="idealCycle">
<div class="formula-title">当前理论节拍统计口径</div>
<el-descriptions :column="1" size="small" class="reg-desc">
<el-descriptions-item label="理论节拍 (min/吨)">
{{ formatNumber(idealCycle.idealCycleTimeMinPerTon) }}
</el-descriptions-item>
<el-descriptions-item label="生产节拍中位数 (min/吨)">
{{ formatNumber(idealCycle.medianCycleTimeMinPerTon) }}
</el-descriptions-item>
<el-descriptions-item label="样本天数">
{{ idealCycle.sampleDays || 0 }}
</el-descriptions-item>
<el-descriptions-item label="吨数下限 (吨)">
{{ formatNumber(idealCycle.minWeightTon) }}
</el-descriptions-item>
<el-descriptions-item label="停机占比上限">
{{
idealCycle.maxDowntimeRate != null
? (Number(idealCycle.maxDowntimeRate) * 100).toFixed(0) + '%'
: '-'
}}
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import {
exportOeeWord,
fetchOeeSummary,
fetchOeeLoss7,
fetchOeeEvents,
fetchOeeIdealCycle
} from '@/api/da/oee'
import * as echarts from 'echarts'
import OeeTrendChart from './components/OeeTrendChart.vue'
import OeeLossPareto from './components/OeeLossPareto.vue'
import OeeStoppageTop from './components/OeeStoppageTop.vue'
export default {
name: 'DaOeeIndex',
components: {
OeeTrendChart,
OeeLossPareto,
OeeStoppageTop
},
data() {
const today = new Date()
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1)
return {
queryRange: [
this.formatDate(firstDay),
this.formatDate(today)
],
pickerOptions: {
disabledDate(time) {
// 不限制选择范围,保留扩展空间
return false
}
},
loading: {
summary: false,
loss7: false,
events: false,
idealCycle: false,
export: false
},
summaryList: [],
loss7List: [],
eventList: [],
idealCycle: null,
regDailyCompareChart: null
}
},
computed: {
kpi() {
const list = Array.isArray(this.summaryList) ? this.summaryList : []
if (list.length === 0) {
return {
oee: 0,
availability: 0,
performanceTon: 0,
quality: 0,
loadingTimeMin: 0,
downtimeMin: 0,
runTimeMin: 0,
totalOutputTon: 0,
totalOutputCoil: 0,
goodOutputTon: 0,
defectOutputTon: 0
}
}
let sumLoading = 0
let sumDowntime = 0
let sumRun = 0
let sumTotalTon = 0
let sumGoodTon = 0
let sumDefectTon = 0
let sumCoil = 0
let sumOee = 0
let sumA = 0
let sumP = 0
let sumQ = 0
list.forEach(row => {
sumLoading += row.loadingTimeMin || 0
sumDowntime += row.downtimeMin || 0
sumRun += row.runTimeMin || 0
sumTotalTon += Number(row.totalOutputTon || 0)
sumGoodTon += Number(row.goodOutputTon || 0)
sumDefectTon += Number(row.defectOutputTon || 0)
sumCoil += row.totalOutputCoil || 0
sumOee += Number(row.oee || 0)
sumA += Number(row.availability || 0)
sumP += Number(row.performanceTon || 0)
sumQ += Number(row.quality || 0)
})
const n = list.length
const defectAgg = Math.max(0, sumTotalTon - sumGoodTon)
return {
oee: n ? sumOee / n : 0,
availability: n ? sumA / n : 0,
performanceTon: n ? sumP / n : 0,
quality: n ? sumQ / n : 0,
loadingTimeMin: sumLoading,
downtimeMin: sumDowntime,
runTimeMin: sumRun,
totalOutputTon: sumTotalTon,
totalOutputCoil: sumCoil,
goodOutputTon: sumGoodTon,
defectOutputTon: defectAgg || sumDefectTon
}
}
},
created() {
this.loadAll()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.regDailyCompareChart) {
this.regDailyCompareChart.dispose()
this.regDailyCompareChart = null
}
},
methods: {
formatDate(d) {
const y = d.getFullYear()
const m = (`0${d.getMonth() + 1}`).slice(-2)
const day = (`0${d.getDate()}`).slice(-2)
return `${y}-${m}-${day}`
},
formatPercent(value) {
if (value === null || value === undefined || isNaN(value)) {
return '-'
}
const num = Number(value)
return num.toFixed(1)
},
formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '-'
}
const num = Number(value)
if (isNaN(num)) return '-'
return num.toFixed(2)
},
formatDateTime(val) {
if (!val) return ''
// 后端可能返回字符串或时间戳,这里做容错
if (typeof val === 'string') return val
try {
const d = new Date(val)
const y = d.getFullYear()
const m = (`0${d.getMonth() + 1}`).slice(-2)
const day = (`0${d.getDate()}`).slice(-2)
const hh = (`0${d.getHours()}`).slice(-2)
const mm = (`0${d.getMinutes()}`).slice(-2)
const ss = (`0${d.getSeconds()}`).slice(-2)
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
} catch (e) {
return ''
}
},
buildQuery() {
const [start, end] = this.queryRange || []
return {
startDate: start,
endDate: end
}
},
async loadAll() {
await Promise.all([
this.loadSummary(),
this.loadLoss7(),
this.loadEvents(),
this.loadIdealCycle()
])
},
async loadSummary() {
this.loading.summary = true
try {
const res = await fetchOeeSummary(this.buildQuery())
// 兼容后端直接返回数组或 TableDataInfo { rows, total } 两种结构
this.summaryList = res.data
} catch (e) {
this.$message.error('加载酸轧日汇总失败')
} finally {
this.loading.summary = false
}
},
async loadLoss7() {
this.loading.loss7 = true
try {
const res = await fetchOeeLoss7(this.buildQuery())
// 兼容后端直接返回数组或 TableDataInfo { rows, total } 两种结构
this.loss7List = res.data
} catch (e) {
this.$message.error('加载酸轧 7 大损失失败')
} finally {
this.loading.loss7 = false
}
},
async loadEvents() {
this.loading.events = true
try {
const res = await fetchOeeEvents(this.buildQuery())
// 后端 TableDataInfo 结构:{ rows, total }
this.eventList = (res && Array.isArray(res.rows)) ? res.rows : []
} catch (e) {
this.$message.error('加载酸轧停机事件失败')
} finally {
this.loading.events = false
}
},
async loadIdealCycle() {
this.loading.idealCycle = true
try {
const res = await fetchOeeIdealCycle(this.buildQuery())
this.idealCycle = res && res.data ? res.data : null
this.$nextTick(() => {
this.renderIdealCycleChart()
})
} catch (e) {
this.$message.error('加载理论节拍失败')
} finally {
this.loading.idealCycle = false
}
},
handleSearch() {
if (!this.queryRange || this.queryRange.length !== 2) {
this.$message.warning('请选择查询日期范围')
return
}
this.loadAll()
},
handleReset() {
const today = new Date()
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1)
this.queryRange = [this.formatDate(firstDay), this.formatDate(today)]
this.loadAll()
},
async handleExportWord() {
this.loading.export = true
try {
const res = await exportOeeWord(this.buildQuery())
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `酸轧线OEE报表_${this.queryRange[0]}_${this.queryRange[1]}.docx`
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
this.$message.error('导出 Word 失败')
} finally {
this.loading.export = false
}
},
renderIdealCycleChart() {
if (!this.idealCycle) return
const dailyPoints = Array.isArray(this.idealCycle.dailyComparePoints)
? this.idealCycle.dailyComparePoints
: []
if (this.$refs.regDailyCompareChart && dailyPoints.length) {
if (!this.regDailyCompareChart) {
this.regDailyCompareChart = echarts.init(this.$refs.regDailyCompareChart)
}
const categories = dailyPoints.map(d => d.statDate)
const actualRun = dailyPoints.map(d => Number(d.actualRunTimeMin || 0))
const theoretical = dailyPoints.map(d => Number(d.theoreticalTimeMin || 0))
const compareOption = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 30, bottom: 40 },
legend: { top: 0, left: 'center' },
xAxis: {
type: 'category',
data: categories,
axisLabel: { fontSize: 10 }
},
yAxis: {
type: 'value',
name: '时间 (min)',
nameLocation: 'middle',
nameGap: 35
},
series: [
{
name: '实际运转时间',
type: 'line',
data: actualRun,
smooth: true,
symbolSize: 4,
itemStyle: { color: '#409EFF' }
},
{
name: '理论耗时',
type: 'line',
data: theoretical,
smooth: true,
symbolSize: 4,
itemStyle: { color: '#E6A23C' }
}
]
}
this.regDailyCompareChart.setOption(compareOption)
}
},
handleResize() {
this.regDailyCompareChart && this.regDailyCompareChart.resize()
}
}
}
</script>
<style scoped>
.oee-report-page {
padding: 16px;
background: #f5f7fa;
}
.oee-header-card {
margin-bottom: 12px;
}
.oee-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.oee-title-block {
display: flex;
flex-direction: column;
}
.oee-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.oee-subtitle {
margin-top: 4px;
font-size: 13px;
color: #606266;
}
.oee-query-bar {
display: flex;
align-items: center;
gap: 8px;
}
.oee-main-row {
margin-top: 8px;
}
.oee-word-card {
min-height: 600px;
background: #ffffff;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
overflow-x: hidden;
}
.oee-section-title {
margin: 12px 0 6px;
font-weight: 600;
color: #303133;
}
.oee-help-text {
margin: 0 0 6px;
font-size: 12px;
color: #909399;
}
.oee-kpi-table,
.oee-daily-table,
.oee-loss7-table,
.oee-events-table {
font-size: 12px;
}
.oee-formula-card {
background: #ffffff;
}
.formula-block {
margin-bottom: 16px;
}
.formula-title {
font-weight: 600;
margin-bottom: 6px;
color: #303133;
}
.formula-eq {
font-family: 'Times New Roman', serif;
font-style: italic;
margin-bottom: 6px;
}
.formula-list {
padding-left: 16px;
margin: 0;
}
.formula-list li {
margin-bottom: 4px;
}
.reg-desc {
font-size: 12px;
}
.reg-chart {
width: 100%;
height: 260px;
border: 1px solid #ebeef5;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
}
.reg-chart canvas {
max-width: 100% !important;
}
</style>

View File

@@ -3,148 +3,177 @@
<div>
<div class="waybill-container" ref="waybillRef">
<div class="waybill-content">
<!-- 头部信息 -->
<!-- 标题信息 -->
<div class="title">科伦普发货单</div>
<div class="waybill-header">
<div class="header-left">
<span class="label">收货单位</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.consigneeUnit }}</div>
<!-- 头部信息 -->
<!-- 标题信息 -->
<div class="title">科伦普发货单</div>
<div class="waybill-header">
<div class="header-left">
<span class="label">收货单位</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.consigneeUnit }}</div>
</div>
<div class="header-center">
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryYear }}
</div>
<span class="label date-label"></span>
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryMonth }}
</div>
<span class="label date-label"></span>
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryDay }}
</div>
<span class="label date-label"></span>
</div>
<div class="header-right">
<span class="label">发货单位</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.senderUnit }}</div>
</div>
</div>
<div class="header-center">
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryYear }}</div>
<span class="label date-label"></span>
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryMonth }}</div>
<span class="label date-label"></span>
<div class="editable-input date-input transparent-input" contenteditable>{{ localWaybill.deliveryDay }}</div>
<span class="label date-label"></span>
</div>
<div class="header-right">
<span class="label">发货单位</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.senderUnit }}</div>
</div>
</div>
<div class="waybill-header">
<div class="header-left">
<span class="label">负责人</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.principal }}</div>
<div class="waybill-header">
<div class="header-left">
<span class="label">负责人</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.principal }}</div>
</div>
<div class="header-right">
<span class="label">电话</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.principalPhone }}</div>
</div>
<div class="header-right">
<span class="label">合同号</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.contractCode }}</div>
</div>
<div class="header-center">
<span class="label">车牌</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.licensePlate }}</div>
</div>
</div>
<div class="header-right">
<span class="label">电话</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.principalPhone }}</div>
</div>
<div class="header-right">
<span class="label">合同号</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.contractCode }}</div>
</div>
<div class="header-center">
<span class="label">车牌</span>
<div class="editable-input transparent-input" contenteditable>{{ localWaybill.licensePlate }}</div>
</div>
</div>
<!-- 表格 -->
<table class="waybill-table">
<thead>
<tr>
<th>品名</th>
<th>切边</th>
<th>包装</th>
<th>仓库位置</th>
<th>结算</th>
<th>原料厂家</th>
<th>卷号</th>
<th>规格</th>
<th>材质</th>
<!-- <th>数量</th> -->
<th>重量(t)</th>
<th>单价</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<!-- 无明细提示 -->
<tr v-if="localWaybillDetails.length === 0">
<td colspan="12" class="no-data">
<div class="no-data-content">
<el-empty description="暂无发货单明细" />
</div>
</td>
</tr>
<!-- 明细数据 -->
<tr v-for="(item, index) in displayWaybillDetails" :key="index">
<td><div class="table-input transparent-input" contenteditable>{{ item.productName }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.edgeType }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.packageType }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.actualWarehouseName }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.settlementType }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.rawMaterialFactory }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.coilNumber }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.specification }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.material }}</div></td>
<!-- <td><input type="number" class="table-input transparent-input" v-model.number="item.quantity"
<!-- 表格 -->
<table class="waybill-table">
<thead>
<tr>
<th>品名</th>
<th>切边</th>
<th>包装</th>
<th>仓库位置</th>
<th>结算</th>
<th>原料厂家</th>
<th>卷号</th>
<th>规格</th>
<th>材质</th>
<!-- <th>数量</th> -->
<th>重量(t)</th>
<th>单价</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<!-- 无明细提示 -->
<tr v-if="localWaybillDetails.length === 0">
<td colspan="12" class="no-data">
<div class="no-data-content">
<el-empty description="暂无发货单明细" />
</div>
</td>
</tr>
<!-- 明细数据 -->
<tr v-for="(item, index) in displayWaybillDetails" :key="index">
<td>
<div class="table-input transparent-input" contenteditable>{{ item.productName }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.edgeType }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.packageType }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.actualWarehouseName }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.settlementType }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.rawMaterialFactory }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.coilNumber }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.specification }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.material }}</div>
</td>
<!-- <td><input type="number" class="table-input transparent-input" v-model.number="item.quantity"
placeholder="0" /></td> -->
<td><input type="number" class="table-input transparent-input" v-model.number="item.weight"
placeholder="0.00" /></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.unitPrice }}</div></td>
<td><div class="table-input transparent-input" contenteditable>{{ item.remark }}</div></td>
</tr>
<!-- 加粗最后一行的线 -->
<tr style="height: 0;">
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<!-- <td><input type="number" class="table-input transparent-input" v-model.number="item.quantity"
<td><input type="number" class="table-input transparent-input" v-model.number="item.weight"
placeholder="0.00" /></td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.unitPrice }}</div>
</td>
<td>
<div class="table-input transparent-input" contenteditable>{{ item.remark }}</div>
</td>
</tr>
<!-- 加粗最后一行的线 -->
<tr style="height: 0;">
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<!-- <td><input type="number" class="table-input transparent-input" v-model.number="item.quantity"
placeholder="0" /></td> -->
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
</tr>
</tbody>
</table>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
<td style="height: 0;"></td>
</tr>
</tbody>
</table>
<!-- 备注说明 -->
<div class="waybill-remarks">
<p>
1品名冷硬钢卷酸连轧冷轧钢卷脱脂退火火拉矫镀锌卷板镀锌管料镀锌分剪料2切边净边/毛边3包装裸包周三径四简包1周三径四内外护角简包2周三径四+防锈纸普包周三径四+内外护角+防锈纸+端护板精包1周三径四+内外护角+防锈纸+薄膜+端护板+内外护板精包2周三径四+内外护角+防锈纸+薄膜+端护板+内外护板+木托
</p>
</div>
<!-- 备注说明 -->
<div class="waybill-remarks">
<p>
1品名冷硬钢卷酸连轧冷轧钢卷脱脂退火火拉矫镀锌卷板镀锌管料镀锌分剪料2切边净边/毛边3包装裸包周三径四简包1周三径四内外护角简包2周三径四+防锈纸普包周三径四+内外护角+防锈纸+端护板精包1周三径四+内外护角+防锈纸+薄膜+端护板+内外护板精包2周三径四+内外护角+防锈纸+薄膜+端护板+内外护板+木托
</p>
</div>
<div class="waybill-pickup-location">
<!-- <div class="pickup-location-item inline"> -->
<span style="font-size: 18px; font-weight: bold;">取货地点</span>
<input type="text" class="editable-input full-input transparent-input" v-model="localWaybill.pickupLocation" />
<!-- </div> -->
</div>
<div class="waybill-pickup-location">
<!-- <div class="pickup-location-item inline"> -->
<span style="font-size: 18px; font-weight: bold;">取货地点</span>
<input type="text" class="editable-input full-input transparent-input"
v-model="localWaybill.pickupLocation" />
<!-- </div> -->
</div>
<!-- 签名栏 -->
<div class="waybill-footer">
<div class="footer-item inline">
<!-- 签名栏 -->
<div class="waybill-footer">
<!-- <div class="footer-item inline">
<span class="label">销售</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.salesman }}</div>
</div> -->
<div class="footer-item inline">
<span class="label">发货</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.deliveryman }}
</div>
</div>
<div class="footer-item inline">
<span class="label">司机</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.driver }}
</div>
</div>
<div class="footer-item inline">
<span class="label">磅房</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.weightRoom }}
</div>
</div>
</div>
<div class="footer-item inline">
<span class="label">发货</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.deliveryman }}</div>
</div>
<div class="footer-item inline">
<span class="label">司机</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.driver }}</div>
</div>
<div class="footer-item inline">
<span class="label">磅房</span>
<div class="editable-input signature-input transparent-input" contenteditable>{{ localWaybill.weightRoom }}</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
@@ -152,7 +181,8 @@
<div class="waybill-pagebar">
<el-button size="mini" @click="changePage(currentPage - 1)" :disabled="currentPage <= 1">上一页</el-button>
<span class="page-info"> {{ currentPage }} / {{ totalPages }} </span>
<el-button size="mini" @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages">下一页</el-button>
<el-button size="mini" @click="changePage(currentPage + 1)"
:disabled="currentPage >= totalPages">下一页</el-button>
</div>
<el-button type="primary" @click="saveAsImage">保存为图片</el-button>
<el-button type="success" @click="printWaybill">打印</el-button>
@@ -396,15 +426,15 @@ export default {
scrollY: 0
});
const png = canvas.toDataURL('image/png');
const imgPng = await pdfDoc.embedPng(png);
const png = canvas.toDataURL('image/png');
const imgPng = await pdfDoc.embedPng(png);
const pdfPage = pdfDoc.addPage([pageWidthPt, pageHeightPt]);
pdfPage.drawImage(imgPng, {
x: 0,
y: 0,
width: pageWidthPt,
height: pageHeightPt
});
x: 0,
y: 0,
width: pageWidthPt,
height: pageHeightPt
});
}
} finally {
// 恢复截图前的容器样式
@@ -628,20 +658,20 @@ export default {
} */
/* 重量kg */
/* 重量kg */
.waybill-table th:nth-child(10),
.waybill-table td:nth-child(10) {
width: 70px;
}
/* 单价 */
/* 单价 */
.waybill-table th:nth-child(11),
.waybill-table td:nth-child(11) {
width: 50px;
}
/* 备注 */
/* 备注 */
.waybill-table th:nth-child(12),
.waybill-table td:nth-child(12) {
/* width: 40px; */
@@ -827,6 +857,7 @@ export default {
}
@media print {
html,
body {
margin: 0;

View File

@@ -1,6 +1,7 @@
package com.klp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -51,6 +52,7 @@ import java.math.BigDecimal;
@Slf4j
@RequiredArgsConstructor
@Service
@DS("master")
public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
private final WmsMaterialCoilMapper baseMapper;
@@ -401,8 +403,6 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
qw.eq(StringUtils.isNotBlank(bo.getItemType()), "mc.item_type", bo.getItemType());
qw.eq(StringUtils.isNotBlank(bo.getCreateBy()), "mc.create_by", bo.getCreateBy());
qw.eq(StringUtils.isNotBlank(bo.getUpdateBy()), "mc.update_by", bo.getUpdateBy());
//逻辑删除
qw.eq("mc.del_flag", 0);
// 切边要求
qw.eq(StringUtils.isNotBlank(bo.getTrimmingRequirement()), "mc.trimming_requirement", bo.getTrimmingRequirement());
// 打包状态
@@ -584,6 +584,8 @@ public class WmsMaterialCoilServiceImpl implements IWmsMaterialCoilService {
// "WHERE dp.del_flag = 0 AND dp.coil IS NOT NULL AND dp.coil <> '' " +
// "AND FIND_IN_SET(CAST(mc.coil_id AS CHAR), dp.coil))");
// }
//逻辑删除
qw.eq("mc.del_flag", 0);
//把team字段作为筛选条件
qw.eq(StringUtils.isNotBlank(bo.getTeam()), "mc.team", bo.getTeam());
//根据开始时间和结束时间筛选修改时间