From 33f0b90a7da284b0405ff37a1b2735e0daeee4bb Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 12 Nov 2026 10:38:55 +0800 Subject: [PATCH] fix: preserve SCADA comparison device coverage --- .../agent/presentation/command-schema.ts | 27 +++++++++- .../workbench/api/investigation-report.ts | 3 +- .../components/investigation-chart-group.tsx | 10 ++++ .../utils/investigation-chart-groups.test.ts | 26 +++++++++ .../utils/investigation-chart-groups.ts | 15 ++++-- .../scada-presentation-coverage.e2e.ts | 54 +++++++++++++++++++ 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 tests/browser/scada-presentation-coverage.e2e.ts diff --git a/src/features/agent/presentation/command-schema.ts b/src/features/agent/presentation/command-schema.ts index b6ce31d..20ab74e 100644 --- a/src/features/agent/presentation/command-schema.ts +++ b/src/features/agent/presentation/command-schema.ts @@ -9,6 +9,7 @@ export function createChartSampleTimeSchema(zod: typeof z = z) { type PublicationChart = { chart_type: string; + scada?: { role: "current" | "comparison"; window_start: string }; x_data: string[]; series: Array<{ type?: string; data: Array; sample_times?: Array }>; }; @@ -56,8 +57,12 @@ export function chartPublicationError(chart: PublicationChart): string | null { if ((series.type ?? "line") === "line") for (const base of bases) current.add(base); else historical.push(...bases); } - if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口"; - const start = [...current][0]; + const emptyScada = current.size === 0 && chart.scada?.role === "comparison"; + if (current.size !== 1 && !emptyScada) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口"; + const windowStart = emptyScada ? Date.parse(chart.scada!.window_start) + 8 * 3600_000 : 0; + const midnight = Math.floor(windowStart / 86400_000) * 86400_000; + const firstSlot = midnight + seconds[0] * 1000; + const start = emptyScada ? firstSlot + (firstSlot < windowStart ? 86400_000 : 0) : [...current][0]; return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0) ? "历史 scatter 必须来自当前窗口之前的对应日期" : null; } @@ -67,9 +72,25 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida const z = zod; const presentationIdentifierSchema = z.string().trim().min(1).max(128); + const scadaChartSchema = z.object({ + role: z.enum(["current", "comparison"]), + metric: z.enum(["pressure", "flow"]), + unit: presentationIdentifierSchema, + window_start: createChartSampleTimeSchema(z), + window_end: createChartSampleTimeSchema(z), + value_field: z.enum(["monitored_value", "cleaned_value"]).optional(), + // Populated by Server after checking observations, never a display exemption. + gaps: z.array(z.object({ + device_id: presentationIdentifierSchema, + day_offset: z.number().int().min(0).max(7), + missing_count: z.number().int().min(1).max(100), + reason: z.enum(["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"]) + }).strict()).max(8).optional() + }).strict(); const chartSchema = z .object({ title: z.string().max(200), + scada: scadaChartSchema.optional(), chart_type: z.enum(["line", "bar", "time_comparison"]), x_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(), @@ -79,6 +100,8 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida z .object({ name: z.string().max(128), + device_id: presentationIdentifierSchema.optional(), + day_offset: z.number().int().min(0).max(7).optional(), type: z.enum(["line", "bar", "scatter"]).optional(), data: z.array(z.number().finite().nullable()).min(1).max(100), sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional() diff --git a/src/features/workbench/api/investigation-report.ts b/src/features/workbench/api/investigation-report.ts index 60d2e49..d6caa61 100644 --- a/src/features/workbench/api/investigation-report.ts +++ b/src/features/workbench/api/investigation-report.ts @@ -85,7 +85,8 @@ export const investigationReportSchema = z stop_reason: text.nullish(), data_gaps: z.array(dataGapSchema).nullish(), diagram: investigationDiagramSchema.nullish(), - ui_blocks: reportUiBlocksSchema.optional() + ui_blocks: reportUiBlocksSchema.optional(), + scada_device_ids: z.array(text).max(1000).nullish() }) .strict() .superRefine((report, ctx) => { diff --git a/src/features/workbench/components/investigation-chart-group.tsx b/src/features/workbench/components/investigation-chart-group.tsx index 9d3939b..87c6063 100644 --- a/src/features/workbench/components/investigation-chart-group.tsx +++ b/src/features/workbench/components/investigation-chart-group.tsx @@ -34,6 +34,14 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0]; const multiple = group.items.length > 1; const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title); + const gaps = selected.command.chart.scada?.gaps ?? []; + const missingCurrent = gaps.filter((gap) => gap.day_offset === 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0); + const missingHistory = gaps.filter((gap) => gap.day_offset > 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0); + const gapNotice = gaps.length ?

+ {[missingCurrent ? `当前缺测 ${missingCurrent} 点` : "", missingHistory ? `历史缺测 ${missingHistory} 点` : "", + gaps.some((gap) => gap.reason.startsWith("outside_frozen_")) ? "部分数据不在冻结范围内" : "", + gaps.some((gap) => gap.reason === "no_scheduled_samples") ? "窗口内无计划采样点" : ""].filter(Boolean).join(";")},已保留空槽。 +

: null; const objectButtons = (compact = false) => ; const content = (expanded: boolean, compact = false) => compact ?
+ {gapNotice} {multiple ?