Normalize external model state comparisons

This commit is contained in:
Julian Appel 2026-09-30 21:19:16 +02:00
parent 4ff01b2ccd
commit 431f9ff059
5 changed files with 34 additions and 3 deletions

View file

@ -6,6 +6,7 @@ import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/por
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
@ -30,7 +31,7 @@ export class ExternalFollowUpImportProjectCommandRepository implements ExternalF
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId);
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get();
if (!currentState.projectExists || !currentConfiguration || !same(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
if (!currentState.projectExists || !currentConfiguration || !externalModelStatesEqual(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
const rowIds = input.command.payload.rows.map(({ expected }) => expected.id);
const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden.");

View file

@ -8,6 +8,7 @@ import {
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
@ -32,7 +33,7 @@ export class ExternalInitialImportProjectCommandRepository implements ExternalIn
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
const payload = input.command.payload;
if (!same(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
}
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
@ -40,7 +41,7 @@ export class ExternalInitialImportProjectCommandRepository implements ExternalIn
replaceState(tx, input.projectId, payload.targetState);
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
if (!same(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
});
}

View file

@ -0,0 +1,27 @@
import type { ExternalModelStateSnapshot } from "./external-model-contracts.js";
export function externalModelStatesEqual(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
return canonical(normalize(left)) === canonical(normalize(right));
}
function normalize(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
return {
...state,
importBatches: [...state.importBatches].sort((left, right) => compareNullableNumber(left.appliedProjectRevision, right.appliedProjectRevision) || compareText(left.id, right.id)),
roomMappings: [...state.roomMappings].sort((left, right) => compareText(left.normalizedSourceRoomKey, right.normalizedSourceRoomKey) || compareText(left.id, right.id)),
objects: [...state.objects].sort((left, right) => compareText(left.worksharingId, right.worksharingId) || compareText(left.id, right.id)),
};
}
function compareNullableNumber(left: number | null, right: number | null) {
if (left === right) return 0;
if (left === null) return -1;
if (right === null) return 1;
return left - right;
}
function compareText(left: string, right: string) { return left < right ? -1 : left > right ? 1 : 0; }
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; }
return JSON.stringify(value);
}

View file

@ -61,6 +61,7 @@ describe("external follow-up import project command", () => {
const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues });
const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
built.targetState.objects.reverse();
const repository = new ExternalFollowUpImportProjectCommandRepository(context.db);
const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] });
assert.throws(

View file

@ -57,6 +57,7 @@ describe("Linear XLSX initial import", () => {
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } });
let sequence = 0;
const target = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T10:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
target.objects.reverse();
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), target) });
assert.equal(inserted.revision.revisionNumber, 1);