leistungsbilanz-ts/src/db/repositories/external-follow-up-import-project-command.repository.ts

86 lines
10 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js";
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 type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalFollowUpImportProjectCommandStore["execute"]>[0]) {
assertExternalFollowUpImportProjectCommand(input.command);
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.");
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.");
const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows);
for (const transition of input.command.payload.rows) {
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert.");
}
replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration);
replaceState(tx, input.projectId, input.command.payload.targetState);
return invertExternalFollowUpImportProjectCommand(input.command);
});
}
}
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt.");
const growing = target.importBatches.length > expectedState.importBatches.length;
const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!;
if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig.");
if (growing) {
if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig.");
const bytes = Buffer.from(latest.originalContentBase64!, "base64");
if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports ist ungültig.");
}
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
const deviceTypeIds = new Set(configuration.deviceTypes.map((entry) => entry.id));
for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); }
for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); }
assertRows(database, projectId, target, rows);
}
function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))];
const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])];
if (!rowsToLoad.length) return;
const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all();
if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt.");
const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition]));
for (const context of contexts) {
const current = toCircuitDeviceRowSnapshot(context.row);
const effective = transitionById.get(current.id)?.target ?? current;
assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
}
}
function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); database.insert(externalModelConfigurations).values(target).run(); }
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run(); if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run(); if (target.objects.length) database.insert(externalModelObjects).values(target.objects).run(); }
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
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); }