|
|
@@ -33,6 +33,11 @@ import java.util.Set;
|
|
|
* isolated, and resolves the handled disposal scope through the authoritative
|
|
|
* work-order dictionary (ADR-0010): an event counts as handled only when its
|
|
|
* associated work order stateCode equals 5 (已结案).
|
|
|
+ *
|
|
|
+ * <p>票 61(前端统一分页批次三):响应同时携带**小时桶聚合**(趋势,口径不变)
|
|
|
+ * 与**逐事件行**({@code events[]},形状与 {@code /events/current} 的 EventItem 同构,
|
|
|
+ * 按 eventTime 倒序、同时间按 eventNo 升序),逐事件行由 {@code page/pageSize} 分页。
|
|
|
+ * 两条口径取自同一份窗口内事件集合,可互相复算。
|
|
|
*/
|
|
|
@Service
|
|
|
public class CockpitHistoryAnalysisService {
|
|
|
@@ -42,6 +47,8 @@ public class CockpitHistoryAnalysisService {
|
|
|
private static final String WARNING = "WARNING";
|
|
|
private static final String ALARM = "ALARM";
|
|
|
private static final int HANDLED_STATE_CODE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
|
|
|
+ private static final int DEFAULT_PAGE_SIZE = 100;
|
|
|
+ private static final int MAX_PAGE_SIZE = 1000;
|
|
|
private static final long DEFAULT_WINDOW_DAYS = 7;
|
|
|
private static final long MAX_WINDOW_DAYS = 31;
|
|
|
private static final DateTimeFormatter TIMESTAMP =
|
|
|
@@ -64,18 +71,23 @@ public class CockpitHistoryAnalysisService {
|
|
|
String level,
|
|
|
String industry,
|
|
|
String startTime,
|
|
|
- String endTime) {
|
|
|
+ String endTime,
|
|
|
+ Integer page,
|
|
|
+ Integer pageSize) {
|
|
|
String family = resolveFamily(eventFamily);
|
|
|
Integer resolvedLevel = resolveLevel(level);
|
|
|
String selectedIndustry = resolveIndustry(industry);
|
|
|
TimeWindow window = resolveWindow(startTime, endTime);
|
|
|
+ int safePage = page == null ? 1 : page;
|
|
|
+ int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
|
|
|
+ validatePagination(safePage, safePageSize);
|
|
|
if (state == null) {
|
|
|
CockpitHistoryAnalysisSnapshot loaded = adapter.load();
|
|
|
state = loaded == null
|
|
|
? CockpitHistoryAnalysisSnapshot.empty(businessTime())
|
|
|
: loaded;
|
|
|
}
|
|
|
- return build(requestId, family, resolvedLevel, selectedIndustry, window);
|
|
|
+ return build(requestId, family, resolvedLevel, selectedIndustry, window, safePage, safePageSize);
|
|
|
}
|
|
|
|
|
|
public CockpitErrorPayload invalidRequest(String requestId, String message) {
|
|
|
@@ -99,12 +111,14 @@ public class CockpitHistoryAnalysisService {
|
|
|
String family,
|
|
|
Integer level,
|
|
|
String industry,
|
|
|
- TimeWindow window) {
|
|
|
+ TimeWindow window,
|
|
|
+ int page,
|
|
|
+ int pageSize) {
|
|
|
Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types = types(state);
|
|
|
Map<String, Integer> handledLinks = handledLinks(state.getWorkOrders());
|
|
|
- List<EventFact> facts = WARNING.equals(family)
|
|
|
- ? warningFacts(state, types, level, industry)
|
|
|
- : alarmFacts(state, types, level, industry);
|
|
|
+ List<EventRow> eventRows = WARNING.equals(family)
|
|
|
+ ? warningRows(state, types, level, industry)
|
|
|
+ : alarmRows(state, types, level, industry);
|
|
|
|
|
|
ZonedDateTime first = window.start
|
|
|
.atZoneSameInstant(BUSINESS_ZONE)
|
|
|
@@ -122,11 +136,9 @@ public class CockpitHistoryAnalysisService {
|
|
|
|
|
|
int totalEvents = 0;
|
|
|
int totalHandled = 0;
|
|
|
- for (EventFact fact : facts) {
|
|
|
- if (fact.eventTime == null) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- ZonedDateTime bucket = fact.eventTime
|
|
|
+ List<EventRow> inWindow = new ArrayList<>();
|
|
|
+ for (EventRow row : eventRows) {
|
|
|
+ ZonedDateTime bucket = row.eventTime
|
|
|
.atZoneSameInstant(BUSINESS_ZONE)
|
|
|
.truncatedTo(ChronoUnit.HOURS);
|
|
|
String key = TIMESTAMP.format(bucket);
|
|
|
@@ -134,15 +146,16 @@ public class CockpitHistoryAnalysisService {
|
|
|
if (target == null) {
|
|
|
continue;
|
|
|
}
|
|
|
+ inWindow.add(row);
|
|
|
totalEvents++;
|
|
|
target.eventCount++;
|
|
|
- Integer stateCode = fact.eventNo == null ? null : handledLinks.get(fact.eventNo.trim());
|
|
|
+ Integer stateCode = row.eventNo == null ? null : handledLinks.get(row.eventNo.trim());
|
|
|
if (HANDLED_STATE_CODE == effectiveStateCode(stateCode)) {
|
|
|
target.handledCount++;
|
|
|
totalHandled++;
|
|
|
}
|
|
|
- if (!isBlank(fact.deviceCode)) {
|
|
|
- target.deviceCodes.add(fact.deviceCode.trim());
|
|
|
+ if (!isBlank(row.deviceCode)) {
|
|
|
+ target.deviceCodes.add(row.deviceCode.trim());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -158,6 +171,17 @@ public class CockpitHistoryAnalysisService {
|
|
|
family, sorted(bucket.deviceCodes))));
|
|
|
}
|
|
|
|
|
|
+ // 票 61:逐事件行分页(与 hours buckets 同一份窗口内事件集合)。
|
|
|
+ inWindow.sort(eventOrdering());
|
|
|
+ int total = inWindow.size();
|
|
|
+ int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
|
|
|
+ int fromIndex = Math.min((page - 1) * pageSize, total);
|
|
|
+ int toIndex = Math.min(fromIndex + pageSize, total);
|
|
|
+ List<CockpitHistoryAnalysisResponse.EventItem> events = new ArrayList<>();
|
|
|
+ for (EventRow row : inWindow.subList(fromIndex, toIndex)) {
|
|
|
+ events.add(row.toItem());
|
|
|
+ }
|
|
|
+
|
|
|
String updatedAt = isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt();
|
|
|
// 汇总处置率=范围内全部工单(状态 0–6)口径(决策票 06),与行级事件桶分开;
|
|
|
// 行级桶仍是事件时间桶,其 efficiency 保留事件口径。
|
|
|
@@ -180,17 +204,21 @@ public class CockpitHistoryAnalysisService {
|
|
|
// 保持事件口径,与行级 handledCount 同维(决策 q7:只改汇总处置率)。
|
|
|
totalHandled,
|
|
|
scopeEfficiency(scopeRate.handled, scopeRate.eligible)),
|
|
|
+ new CockpitHistoryAnalysisResponse.Pagination(
|
|
|
+ page, pageSize, total, totalPages, toIndex < total),
|
|
|
+ events,
|
|
|
rows);
|
|
|
}
|
|
|
|
|
|
- private List<EventFact> warningFacts(
|
|
|
+ /** 预警族逐事件行(字段取自历史快照;时间/等级/设备/位置与当前事件端点同源)。 */
|
|
|
+ private List<EventRow> warningRows(
|
|
|
CockpitHistoryAnalysisSnapshot snapshot,
|
|
|
Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
|
|
|
Integer level,
|
|
|
String industry) {
|
|
|
- List<EventFact> facts = new ArrayList<>();
|
|
|
+ List<EventRow> rows = new ArrayList<>();
|
|
|
if (snapshot.getWarnings() == null) {
|
|
|
- return facts;
|
|
|
+ return rows;
|
|
|
}
|
|
|
for (CockpitHistoryAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
|
|
|
if (warning == null
|
|
|
@@ -198,22 +226,38 @@ public class CockpitHistoryAnalysisService {
|
|
|
|| (level != null && !level.equals(warning.getLevel()))) {
|
|
|
continue;
|
|
|
}
|
|
|
- facts.add(new EventFact(
|
|
|
+ OffsetDateTime eventTime = parseEventTime(warning.getEventTime());
|
|
|
+ if (eventTime == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ rows.add(new EventRow(
|
|
|
warning.getWarningNo(),
|
|
|
- parseEventTime(warning.getEventTime()),
|
|
|
- warning.getDeviceCode()));
|
|
|
- }
|
|
|
- return facts;
|
|
|
+ warning.getWarningNo(),
|
|
|
+ warning.getTypeCode(),
|
|
|
+ warning.getLevel(),
|
|
|
+ warning.getStatus(),
|
|
|
+ eventTime,
|
|
|
+ warning.getUpdatedTime(),
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ warning.getDeviceCode(),
|
|
|
+ warning.getDeviceName(),
|
|
|
+ warning.getLocation(),
|
|
|
+ industryOf(types, warning.getTypeCode()),
|
|
|
+ null,
|
|
|
+ null));
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
}
|
|
|
|
|
|
- private List<EventFact> alarmFacts(
|
|
|
+ private List<EventRow> alarmRows(
|
|
|
CockpitHistoryAnalysisSnapshot snapshot,
|
|
|
Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
|
|
|
Integer level,
|
|
|
String industry) {
|
|
|
- List<EventFact> facts = new ArrayList<>();
|
|
|
+ List<EventRow> rows = new ArrayList<>();
|
|
|
if (snapshot.getAlarms() == null) {
|
|
|
- return facts;
|
|
|
+ return rows;
|
|
|
}
|
|
|
for (CockpitHistoryAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
|
|
|
if (alarm == null
|
|
|
@@ -221,12 +265,28 @@ public class CockpitHistoryAnalysisService {
|
|
|
|| (level != null && !level.equals(alarm.getLevel()))) {
|
|
|
continue;
|
|
|
}
|
|
|
- facts.add(new EventFact(
|
|
|
+ OffsetDateTime eventTime = parseEventTime(alarm.getEventTime());
|
|
|
+ if (eventTime == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ rows.add(new EventRow(
|
|
|
alarm.getAlarmId(),
|
|
|
- parseEventTime(alarm.getEventTime()),
|
|
|
- alarm.getDeviceCode()));
|
|
|
- }
|
|
|
- return facts;
|
|
|
+ isBlank(alarm.getEventName()) ? alarm.getAlarmId() : alarm.getEventName(),
|
|
|
+ alarm.getTypeCode(),
|
|
|
+ alarm.getLevel(),
|
|
|
+ alarm.getStatus(),
|
|
|
+ eventTime,
|
|
|
+ alarm.getUpdatedTime(),
|
|
|
+ alarm.getValue(),
|
|
|
+ alarm.getUnit(),
|
|
|
+ alarm.getDeviceCode(),
|
|
|
+ alarm.getDeviceName(),
|
|
|
+ alarm.getLocation(),
|
|
|
+ industryOf(types, alarm.getTypeCode()),
|
|
|
+ alarm.getMinValue(),
|
|
|
+ alarm.getMaxValue()));
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
}
|
|
|
|
|
|
private static boolean inScope(
|
|
|
@@ -244,6 +304,15 @@ public class CockpitHistoryAnalysisService {
|
|
|
return industry == null || industry.equals(type.getIndustry());
|
|
|
}
|
|
|
|
|
|
+ /** 事件行的行业=其类型所属行业(未登记类型回 null,不猜)。 */
|
|
|
+ private static String industryOf(
|
|
|
+ Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
|
|
|
+ String typeCode) {
|
|
|
+ CockpitHistoryAnalysisSnapshot.SourceType type =
|
|
|
+ isBlank(typeCode) ? null : types.get(typeCode.trim());
|
|
|
+ return type == null ? null : type.getIndustry();
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
|
|
|
* 按 {@code create_time} 界定窗口、按行业(经类型)收敛,与事件是否回联无关。
|
|
|
@@ -391,6 +460,12 @@ public class CockpitHistoryAnalysisService {
|
|
|
return resolved;
|
|
|
}
|
|
|
|
|
|
+ private static void validatePagination(int page, int pageSize) {
|
|
|
+ if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
|
|
|
+ throw new CockpitInvalidRequestException("历史分析分页参数不合法");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private TimeWindow resolveWindow(String startTime, String endTime) {
|
|
|
boolean hasStart = !isBlank(startTime);
|
|
|
boolean hasEnd = !isBlank(endTime);
|
|
|
@@ -456,6 +531,109 @@ public class CockpitHistoryAnalysisService {
|
|
|
return value == null || value.trim().isEmpty();
|
|
|
}
|
|
|
|
|
|
+ /** 逐事件行(领域内形状):聚合与逐事件清单共用一份事实。 */
|
|
|
+ private static class EventRow {
|
|
|
+ private final String eventNo;
|
|
|
+ private final String eventName;
|
|
|
+ private final String eventType;
|
|
|
+ private final Integer level;
|
|
|
+ private final String status;
|
|
|
+ private final OffsetDateTime eventTime;
|
|
|
+ private final String updatedTime;
|
|
|
+ private final BigDecimal value;
|
|
|
+ private final String unit;
|
|
|
+ private final String deviceCode;
|
|
|
+ private final String deviceName;
|
|
|
+ private final String location;
|
|
|
+ private final String industry;
|
|
|
+ private final BigDecimal minValue;
|
|
|
+ private final BigDecimal maxValue;
|
|
|
+
|
|
|
+ private EventRow(
|
|
|
+ String eventNo,
|
|
|
+ String eventName,
|
|
|
+ String eventType,
|
|
|
+ Integer level,
|
|
|
+ String status,
|
|
|
+ OffsetDateTime eventTime,
|
|
|
+ String updatedTime,
|
|
|
+ BigDecimal value,
|
|
|
+ String unit,
|
|
|
+ String deviceCode,
|
|
|
+ String deviceName,
|
|
|
+ String location,
|
|
|
+ String industry,
|
|
|
+ BigDecimal minValue,
|
|
|
+ BigDecimal maxValue) {
|
|
|
+ this.eventNo = eventNo;
|
|
|
+ this.eventName = eventName;
|
|
|
+ this.eventType = eventType;
|
|
|
+ this.level = level;
|
|
|
+ this.status = status;
|
|
|
+ this.eventTime = eventTime;
|
|
|
+ this.updatedTime = updatedTime;
|
|
|
+ this.value = value;
|
|
|
+ this.unit = unit;
|
|
|
+ this.deviceCode = deviceCode;
|
|
|
+ this.deviceName = deviceName;
|
|
|
+ this.location = location;
|
|
|
+ this.industry = industry;
|
|
|
+ this.minValue = minValue;
|
|
|
+ this.maxValue = maxValue;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getEventNo() {
|
|
|
+ return eventNo;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 逐事件行 → 与 `/events/current` 同构的 EventItem(缺的字段留 null,不编造)。 */
|
|
|
+ private CockpitHistoryAnalysisResponse.EventItem toItem() {
|
|
|
+ return new CockpitHistoryAnalysisResponse.EventItem(
|
|
|
+ eventNo,
|
|
|
+ isBlank(eventName) ? eventNo : eventName,
|
|
|
+ eventType,
|
|
|
+ level,
|
|
|
+ CockpitEventLevelColor.resolve(level),
|
|
|
+ status,
|
|
|
+ timestamp(eventTime),
|
|
|
+ updatedTime,
|
|
|
+ value,
|
|
|
+ unit,
|
|
|
+ deviceCode,
|
|
|
+ deviceName,
|
|
|
+ // 历史快照不携带监测点与坐标(逐事件坐标只在 events/current 提供):
|
|
|
+ // 保留字段为 null,前端按既有缺值守卫处理。
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ location,
|
|
|
+ industry,
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ minValue,
|
|
|
+ maxValue,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 逐事件排序:eventTime 倒序(缺时间排最后),同时间按 eventNo 升序(与当前事件端点同一口径)。 */
|
|
|
+ private static Comparator<EventRow> eventOrdering() {
|
|
|
+ Comparator<EventRow> byTime = (left, right) -> {
|
|
|
+ if (left.eventTime == null && right.eventTime == null) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ if (left.eventTime == null) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (right.eventTime == null) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ return right.eventTime.compareTo(left.eventTime);
|
|
|
+ };
|
|
|
+ return byTime.thenComparing(
|
|
|
+ EventRow::getEventNo, Comparator.nullsLast(String::compareTo));
|
|
|
+ }
|
|
|
+
|
|
|
private static class TimeWindow {
|
|
|
private final OffsetDateTime start;
|
|
|
private final OffsetDateTime end;
|
|
|
@@ -466,18 +644,6 @@ public class CockpitHistoryAnalysisService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private static class EventFact {
|
|
|
- private final String eventNo;
|
|
|
- private final OffsetDateTime eventTime;
|
|
|
- private final String deviceCode;
|
|
|
-
|
|
|
- private EventFact(String eventNo, OffsetDateTime eventTime, String deviceCode) {
|
|
|
- this.eventNo = eventNo;
|
|
|
- this.eventTime = eventTime;
|
|
|
- this.deviceCode = deviceCode;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
private static class Bucket {
|
|
|
private final Set<String> deviceCodes = new LinkedHashSet<>();
|
|
|
private int eventCount;
|