Add Linear XLSX follow-up import

This commit is contained in:
Julian Appel 2026-09-30 20:40:40 +02:00
parent 1a4c8cad8c
commit 2581beee3c
28 changed files with 1114 additions and 31 deletions

View file

@ -0,0 +1,84 @@
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js";
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export const externalFollowUpImportCommandType = "external-import.apply-follow-up" as const;
export const externalFollowUpImportCommandSchemaVersion = 1 as const;
export interface ExternalFollowUpImportPayload {
expectedConfiguration: ExternalModelConfigurationSnapshot;
targetConfiguration: ExternalModelConfigurationSnapshot;
expectedState: ExternalModelStateSnapshot;
targetState: ExternalModelStateSnapshot;
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
}
export interface ExternalFollowUpImportProjectCommand extends SerializedProjectCommand<ExternalFollowUpImportPayload> {
schemaVersion: typeof externalFollowUpImportCommandSchemaVersion;
type: typeof externalFollowUpImportCommandType;
}
export function createExternalFollowUpImportProjectCommand(payload: ExternalFollowUpImportPayload): ExternalFollowUpImportProjectCommand {
const command: ExternalFollowUpImportProjectCommand = { schemaVersion: 1, type: externalFollowUpImportCommandType, payload: { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target) })) } };
assertExternalFollowUpImportProjectCommand(command);
return command;
}
export function invertExternalFollowUpImportProjectCommand(command: ExternalFollowUpImportProjectCommand) {
return createExternalFollowUpImportProjectCommand({
expectedConfiguration: command.payload.targetConfiguration,
targetConfiguration: command.payload.expectedConfiguration,
expectedState: command.payload.targetState,
targetState: command.payload.expectedState,
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
});
}
export function assertExternalFollowUpImportProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalFollowUpImportProjectCommand {
if (command.schemaVersion !== 1 || command.type !== externalFollowUpImportCommandType || !isRecord(command.payload)) throw new Error("Nicht unterstützter Revit-Folgeimport-Command.");
if (Object.keys(command.payload).sort().join(",") !== "expectedConfiguration,expectedState,rows,targetConfiguration,targetState" || !Array.isArray(command.payload.rows)) throw new Error("Der Revit-Folgeimport-Command ist unvollständig.");
const expectedConfiguration = parseConfiguration(command.payload.expectedConfiguration);
const targetConfiguration = parseConfiguration(command.payload.targetConfiguration);
if (expectedConfiguration.id !== targetConfiguration.id || expectedConfiguration.projectId !== targetConfiguration.projectId) throw new Error("Der Folgeimport darf die Konfigurationsidentität nicht ändern.");
const expectedState = parseExternalModelStateSnapshot(command.payload.expectedState);
const targetState = parseExternalModelStateSnapshot(command.payload.targetState);
if (!expectedState.source || !targetState.source || expectedState.source.id !== targetState.source.id || expectedState.source.projectId !== targetState.source.projectId) throw new Error("Der Folgeimport benötigt dieselbe Revit-Quelle auf beiden Seiten.");
const batchDelta = targetState.importBatches.length - expectedState.importBatches.length;
if (Math.abs(batchDelta) !== 1) throw new Error("Der Folgeimport muss genau einen Importbatch hinzufügen oder entfernen.");
const larger = batchDelta > 0 ? targetState : expectedState;
const smaller = batchDelta > 0 ? expectedState : targetState;
const largerConfiguration = batchDelta > 0 ? targetConfiguration : expectedConfiguration;
const addedBatch = larger.importBatches.at(-1)!;
if (addedBatch.importKind !== "follow_up" || addedBatch.configurationSnapshot === null || addedBatch.originalContentBase64 === null || addedBatch.document === null) throw new Error("Der Folgeimport-Batch ist unvollständig.");
if (canonical(addedBatch.configurationSnapshot) !== canonical(largerConfiguration.configuration)) throw new Error("Die Konfigurationskopie des Folgeimports stimmt nicht mit dem Ziel überein.");
if (larger.importBatches.length !== smaller.importBatches.length + 1 || smaller.importBatches.some((batch, index) => !sameBatchMetadata(batch, larger.importBatches[index]))) throw new Error("Der Folgeimport darf bestehende Importbatch-Metadaten nicht verändern.");
const largerObjectById = new Map(larger.objects.map((object) => [object.id, object]));
for (const object of smaller.objects) {
const next = largerObjectById.get(object.id);
if (!next || next.worksharingId !== object.worksharingId || next.sourceId !== object.sourceId || next.circuitDeviceRowId !== object.circuitDeviceRowId || next.linkedProjectDeviceId !== object.linkedProjectDeviceId) throw new Error("Der Folgeimport darf bestehende Objektidentitäten oder Links nicht verändern.");
}
const smallerIds = new Set(smaller.objects.map((object) => object.id));
if (larger.objects.some((object) => !smallerIds.has(object.id) && (object.circuitDeviceRowId !== null || object.presenceStatus !== "present" || object.lastSeenImportBatchId !== addedBatch.id))) throw new Error("Neue Folgeimportobjekte müssen unverknüpft und vorhanden sein.");
if (larger.objects.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === addedBatch.id).length !== addedBatch.objectCount) throw new Error("Die Objektzahl des Folgeimportbatches ist inkonsistent.");
const largerMappings = new Map(larger.roomMappings.map((mapping) => [mapping.id, mapping]));
if (smaller.roomMappings.some((mapping) => canonical(mapping) !== canonical(largerMappings.get(mapping.id)))) throw new Error("Der Folgeimport darf bestehende Raumzuordnungen nicht verändern.");
const rowIds = new Set<string>();
for (const transition of command.payload.rows) {
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang im Folgeimport.");
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität im Folgeimport.");
const expectedRest = { ...expected, quantity: 0 }; const targetRest = { ...target, quantity: 0 };
if (canonical(expectedRest) !== canonical(targetRest) || expected.quantity === target.quantity) throw new Error("Der Folgeimport darf an Gerätezeilen nur die Gesamtmenge ändern.");
rowIds.add(expected.id);
}
}
function parseConfiguration(value: unknown): ExternalModelConfigurationSnapshot { if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") throw new Error("Ungültige Revit-Konfiguration im Folgeimport."); assertExternalModelConfiguration(value.configuration); return value as unknown as ExternalModelConfigurationSnapshot; }
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; }
function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
function sameBatchMetadata(left: ExternalModelStateSnapshot["importBatches"][number], right: ExternalModelStateSnapshot["importBatches"][number] | undefined) { if (!right) return false; return left.id === right.id && left.projectId === right.projectId && left.sourceId === right.sourceId && left.importKind === right.importKind && left.importedAtIso === right.importedAtIso && left.fileName === right.fileName && left.sha256 === right.sha256 && left.objectCount === right.objectCount; }

View file

@ -0,0 +1,54 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
export const externalObjectDeleteMissingCommandType = "external-model-object.delete-missing" as const;
type ObjectTransition = { expected: ExternalModelObjectSnapshot | null; target: ExternalModelObjectSnapshot | null };
export interface ExternalObjectDeleteMissingProjectCommand extends SerializedProjectCommand<{ objects: ObjectTransition[]; rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> }> {
schemaVersion: 1;
type: typeof externalObjectDeleteMissingCommandType;
}
export function createExternalObjectDeleteMissingProjectCommand(payload: ExternalObjectDeleteMissingProjectCommand["payload"]): ExternalObjectDeleteMissingProjectCommand {
const command: ExternalObjectDeleteMissingProjectCommand = { schemaVersion: 1, type: externalObjectDeleteMissingCommandType, payload };
assertExternalObjectDeleteMissingProjectCommand(command);
return command;
}
export function invertExternalObjectDeleteMissingProjectCommand(command: ExternalObjectDeleteMissingProjectCommand) {
return createExternalObjectDeleteMissingProjectCommand({
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
});
}
export function assertExternalObjectDeleteMissingProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectDeleteMissingProjectCommand {
if (command.schemaVersion !== 1 || command.type !== externalObjectDeleteMissingCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.rows) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Command zum Löschen fehlender Revit-Objekte.");
const objectIds = new Set<string>();
let deletion: boolean | null = null;
for (const transition of command.payload.objects) {
if (!isRecord(transition) || (transition.expected === null) === (transition.target === null)) throw new Error("Ungültiger Löschübergang für ein Revit-Objekt.");
const object = parseObject(transition.expected ?? transition.target);
if (object.presenceStatus !== "missing" || objectIds.has(object.id)) throw new Error("Nur eindeutig fehlende Revit-Objekte dürfen gelöscht werden.");
const nextDeletion = transition.target === null;
if (deletion !== null && deletion !== nextDeletion) throw new Error("Löschen und Wiederherstellen dürfen nicht gemischt werden.");
deletion = nextDeletion;
objectIds.add(object.id);
}
const affectedRowIds = new Set(command.payload.objects.flatMap(({ expected, target }) => (expected ?? target)!.circuitDeviceRowId ? [(expected ?? target)!.circuitDeviceRowId!] : []));
const rowIds = new Set<string>();
for (const transition of command.payload.rows) {
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang.");
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität.");
if (canonical({ ...expected, quantity: 0 }) !== canonical({ ...target, quantity: 0 }) || expected.quantity === target.quantity) throw new Error("Beim Löschen fehlender Objekte darf nur die Gesamtmenge geändert werden.");
rowIds.add(expected.id);
}
if (canonical([...affectedRowIds].sort()) !== canonical([...rowIds].sort())) throw new Error("Die Mengenübergänge der betroffenen Gerätezeilen sind unvollständig.");
}
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
function parseRow(value: unknown) { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }

View file

@ -0,0 +1,6 @@
import type { ExternalFollowUpImportProjectCommand } from "../models/external-follow-up-import-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExternalFollowUpImportProjectCommandStore {
execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalFollowUpImportProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalFollowUpImportProjectCommand };
}

View file

@ -0,0 +1,3 @@
import type { ExternalObjectDeleteMissingProjectCommand } from "../models/external-object-delete-missing-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExternalObjectDeleteMissingProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectDeleteMissingProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectDeleteMissingProjectCommand }; }

View file

@ -182,8 +182,12 @@ import {
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
import { assertExternalFollowUpImportProjectCommand, externalFollowUpImportCommandType } from "../models/external-follow-up-import-project-command.model.js";
import type { ExternalFollowUpImportProjectCommandStore } from "../ports/external-follow-up-import-project-command.store.js";
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
import { assertExternalObjectDeleteMissingProjectCommand, externalObjectDeleteMissingCommandType } from "../models/external-object-delete-missing-project-command.model.js";
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../ports/external-object-delete-missing-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
) {}
executeUser(
@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalFollowUpImportCommandType: {
assertExternalFollowUpImportProjectCommand(input.command);
if (!this.externalFollowUpImportStore) throw new Error("External follow-up import store is not available.");
return this.externalFollowUpImportStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectPlanningUpdateCommandType: {
assertExternalObjectPlanningUpdateProjectCommand(input.command);
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectDeleteMissingCommandType: {
assertExternalObjectDeleteMissingProjectCommand(input.command);
if (!this.externalObjectDeleteMissingStore) throw new Error("External missing-object delete store is not available.");
return this.externalObjectDeleteMissingStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) {