fix: preserve SCADA comparison device coverage

This commit is contained in:
2026-11-12 10:38:55 +08:00
parent a617aa95e9
commit 33f0b90a7d
6 changed files with 129 additions and 6 deletions
@@ -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<number | null>; sample_times?: Array<string | null> }>;
};
@@ -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()
@@ -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) => {
@@ -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 ? <p role="status" aria-label="监测数据缺口" className="shrink-0 text-xs text-muted-foreground">
{[missingCurrent ? `当前缺测 ${missingCurrent} 点` : "", missingHistory ? `历史缺测 ${missingHistory} 点` : "",
gaps.some((gap) => gap.reason.startsWith("outside_frozen_")) ? "部分数据不在冻结范围内" : "",
gaps.some((gap) => gap.reason === "no_scheduled_samples") ? "窗口内无计划采样点" : ""].filter(Boolean).join(";")},已保留空槽。
</p> : null;
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
@@ -51,6 +59,7 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
</div>
</aside>;
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
{gapNotice}
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
@@ -62,6 +71,7 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
{multiple ? objectButtons(true) : null}
</div>
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
{gapNotice}
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
@@ -21,6 +21,32 @@ const legacyComparison = (id: string, metric = "压力", unit = "m", date = "202
};
describe("investigation comparison groups", () => {
it("groups all four bound devices even when titles differ and one device has no observations", () => {
const ids = ["486033", "542017", "541061", "524529"];
const charts = ids.map((id, index) => {
const command = comparison(id, "流量", "m3/h");
command.chart.title = `不同的标题 ${index}`;
command.chart.scada = { role: "comparison", metric: "flow", unit: "m3/h",
window_start: "2026-07-16T05:00:00+08:00", window_end: "2026-07-16T08:00:00+08:00" };
command.chart.series.forEach((series, offset) => {
series.device_id = id;
series.day_offset = offset;
series.name = offset ? "历史" : "当前";
if (id === "524529") { series.data = [null, null]; series.sample_times = [null, null]; }
});
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(ids);
expect(groups[0].id).toBe(groupInvestigationCharts([charts[3]])[0].id);
expect(groups[0].items[3].command).toBe(charts[3]);
charts[3].chart.scada!.unit = "m³/h";
charts[3].chart.scada!.window_start = "2026-07-15T21:00:00Z";
charts[3].chart.scada!.window_end = "2026-07-16T00:00:00Z";
expect(groupInvestigationCharts(charts)).toHaveLength(1);
});
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
const groups = groupInvestigationCharts(charts);
@@ -44,10 +44,19 @@ export function groupInvestigationCharts(charts: InvestigationChart[]): Investig
const groups = new Map<string, InvestigationChartGroup>();
for (const command of charts) {
const { chart } = command;
const scada = chart.scada;
const boundDevices = new Set(chart.series.map((series) => series.device_id));
const typed = scada?.role === "comparison" && boundDevices.size === 1 &&
!boundDevices.has(undefined) && chart.series.every((series) => series.day_offset !== undefined);
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
const id = comparable ? JSON.stringify([
const object = typed ? {
id: chart.series[0].device_id!,
title: `${scada.metric === "pressure" ? "压力" : "流量"}当前曲线与前七天同期对比(${scada.unit})`
} : match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && (typed || chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id));
const id = typed ? JSON.stringify(["scada-comparison", scada.metric, scada.unit.replace("³", "3").replace(/\s/g, "").toLowerCase(),
Date.parse(scada.window_start), Date.parse(scada.window_end), scada.value_field ?? "monitored_value", chart.x_data
]) : comparable ? JSON.stringify([
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
chart.series.filter((series) => series.type !== "scatter").map((series) => [
series.name.slice(object.id.length).trim(), series.sample_times ?? null