feat: standardize algorithm measurement results
This commit is contained in:
@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import * as db from './db.js';
|
||||
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
|
||||
import {
|
||||
inspectionObjectContextFromName,
|
||||
logCompatibilityWarnings,
|
||||
normalizeMeasurements,
|
||||
type InspectionObjectContext,
|
||||
} from './measurementCompatibility.js';
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginProgressEvent,
|
||||
@@ -111,7 +117,12 @@ async function gatherInputs(
|
||||
}
|
||||
}
|
||||
|
||||
return { task, pointCloudRecords, pointCloudPaths, cadPath };
|
||||
const inspectionObject = task.inspectionObjectId
|
||||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||||
: undefined;
|
||||
const inspectionContext = inspectionObjectContextFromName(inspectionObject?.name);
|
||||
|
||||
return { task, pointCloudRecords, pointCloudPaths, cadPath, inspectionContext };
|
||||
}
|
||||
|
||||
async function writeInputJson(
|
||||
@@ -124,6 +135,7 @@ async function writeInputJson(
|
||||
cadPath: string | null,
|
||||
inputPointCloudFileIds: number[],
|
||||
inputCadFileIds: number[],
|
||||
inspectionContext: InspectionObjectContext,
|
||||
) {
|
||||
const inputJson = {
|
||||
schemaVersion: 1,
|
||||
@@ -133,6 +145,7 @@ async function writeInputJson(
|
||||
taskId,
|
||||
workDir,
|
||||
outDir: workDir,
|
||||
context: inspectionContext,
|
||||
inputs: {
|
||||
pointCloudPaths,
|
||||
pointCloudFileIds: inputPointCloudFileIds,
|
||||
@@ -330,7 +343,7 @@ export async function runAlgorithmJob(
|
||||
|
||||
try {
|
||||
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
||||
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
|
||||
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
|
||||
taskId,
|
||||
manifest,
|
||||
pointCloudFileIds,
|
||||
@@ -349,6 +362,7 @@ export async function runAlgorithmJob(
|
||||
cadPath,
|
||||
inputPointCloudFileIds,
|
||||
inputCadFileIds,
|
||||
inspectionContext,
|
||||
);
|
||||
|
||||
let exitCode: number | null = 0;
|
||||
@@ -367,6 +381,17 @@ export async function runAlgorithmJob(
|
||||
: 'error';
|
||||
|
||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||
const normalized = normalizeMeasurements({
|
||||
pluginId,
|
||||
measurements: result?.measurements,
|
||||
extra: result?.extra,
|
||||
context: inspectionContext,
|
||||
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||
});
|
||||
logCompatibilityWarnings(resultId, normalized.warnings);
|
||||
const normalizedMeasurements = Array.isArray(result?.measurements)
|
||||
? normalized.measurements
|
||||
: undefined;
|
||||
const resultData: PluginResultData = {
|
||||
kind: 'plugin',
|
||||
pluginId,
|
||||
@@ -378,12 +403,12 @@ export async function runAlgorithmJob(
|
||||
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
||||
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||
message: result?.message,
|
||||
measurements: result?.measurements,
|
||||
measurements: normalizedMeasurements,
|
||||
errorStats: result?.errorStats,
|
||||
overlay3d: result?.overlay3d,
|
||||
images: result?.images,
|
||||
files: result?.files,
|
||||
extra: result?.extra,
|
||||
extra: normalized.extra,
|
||||
error: finalStatus === 'error'
|
||||
? result?.message ?? `子进程退出码 ${exitCode}`
|
||||
: undefined,
|
||||
@@ -393,7 +418,7 @@ export async function runAlgorithmJob(
|
||||
await db.updateTaskAnalysisResult(resultId, {
|
||||
status: finalStatus,
|
||||
errorStats: result?.errorStats ?? null,
|
||||
measurements: result?.measurements ?? null,
|
||||
measurements: normalizedMeasurements ?? null,
|
||||
resultData,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import { samplePointcloudFiles } from './pointcloudSample.js';
|
||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||
import { authenticateRequest } from './_core/auth.js';
|
||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||
import {
|
||||
inspectionObjectContextFromName,
|
||||
logCompatibilityWarnings,
|
||||
normalizeMeasurements,
|
||||
} from './measurementCompatibility.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -562,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req,
|
||||
try {
|
||||
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
|
||||
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
|
||||
const task = await db.getInspectionTaskById(taskId);
|
||||
const inspectionObject = task?.inspectionObjectId
|
||||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||||
: undefined;
|
||||
const normalized = normalizeMeasurements({
|
||||
pluginId: row.pluginId ?? (typeof existingRd.pluginId === 'string' ? existingRd.pluginId : undefined),
|
||||
measurements: result.measurements ?? existingRd.measurements,
|
||||
extra: result.extra ?? existingRd.extra,
|
||||
context: inspectionObjectContextFromName(inspectionObject?.name),
|
||||
algorithmProtocolVersion: result.schemaVersion ?? row.schemaVersion,
|
||||
});
|
||||
logCompatibilityWarnings(resultId, normalized.warnings);
|
||||
const mergedRd = {
|
||||
...existingRd,
|
||||
message: result.message ?? existingRd.message,
|
||||
measurements: result.measurements ?? existingRd.measurements,
|
||||
measurements: normalized.measurements,
|
||||
errorStats: result.errorStats ?? existingRd.errorStats,
|
||||
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
||||
images: result.images ?? existingRd.images,
|
||||
files: result.files ?? existingRd.files,
|
||||
extra: result.extra ?? existingRd.extra,
|
||||
extra: normalized.extra,
|
||||
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
|
||||
};
|
||||
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
|
||||
await db.updateTaskAnalysisResult(resultId, {
|
||||
measurements: normalized.measurements,
|
||||
resultData: mergedRd,
|
||||
});
|
||||
res.json({ success: true, overlay3d: result.overlay3d });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e) });
|
||||
|
||||
134
server/measurementCompatibility.test.ts
Normal file
134
server/measurementCompatibility.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
inspectionObjectContextFromName,
|
||||
normalizeMeasurements,
|
||||
sortNormalizedMeasurements,
|
||||
} from './measurementCompatibility.js';
|
||||
|
||||
const valueFields = {
|
||||
value: 1.25,
|
||||
unit: 'mm',
|
||||
tolerance: 0.8,
|
||||
toleranceLabel: '+0.8/-0.2',
|
||||
baseline: 375,
|
||||
status: 'pass' as const,
|
||||
passLabel: '通过',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MEASUREMENT_COMPAT_ENABLED;
|
||||
});
|
||||
|
||||
describe('normalizeMeasurements', () => {
|
||||
it.each([
|
||||
['任务46_车体横断面对角线差值', '46', '车体横断面对角线差值'],
|
||||
['任务11 · 车钩面板的平整度', '11', '车钩面板的平整度'],
|
||||
])('拆分通用旧任务名 %s', (originalName, taskNo, name) => {
|
||||
const output = normalizeMeasurements({
|
||||
pluginId: 'train-measurement',
|
||||
measurements: [{ name: originalName, ...valueFields }],
|
||||
});
|
||||
expect(output.measurements[0]).toMatchObject({
|
||||
taskNo,
|
||||
metricCode: `legacy.train_measurement.task_${taskNo}`,
|
||||
name,
|
||||
legacyOriginalName: originalName,
|
||||
...valueFields,
|
||||
});
|
||||
});
|
||||
|
||||
it('新协议结果原样保留且重复调用一致', () => {
|
||||
const measurement = { taskNo: '8', metricCode: 'coupler.reference-offset', name: '参考面右偏转', ...valueFields, customConclusion: '合格' };
|
||||
const first = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [measurement], extra: { marker: true } });
|
||||
const second = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: first.measurements, extra: first.extra });
|
||||
expect(first.measurements).toEqual([measurement]);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('任务 5 保留左右梁两行和位置顺序', () => {
|
||||
const output = normalizeMeasurements({
|
||||
pluginId: 'underframe-deflection',
|
||||
measurements: [
|
||||
{ name: '右梁 · 纵向梁到直线的垂直偏差(挠度)', value: 2 },
|
||||
{ name: '左梁 · 纵向梁到直线的垂直偏差(挠度)', value: 1 },
|
||||
],
|
||||
});
|
||||
expect(output.measurements).toMatchObject([
|
||||
{ taskNo: '5', name: '挠度', position: '左梁', value: 1 },
|
||||
{ taskNo: '5', name: '挠度', position: '右梁', value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['头车', '8', '10'],
|
||||
['中车', '9', '12'],
|
||||
['中间车', '9', '12'],
|
||||
])('%s 车钩精确映射为正确任务号', (objectName, referenceTaskNo, longitudinalTaskNo) => {
|
||||
const output = normalizeMeasurements({
|
||||
pluginId: 'coupler-panel',
|
||||
context: inspectionObjectContextFromName(objectName),
|
||||
measurements: [
|
||||
{ name: '车钩面板距纵向中心的右偏转角', value: 2 },
|
||||
{ name: '车钩面板距参考面的右偏转角', value: 1 },
|
||||
],
|
||||
});
|
||||
expect(output.measurements).toMatchObject([
|
||||
{ taskNo: referenceTaskNo, name: '参考面右偏转', value: 1 },
|
||||
{ taskNo: longitudinalTaskNo, name: '纵向中心右偏转', value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('未知检测对象不默认成头车', () => {
|
||||
const output = normalizeMeasurements({
|
||||
pluginId: 'coupler-panel',
|
||||
context: inspectionObjectContextFromName('拖车'),
|
||||
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }],
|
||||
});
|
||||
expect(output.measurements[0]).toMatchObject({ legacyUnmapped: true });
|
||||
expect(output.measurements[0].taskNo).toBeUndefined();
|
||||
expect(output.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('车钩中间量移入 extra.intermediateMetrics 并保留字段', () => {
|
||||
const intermediate = ['最大 x 偏差 dx_y', '最大 x 偏差 dx_z', '面板 y 向最大偏差 D_y', '面板 z 向最大偏差 D_z']
|
||||
.map((name, index) => ({ name, value: index, unit: 'mm', status: 'info' }));
|
||||
const output = normalizeMeasurements({
|
||||
pluginId: 'coupler-panel',
|
||||
context: inspectionObjectContextFromName('头车'),
|
||||
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }, ...intermediate],
|
||||
extra: { marker: 'kept' },
|
||||
});
|
||||
expect(output.measurements).toHaveLength(1);
|
||||
expect(output.extra).toEqual({ marker: 'kept', intermediateMetrics: intermediate });
|
||||
});
|
||||
|
||||
it('未知名称不模糊猜测并生成稳定编码', () => {
|
||||
const source = { name: '类似车钩面板距参考面的右偏转角但不是', ...valueFields, customConclusion: '人工通过' };
|
||||
const output = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [source] });
|
||||
expect(output.measurements[0]).toMatchObject({
|
||||
...source,
|
||||
legacyUnmapped: true,
|
||||
legacyOriginalName: source.name,
|
||||
});
|
||||
expect(output.measurements[0].metricCode).toMatch(/^legacy\.coupler_panel\.unmapped\.[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('按数字任务号排序且未映射项最后', () => {
|
||||
const sorted = sortNormalizedMeasurements([
|
||||
{ taskNo: '46', name: '46' },
|
||||
{ name: 'unknown' },
|
||||
{ taskNo: '5', position: '右梁', name: '5R' },
|
||||
{ taskNo: '11', name: '11' },
|
||||
{ taskNo: '5', position: '左梁', name: '5L' },
|
||||
]);
|
||||
expect(sorted.map((item) => item.name)).toEqual(['5L', '5R', '11', '46', 'unknown']);
|
||||
});
|
||||
|
||||
it('环境开关关闭时原样返回', () => {
|
||||
process.env.MEASUREMENT_COMPAT_ENABLED = '0';
|
||||
const measurements = [{ name: '任务46_原始', value: 1 }];
|
||||
const output = normalizeMeasurements({ pluginId: 'train-measurement', measurements, extra: { raw: true } });
|
||||
expect(output).toEqual({ measurements, extra: { raw: true }, warnings: [], converted: false });
|
||||
});
|
||||
});
|
||||
261
server/measurementCompatibility.ts
Normal file
261
server/measurementCompatibility.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { PluginMeasurement } from './plugins/types.js';
|
||||
|
||||
export type InspectionObjectKind = 'head' | 'middle' | 'unknown';
|
||||
|
||||
export interface InspectionObjectContext {
|
||||
inspectionObject: InspectionObjectKind;
|
||||
inspectionObjectName?: string;
|
||||
}
|
||||
|
||||
export interface CompatibilityWarning {
|
||||
code: 'legacy-unmapped';
|
||||
pluginId: string;
|
||||
originalName: string;
|
||||
inspectionObject: InspectionObjectKind;
|
||||
inspectionObjectName?: string;
|
||||
}
|
||||
|
||||
export interface NormalizeMeasurementsInput {
|
||||
pluginId?: string | null;
|
||||
measurements?: unknown;
|
||||
extra?: unknown;
|
||||
context?: InspectionObjectContext;
|
||||
algorithmProtocolVersion?: string | number | null;
|
||||
}
|
||||
|
||||
export interface NormalizeMeasurementsOutput {
|
||||
measurements: PluginMeasurement[];
|
||||
extra: Record<string, unknown>;
|
||||
warnings: CompatibilityWarning[];
|
||||
converted: boolean;
|
||||
}
|
||||
|
||||
const COUPLER_INTERMEDIATE_NAMES = new Set([
|
||||
'最大 x 偏差 dx_y',
|
||||
'最大 x 偏差 dx_z',
|
||||
'面板 y 向最大偏差 D_y',
|
||||
'面板 z 向最大偏差 D_z',
|
||||
]);
|
||||
|
||||
const DEFLECTION_NAMES = new Map<string, string>([
|
||||
['左梁 · 纵向梁到直线的垂直偏差(挠度)', '左梁'],
|
||||
['右梁 · 纵向梁到直线的垂直偏差(挠度)', '右梁'],
|
||||
['挠度左梁', '左梁'],
|
||||
['挠度右梁', '右梁'],
|
||||
]);
|
||||
|
||||
const COUPLER_NAMES = {
|
||||
longitudinal: new Set([
|
||||
'车钩面板距纵向中心的右偏转角',
|
||||
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
|
||||
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
|
||||
]),
|
||||
reference: new Set([
|
||||
'车钩面板距参考面的右偏转角',
|
||||
'车钩面板距参考面的右偏转角(头车一位端处车钩)',
|
||||
]),
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? { ...(value as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
|
||||
function pluginKey(pluginId: string): string {
|
||||
return (pluginId || 'unknown').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'unknown';
|
||||
}
|
||||
|
||||
function stableNameHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function legacyMetricCode(pluginId: string, taskNo: string): string {
|
||||
return `legacy.${pluginKey(pluginId)}.task_${taskNo}`;
|
||||
}
|
||||
|
||||
function unmappedMetricCode(pluginId: string, name: string): string {
|
||||
return `legacy.${pluginKey(pluginId)}.unmapped.${stableNameHash(name)}`;
|
||||
}
|
||||
|
||||
export function inspectionObjectContextFromName(name?: string | null): InspectionObjectContext {
|
||||
const normalized = name?.trim() ?? '';
|
||||
return {
|
||||
inspectionObject: normalized === '头车'
|
||||
? 'head'
|
||||
: normalized === '中车' || normalized === '中间车'
|
||||
? 'middle'
|
||||
: 'unknown',
|
||||
inspectionObjectName: normalized || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function measurementCompatibilityEnabled(): boolean {
|
||||
return process.env.MEASUREMENT_COMPAT_ENABLED !== '0';
|
||||
}
|
||||
|
||||
function positionOrder(position: unknown): number {
|
||||
if (position === '左梁') return 0;
|
||||
if (position === '右梁') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
export function sortNormalizedMeasurements<T extends { taskNo?: unknown; position?: unknown }>(items: readonly T[]): T[] {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aNo = typeof a.item.taskNo === 'string' && /^\d+$/.test(a.item.taskNo) ? Number(a.item.taskNo) : Number.POSITIVE_INFINITY;
|
||||
const bNo = typeof b.item.taskNo === 'string' && /^\d+$/.test(b.item.taskNo) ? Number(b.item.taskNo) : Number.POSITIVE_INFINITY;
|
||||
if (aNo !== bNo) return aNo - bNo;
|
||||
const positionDelta = positionOrder(a.item.position) - positionOrder(b.item.position);
|
||||
return positionDelta || a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
export function normalizeMeasurements(input: NormalizeMeasurementsInput): NormalizeMeasurementsOutput {
|
||||
const pluginId = input.pluginId ?? '';
|
||||
const context = input.context ?? { inspectionObject: 'unknown' as const };
|
||||
const sourceMeasurements = Array.isArray(input.measurements)
|
||||
? input.measurements.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
|
||||
: [];
|
||||
const sourceExtra = asRecord(input.extra);
|
||||
|
||||
if (!measurementCompatibilityEnabled()) {
|
||||
return {
|
||||
measurements: sourceMeasurements as unknown as PluginMeasurement[],
|
||||
extra: sourceExtra,
|
||||
warnings: [],
|
||||
converted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const measurements: PluginMeasurement[] = [];
|
||||
const intermediateMetrics = Array.isArray(sourceExtra.intermediateMetrics)
|
||||
? [...sourceExtra.intermediateMetrics]
|
||||
: [];
|
||||
const warnings: CompatibilityWarning[] = [];
|
||||
let converted = false;
|
||||
|
||||
sourceMeasurements.forEach((source) => {
|
||||
const item = { ...source };
|
||||
const originalName = typeof item.name === 'string' ? item.name : '';
|
||||
|
||||
// 新协议项必须同时具备 taskNo 和 metricCode;重复规范化时保持原样。
|
||||
if (typeof item.taskNo === 'string' && item.taskNo && typeof item.metricCode === 'string' && item.metricCode) {
|
||||
measurements.push(item as unknown as PluginMeasurement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginId === 'coupler-panel' && COUPLER_INTERMEDIATE_NAMES.has(originalName)) {
|
||||
intermediateMetrics.push(item);
|
||||
converted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const generic = /^任务(\d+)(?:_|\s*·\s*)(.+)$/.exec(originalName);
|
||||
if (generic) {
|
||||
const [, taskNo, name] = generic;
|
||||
measurements.push({
|
||||
...item,
|
||||
taskNo,
|
||||
metricCode: legacyMetricCode(pluginId, taskNo),
|
||||
name: name.trim(),
|
||||
legacyOriginalName: originalName,
|
||||
} as unknown as PluginMeasurement);
|
||||
converted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginId === 'underframe-deflection') {
|
||||
const position = DEFLECTION_NAMES.get(originalName);
|
||||
if (position) {
|
||||
measurements.push({
|
||||
...item,
|
||||
taskNo: '5',
|
||||
metricCode: legacyMetricCode(pluginId, '5'),
|
||||
name: '挠度',
|
||||
position,
|
||||
legacyOriginalName: originalName,
|
||||
} as unknown as PluginMeasurement);
|
||||
converted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginId === 'coupler-panel') {
|
||||
const isLongitudinal = COUPLER_NAMES.longitudinal.has(originalName);
|
||||
const isReference = COUPLER_NAMES.reference.has(originalName);
|
||||
if (isLongitudinal || isReference) {
|
||||
const taskNo = context.inspectionObject === 'head'
|
||||
? (isLongitudinal ? '10' : '8')
|
||||
: context.inspectionObject === 'middle'
|
||||
? (isLongitudinal ? '12' : '9')
|
||||
: undefined;
|
||||
if (taskNo) {
|
||||
measurements.push({
|
||||
...item,
|
||||
taskNo,
|
||||
metricCode: legacyMetricCode(pluginId, taskNo),
|
||||
name: isLongitudinal ? '纵向中心右偏转' : '参考面右偏转',
|
||||
legacyOriginalName: originalName,
|
||||
} as unknown as PluginMeasurement);
|
||||
converted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unmapped = {
|
||||
...item,
|
||||
metricCode: unmappedMetricCode(pluginId, originalName),
|
||||
legacyUnmapped: true,
|
||||
legacyOriginalName: originalName,
|
||||
} as unknown as PluginMeasurement;
|
||||
measurements.push(unmapped);
|
||||
warnings.push({
|
||||
code: 'legacy-unmapped',
|
||||
pluginId,
|
||||
originalName,
|
||||
inspectionObject: context.inspectionObject,
|
||||
inspectionObjectName: context.inspectionObjectName,
|
||||
});
|
||||
converted = true;
|
||||
});
|
||||
|
||||
return {
|
||||
measurements: sortNormalizedMeasurements(measurements),
|
||||
extra: intermediateMetrics.length > 0 ? { ...sourceExtra, intermediateMetrics } : sourceExtra,
|
||||
warnings,
|
||||
converted,
|
||||
};
|
||||
}
|
||||
|
||||
export function logCompatibilityWarnings(resultId: number, warnings: readonly CompatibilityWarning[]): void {
|
||||
warnings.forEach((warning) => {
|
||||
const signature = [
|
||||
resultId,
|
||||
warning.pluginId,
|
||||
warning.originalName,
|
||||
warning.inspectionObject,
|
||||
warning.inspectionObjectName ?? '',
|
||||
].join('\u0000');
|
||||
if (loggedWarningSignatures.has(signature)) return;
|
||||
loggedWarningSignatures.add(signature);
|
||||
console.warn('[MeasurementCompatibility] legacy measurement unmapped', {
|
||||
resultId,
|
||||
pluginId: warning.pluginId,
|
||||
originalName: warning.originalName,
|
||||
inspectionObject: warning.inspectionObject,
|
||||
inspectionObjectName: warning.inspectionObjectName,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 避免历史结果轮询每次重复打印同一条告警;进程重启后会重新记录一次。 */
|
||||
const loggedWarningSignatures = new Set<string>();
|
||||
@@ -105,18 +105,30 @@ export interface PluginProgressEvent {
|
||||
|
||||
/** 测量项(GenericResultView 渲染) */
|
||||
export interface PluginMeasurement {
|
||||
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
|
||||
taskNo?: string;
|
||||
/** 跨版本稳定的测量项编码。 */
|
||||
metricCode?: string;
|
||||
name: string;
|
||||
/** 同任务内的位置,例如左梁、右梁。 */
|
||||
position?: string;
|
||||
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
||||
value: number | null;
|
||||
unit?: string;
|
||||
tolerance?: number;
|
||||
/** 无法安全数值化的原始公差表达式。 */
|
||||
toleranceLabel?: string;
|
||||
/** 斜率类测量的基准长度(mm)。 */
|
||||
baseline?: number;
|
||||
/** pass / warning / fail / info */
|
||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||
passLabel?: string;
|
||||
warningLabel?: string;
|
||||
failLabel?: string;
|
||||
/** 兼容层无法确定任务号时置为 true。 */
|
||||
legacyUnmapped?: boolean;
|
||||
/** 兼容转换前的原始项目名,用于追溯。 */
|
||||
legacyOriginalName?: string;
|
||||
}
|
||||
|
||||
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
||||
|
||||
@@ -16,6 +16,11 @@ import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||
import {
|
||||
inspectionObjectContextFromName,
|
||||
logCompatibilityWarnings,
|
||||
normalizeMeasurements,
|
||||
} from "./measurementCompatibility.js";
|
||||
import {
|
||||
clearLoginFailures,
|
||||
createSessionToken,
|
||||
@@ -963,7 +968,43 @@ export const appRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ taskId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.listTaskAnalysisResults(input.taskId);
|
||||
const [rows, task] = await Promise.all([
|
||||
db.listTaskAnalysisResults(input.taskId),
|
||||
db.getInspectionTaskById(input.taskId),
|
||||
]);
|
||||
const inspectionObject = task?.inspectionObjectId
|
||||
? await db.getInspectionObjectById(task.inspectionObjectId)
|
||||
: undefined;
|
||||
const context = inspectionObjectContextFromName(inspectionObject?.name);
|
||||
|
||||
return rows.map((row) => {
|
||||
const resultData = row.resultData && typeof row.resultData === 'object' && !Array.isArray(row.resultData)
|
||||
? { ...(row.resultData as Record<string, unknown>) }
|
||||
: {};
|
||||
const pluginId = row.pluginId ?? (typeof resultData.pluginId === 'string' ? resultData.pluginId : undefined);
|
||||
const rawMeasurements = Array.isArray(row.measurements)
|
||||
? row.measurements
|
||||
: resultData.measurements;
|
||||
if (!pluginId || !Array.isArray(rawMeasurements)) return row;
|
||||
|
||||
const normalized = normalizeMeasurements({
|
||||
pluginId,
|
||||
measurements: rawMeasurements,
|
||||
extra: resultData.extra,
|
||||
context,
|
||||
algorithmProtocolVersion: row.schemaVersion ?? (resultData.schemaVersion as string | number | undefined),
|
||||
});
|
||||
logCompatibilityWarnings(row.id, normalized.warnings);
|
||||
return {
|
||||
...row,
|
||||
measurements: normalized.measurements,
|
||||
resultData: {
|
||||
...resultData,
|
||||
measurements: normalized.measurements,
|
||||
extra: normalized.extra,
|
||||
},
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
runAnalysis: protectedProcedure
|
||||
|
||||
Reference in New Issue
Block a user