Replace Revit CSV backend with Linear XLSX

This commit is contained in:
Julian Appel 2026-09-30 08:46:07 +02:00
parent 52d3b1a270
commit 1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions

View file

@ -0,0 +1,97 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
UPDATE `circuit_device_rows`
SET `quantity` = `manual_quantity`
WHERE EXISTS (
SELECT 1
FROM `external_model_objects`
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
);--> statement-breakpoint
DROP TABLE `external_model_objects`;--> statement-breakpoint
DROP TABLE `external_room_mappings`;--> statement-breakpoint
DROP TABLE `external_import_batches`;--> statement-breakpoint
DROP TABLE `external_model_sources`;--> statement-breakpoint
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
CREATE TABLE `external_model_configurations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`configuration` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_model_sources` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`source_type` text DEFAULT 'revit_xlsx' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_import_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`import_kind` text NOT NULL,
`imported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`object_count` integer NOT NULL,
`applied_project_revision` integer,
`configuration_snapshot` text,
`original_bytes` blob,
`document` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
CREATE TABLE `external_room_mappings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`normalized_source_room_key` text NOT NULL,
`source_floor_name` text,
`source_room_number` text NOT NULL,
`source_room_name` text NOT NULL,
`room_id` text,
`default_distribution_board_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint
CREATE TABLE `external_model_objects` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`worksharing_id` text NOT NULL,
`ifc_guid` text,
`device_type_id` text NOT NULL,
`last_seen_import_batch_id` text NOT NULL,
`last_accepted_import_batch_id` text NOT NULL,
`accepted_source_values` text NOT NULL,
`planning_values` text NOT NULL,
`overridden_fields` text NOT NULL,
`external_room_mapping_id` text,
`distribution_board_id` text,
`linked_project_device_id` text,
`circuit_device_row_id` text,
`presence_status` text DEFAULT 'present' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,13 @@
"when": 1790624623732,
"tag": "0013_users",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790748107566,
"tag": "0014_linear_xlsx_external_model",
"breakpoints": true
}
]
}

View file

@ -1,105 +0,0 @@
import { eq } from "drizzle-orm";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
createExternalCsvConfigurationUpdateProjectCommand,
type ExternalCsvConfigurationSnapshot,
} from "../../domain/models/external-csv-configuration-project-command.model.js";
import type {
ExecuteExternalCsvConfigurationCommandInput,
ExternalCsvConfigurationProjectCommandStore,
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalCsvConfigurationProjectCommandRepository
implements ExternalCsvConfigurationProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const current = tx
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("External CSV configuration belongs to a different project.");
}
if (sameSnapshot(current, target)) {
throw new Error("External CSV configuration did not change.");
}
if (
input.source === "user" &&
target !== null &&
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
) {
throw new Error("External CSV configuration version must advance by one.");
}
if (target === null) {
tx.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.run();
} else if (current === null) {
tx.insert(externalCsvConfigurations).values(target).run();
} else {
const updated = tx
.update(externalCsvConfigurations)
.set({
configurationVersion: target.configurationVersion,
configuration: target.configuration,
})
.where(eq(externalCsvConfigurations.id, current.id))
.run();
if (updated.changes !== 1) {
throw new Error("External CSV configuration changed before update.");
}
}
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalCsvConfigurationSnapshot | null,
expected: ExternalCsvConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("External CSV configuration changed before update.");
}
}
function sameSnapshot(
left: ExternalCsvConfigurationSnapshot | null,
right: ExternalCsvConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -1,252 +0,0 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import {
assertExternalInitialImportProjectCommand,
createExternalInitialImportProjectCommand,
isEmptyExternalModelState,
} from "../../domain/models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../../external-model/domain/external-model-matching.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export class ExternalInitialImportProjectCommandRepository
implements ExternalInitialImportProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
assertExternalInitialImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentResult = new ExternalModelStateRepository(tx)
.getByProject(input.projectId);
if (!currentResult.projectExists) throw new Error("Project not found.");
const expected = input.command.payload.expected;
if (!sameState(currentResult.state, expected)) {
throw new Error("External model state changed before initial import.");
}
const target = input.command.payload.target;
if (!isEmptyExternalModelState(target)) {
validatePopulatedTarget(tx, input, target);
}
replaceExternalState(tx, input.projectId, target);
const persisted = new ExternalModelStateRepository(tx)
.getByProject(input.projectId).state;
if (!sameState(persisted, target)) {
throw new Error("Persisted external initial import differs from its target.");
}
return createExternalInitialImportProjectCommand(target, currentResult.state);
});
}
}
function validatePopulatedTarget(
database: AppDatabase,
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
target: ExternalModelStateSnapshot
) {
const source = target.source!;
const batch = target.importBatches[0];
if (source.projectId !== input.projectId) {
throw new Error("External initial import belongs to a different project.");
}
if (
input.source === "user" &&
batch.appliedProjectRevision !== input.expectedRevision + 1
) {
throw new Error("External initial import revision is invalid.");
}
const configuration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get();
if (
!configuration ||
configuration.configurationVersion !== batch.configurationVersion ||
canonicalJson(configuration.configuration) !==
canonicalJson(batch.configurationSnapshot)
) {
throw new Error("External initial import configuration changed before apply.");
}
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
if (sha256 !== batch.sha256) {
throw new Error("External initial import checksum is invalid.");
}
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
throw new Error("External initial import document differs from the original bytes.");
}
const preview = createExternalCsvPreview({
fileName: batch.fileName,
bytes,
configuration: batch.configurationSnapshot,
});
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
if (targetByIfcGuid.size !== preview.objects.length) {
throw new Error("External initial import must contain every CSV object exactly once.");
}
const mappingsById = new Map(
target.roomMappings.map((mapping) => [mapping.id, mapping])
);
const expectedRoomKeys = new Set<string>();
for (const candidate of preview.objects) {
const object = targetByIfcGuid.get(candidate.ifcGuid);
if (!object) {
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
}
const projected = projectInitialExternalObject(
candidate,
batch.configurationSnapshot
);
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
throw new Error("External initial import source values differ from the CSV.");
}
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
if (
!object.overriddenFields.includes(field) &&
canonicalJson(object.planningValues[field]) !==
canonicalJson(projected.planningValues[field])
) {
throw new Error(`External initial planning field ${field} changed without an override.`);
}
}
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
if (roomKey === null) {
if (object.externalRoomMappingId !== null) {
throw new Error("External object without a source room must not use a room mapping.");
}
} else {
expectedRoomKeys.add(roomKey);
const mapping = object.externalRoomMappingId === null
? null
: mappingsById.get(object.externalRoomMappingId);
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
throw new Error("External object source room mapping is incomplete.");
}
}
}
if (
target.roomMappings.length !== expectedRoomKeys.size ||
target.roomMappings.some(
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
)
) {
throw new Error("External initial import room mappings do not match CSV rooms.");
}
validateInternalLinks(database, input.projectId, target);
}
function validateInternalLinks(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
const roomIds = new Set(
database.select({ id: rooms.id }).from(rooms)
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
);
const boardIds = new Set(
database.select({ id: distributionBoards.id }).from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
);
const deviceIds = new Set(
database.select({ id: projectDevices.id }).from(projectDevices)
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
);
for (const mapping of target.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
throw new Error("External room mapping references another project.");
}
if (
mapping.defaultDistributionBoardId !== null &&
!boardIds.has(mapping.defaultDistributionBoardId)
) {
throw new Error("External room mapping distribution board belongs to another project.");
}
}
for (const object of target.objects) {
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
throw new Error("External object distribution board belongs to another project.");
}
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
throw new Error("External object project device belongs to another project.");
}
}
}
function replaceExternalState(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
database.delete(externalModelSources)
.where(eq(externalModelSources.projectId, projectId)).run();
if (target.source === null) return;
database.insert(externalModelSources).values(target.source).run();
database.insert(externalImportBatches).values(
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: 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 decodeCanonicalBase64(value: string) {
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("External initial import bytes are not canonical base64.");
}
return bytes;
}
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
}
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
const byId = <T extends { id: string }>(entries: readonly T[]) =>
[...entries].sort((left, right) => left.id.localeCompare(right.id));
return {
source: state.source,
importBatches: byId(state.importBatches),
roomMappings: byId(state.roomMappings),
objects: byId(state.objects),
};
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -0,0 +1,79 @@
import { eq } from "drizzle-orm";
import {
assertExternalModelConfigurationUpdateProjectCommand,
createExternalModelConfigurationUpdateProjectCommand,
type ExternalModelConfigurationSnapshot,
} from "../../domain/models/external-model-configuration-project-command.model.js";
import type {
ExecuteExternalModelConfigurationCommandInput,
ExternalModelConfigurationProjectCommandStore,
} from "../../domain/ports/external-model-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalModelConfigurationProjectCommandRepository
implements ExternalModelConfigurationProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalModelConfigurationCommandInput) {
assertExternalModelConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx.select({ id: projects.id }).from(projects)
.where(eq(projects.id, input.projectId)).get();
if (!project) throw new Error("Project not found.");
const current = tx.select().from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
}
if (sameSnapshot(current, target)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
}
if (target === null) {
tx.delete(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
} else if (current === null) {
tx.insert(externalModelConfigurations).values(target).run();
} else {
const updated = tx.update(externalModelConfigurations)
.set({ configuration: target.configuration })
.where(eq(externalModelConfigurations.id, current.id)).run();
if (updated.changes !== 1) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
return createExternalModelConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalModelConfigurationSnapshot | null,
expected: ExternalModelConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
function sameSnapshot(
left: ExternalModelConfigurationSnapshot | null,
right: ExternalModelConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -1,12 +1,11 @@
import { eq } from "drizzle-orm";
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js";
export class ExternalCsvConfigurationRepository
implements ExternalCsvConfigurationReader
{
export class ExternalModelConfigurationRepository
implements ExternalModelConfigurationReader {
constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) {
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return { projectExists: false, configuration: null };
}
if (!project) return { projectExists: false, configuration: null };
const configuration = this.database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, projectId))
.get() ?? null;
return { projectExists: true, configuration };
}

View file

@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
.select()
.from(externalModelObjects)
.where(eq(externalModelObjects.sourceId, source.id))
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
.orderBy(
asc(externalModelObjects.worksharingId),
asc(externalModelObjects.id)
)
.all();
return {
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
source,
importBatches: batches.map(({ originalBytes, ...batch }) => ({
...batch,
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
originalContentBase64:
originalBytes === null
? null
: Buffer.from(originalBytes).toString("base64"),
})),
roomMappings,
objects,

View file

@ -51,7 +51,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
const markers = new Set(
input.allLinkedObjects.map((object) =>
object.acceptedSourceValues.selectionMarker.trim()
scalarText(object.acceptedSourceValues.selectionMarker).trim()
)
);
if (markers.size > 1) {
@ -118,13 +118,17 @@ function matchesRowPlanningValues(
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function scalarText(value: string | number | boolean | null) {
return value === null ? "" : String(value);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {

View file

@ -31,7 +31,7 @@ import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
@ -241,7 +241,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
buildings: byId(state.buildings),
floors: byId(state.floors),
rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration,
externalModelConfiguration: state.externalModelConfiguration,
externalModel: {
source: state.externalModel.source,
importBatches: byId(state.externalModel.importBatches),
@ -283,8 +283,8 @@ function replaceProjectState(
.where(eq(externalModelSources.projectId, state.project.id))
.run();
database
.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, state.project.id))
.delete(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, state.project.id))
.run();
database
.delete(distributionBoards)
@ -330,10 +330,10 @@ function replaceProjectState(
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) {
if (state.externalModelConfiguration !== null) {
database
.insert(externalCsvConfigurations)
.values(state.externalCsvConfiguration)
.insert(externalModelConfigurations)
.values(state.externalModelConfiguration)
.run();
}
insertMany(database, rooms, state.rooms);
@ -387,7 +387,10 @@ function replaceProjectState(
state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
originalBytes:
originalContentBase64 === null
? null
: Buffer.from(originalContentBase64, "base64"),
})
)
);

View file

@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export interface PersistedProjectStateSnapshot {
@ -66,10 +66,10 @@ export function readProjectStateSnapshot(
.where(eq(distributionBoards.projectId, projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const externalCsvConfiguration = database
const externalModelConfiguration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, projectId))
.get() ?? null;
const externalModel = new ExternalModelStateRepository(database)
.getByProject(projectId).state;
@ -266,7 +266,7 @@ export function readProjectStateSnapshot(
projectEditor: project.projectEditor,
},
distributionBoards: boardRows,
externalCsvConfiguration,
externalModelConfiguration,
externalModel,
circuitLists: listRows,
circuitSections: sectionRows,

View file

@ -23,7 +23,7 @@ import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
@ -163,10 +163,10 @@ function insertProjectState(
.insert(projects)
.values({ ...state.project, currentRevision: 0 })
.run();
if (state.externalCsvConfiguration !== null) {
if (state.externalModelConfiguration !== null) {
database
.insert(externalCsvConfigurations)
.values(state.externalCsvConfiguration)
.insert(externalModelConfigurations)
.values(state.externalModelConfiguration)
.run();
}
insertMany(database, buildings, state.buildings);
@ -240,7 +240,10 @@ function insertProjectState(
state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
originalBytes:
originalContentBase64 === null
? null
: Buffer.from(originalContentBase64, "base64"),
})
)
);

View file

@ -1,22 +0,0 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import { projects } from "./projects.js";
export const externalCsvConfigurations = sqliteTable(
"external_csv_configurations",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
configurationVersion: integer("configuration_version").notNull(),
configuration: text("configuration", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.notNull(),
},
(table) => [
uniqueIndex("external_csv_configurations_project_id_unique").on(
table.projectId
),
]
);

View file

@ -1,9 +1,7 @@
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
ExternalCsvConfiguration,
ExternalCsvDocument,
} from "../../external-model/csv/external-csv-contracts.js";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
import type { ExternalXlsxDocument } from "../../external-model/xlsx/external-xlsx-contracts.js";
import { externalModelSources } from "./external-model-sources.js";
import { projects } from "./projects.js";
@ -21,15 +19,13 @@ export const externalImportBatches = sqliteTable(
importedAtIso: text("imported_at_iso").notNull(),
fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(),
appliedProjectRevision: integer("applied_project_revision").notNull(),
configurationVersion: integer("configuration_version").notNull(),
objectCount: integer("object_count").notNull(),
appliedProjectRevision: integer("applied_project_revision"),
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.notNull(),
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
.$type<ExternalModelConfiguration>(),
originalBytes: blob("original_bytes", { mode: "buffer" }),
document: text("document", { mode: "json" })
.$type<ExternalCsvDocument>()
.notNull(),
.$type<ExternalXlsxDocument>(),
},
(table) => [
index("external_import_batches_project_revision_idx").on(

View file

@ -0,0 +1,21 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { projects } from "./projects.js";
export const externalModelConfigurations = sqliteTable(
"external_model_configurations",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
configuration: text("configuration", { mode: "json" })
.$type<ExternalModelConfiguration>()
.notNull(),
},
(table) => [
uniqueIndex("external_model_configurations_project_id_unique").on(
table.projectId
),
]
);

View file

@ -23,7 +23,9 @@ export const externalModelObjects = sqliteTable(
sourceId: text("source_id")
.notNull()
.references(() => externalModelSources.id, { onDelete: "cascade" }),
ifcGuid: text("ifc_guid").notNull(),
worksharingId: text("worksharing_id").notNull(),
ifcGuid: text("ifc_guid"),
deviceTypeId: text("device_type_id").notNull(),
lastSeenImportBatchId: text("last_seen_import_batch_id")
.notNull()
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
@ -62,17 +64,27 @@ export const externalModelObjects = sqliteTable(
.default("present"),
},
(table) => [
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
uniqueIndex("external_model_objects_source_worksharing_id_unique").on(
table.sourceId,
table.ifcGuid
table.worksharingId
),
index("external_model_objects_project_presence_idx").on(
table.projectId,
table.presenceStatus
),
index("external_model_objects_project_device_type_idx").on(
table.projectId,
table.deviceTypeId
),
index("external_model_objects_external_room_mapping_idx").on(
table.externalRoomMappingId
),
index("external_model_objects_distribution_board_idx").on(
table.distributionBoardId
),
index("external_model_objects_linked_project_device_idx").on(
table.linkedProjectDeviceId
),
index("external_model_objects_circuit_device_row_idx").on(
table.circuitDeviceRowId
),

View file

@ -12,12 +12,9 @@ export const externalModelSources = sqliteTable(
name: text("name").notNull(),
sourceType: text("source_type", { enum: externalModelSourceTypes })
.notNull()
.default("revit_csv"),
.default("revit_xlsx"),
},
(table) => [
uniqueIndex("external_model_sources_project_type_unique").on(
table.projectId,
table.sourceType
),
uniqueIndex("external_model_sources_project_unique").on(table.projectId),
]
);

View file

@ -1,94 +0,0 @@
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalCsvConfigurationUpdateCommandType =
"external-csv-configuration.update" as const;
export const externalCsvConfigurationCommandSchemaVersion = 1 as const;
export interface ExternalCsvConfigurationSnapshot {
id: string;
projectId: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
}
export interface ExternalCsvConfigurationUpdatePayload {
expected: ExternalCsvConfigurationSnapshot | null;
target: ExternalCsvConfigurationSnapshot | null;
}
export interface ExternalCsvConfigurationUpdateProjectCommand
extends SerializedProjectCommand<ExternalCsvConfigurationUpdatePayload> {
schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion;
type: typeof externalCsvConfigurationUpdateCommandType;
}
export function createExternalCsvConfigurationUpdateProjectCommand(
expected: ExternalCsvConfigurationSnapshot | null,
target: ExternalCsvConfigurationSnapshot | null
): ExternalCsvConfigurationUpdateProjectCommand {
const command: ExternalCsvConfigurationUpdateProjectCommand = {
schemaVersion: externalCsvConfigurationCommandSchemaVersion,
type: externalCsvConfigurationUpdateCommandType,
payload: { expected, target },
};
assertExternalCsvConfigurationUpdateProjectCommand(command);
return command;
}
export function assertExternalCsvConfigurationUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalCsvConfigurationUpdateProjectCommand {
if (
command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion ||
command.type !== externalCsvConfigurationUpdateCommandType ||
!isRecord(command.payload)
) {
throw new Error("Unsupported external CSV configuration update command.");
}
assertSnapshot(command.payload.expected, "expected");
assertSnapshot(command.payload.target, "target");
if (command.payload.expected === null && command.payload.target === null) {
throw new Error("External CSV configuration command must change state.");
}
if (
command.payload.expected !== null &&
command.payload.target !== null &&
(command.payload.expected.id !== command.payload.target.id ||
command.payload.expected.projectId !== command.payload.target.projectId)
) {
throw new Error("External CSV configuration identity must remain stable.");
}
}
function assertSnapshot(
value: unknown,
field: string
): asserts value is ExternalCsvConfigurationSnapshot | null {
if (value === null) {
return;
}
if (!isRecord(value)) {
throw new Error(`${field} external CSV configuration must be an object or null.`);
}
assertId(value.id, `${field}.id`);
assertId(value.projectId, `${field}.projectId`);
if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) {
throw new Error(`${field}.configurationVersion must be a positive integer.`);
}
assertExternalCsvConfiguration(value.configuration);
if (Object.keys(value).length !== 4) {
throw new Error(`${field} external CSV configuration contains unknown fields.`);
}
}
function assertId(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,134 +0,0 @@
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalInitialImportCommandType =
"external-import.apply-initial" as const;
export const externalInitialImportCommandSchemaVersion = 1 as const;
export interface ExternalInitialImportPayload {
expected: ExternalModelStateSnapshot;
target: ExternalModelStateSnapshot;
}
export interface ExternalInitialImportProjectCommand
extends SerializedProjectCommand<ExternalInitialImportPayload> {
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
type: typeof externalInitialImportCommandType;
}
export function createExternalInitialImportProjectCommand(
expected: ExternalModelStateSnapshot,
target: ExternalModelStateSnapshot
): ExternalInitialImportProjectCommand {
const command: ExternalInitialImportProjectCommand = {
schemaVersion: externalInitialImportCommandSchemaVersion,
type: externalInitialImportCommandType,
payload: { expected, target },
};
assertExternalInitialImportProjectCommand(command);
return command;
}
export function assertExternalInitialImportProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalInitialImportProjectCommand {
if (
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
command.type !== externalInitialImportCommandType ||
!isRecord(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported external initial import command.");
}
const expected = parseExternalModelStateSnapshot(command.payload.expected);
const target = parseExternalModelStateSnapshot(command.payload.target);
const expectedIsEmpty = isEmptyExternalModelState(expected);
const targetIsEmpty = isEmptyExternalModelState(target);
if (expectedIsEmpty === targetIsEmpty) {
throw new Error("External initial import must transition between empty and populated state.");
}
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
}
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
return { source: null, importBatches: [], roomMappings: [], objects: [] };
}
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
return (
state.source === null &&
state.importBatches.length === 0 &&
state.roomMappings.length === 0 &&
state.objects.length === 0
);
}
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
throw new Error("External initial import requires one source, one batch and at least one object.");
}
const source = state.source;
const batch = state.importBatches[0];
if (
batch.importKind !== "initial" ||
batch.projectId !== source.projectId ||
batch.sourceId !== source.id
) {
throw new Error("External initial import batch identity is invalid.");
}
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
const roomKeys = new Set<string>();
for (const mapping of state.roomMappings) {
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
throw new Error("External initial room mapping identity is invalid.");
}
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
throw new Error("External initial import contains duplicate room keys.");
}
roomKeys.add(mapping.normalizedSourceRoomKey);
}
uniqueIds(state.objects, "object");
const ifcGuids = new Set<string>();
for (const object of state.objects) {
if (
object.projectId !== source.projectId ||
object.sourceId !== source.id ||
object.lastSeenImportBatchId !== batch.id ||
object.lastAcceptedImportBatchId !== batch.id
) {
throw new Error("External initial object identity is invalid.");
}
if (ifcGuids.has(object.ifcGuid)) {
throw new Error("External initial import contains duplicate IFCGUIDs.");
}
ifcGuids.add(object.ifcGuid);
if (
object.externalRoomMappingId !== null &&
!mappingIds.has(object.externalRoomMappingId)
) {
throw new Error("External initial object room mapping is invalid.");
}
if (object.circuitDeviceRowId !== null) {
throw new Error("External initial import must not assign circuit device rows.");
}
if (object.presenceStatus !== "present") {
throw new Error("External initial import objects must be present.");
}
}
}
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`External initial import contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -0,0 +1,88 @@
import {
assertExternalModelConfiguration,
type ExternalModelConfiguration,
} from "../../external-model/configuration/external-model-configuration.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalModelConfigurationUpdateCommandType =
"external-model-configuration.update" as const;
export const externalModelConfigurationCommandSchemaVersion = 1 as const;
export interface ExternalModelConfigurationSnapshot {
id: string;
projectId: string;
configuration: ExternalModelConfiguration;
}
export interface ExternalModelConfigurationUpdatePayload {
expected: ExternalModelConfigurationSnapshot | null;
target: ExternalModelConfigurationSnapshot | null;
}
export interface ExternalModelConfigurationUpdateProjectCommand
extends SerializedProjectCommand<ExternalModelConfigurationUpdatePayload> {
schemaVersion: typeof externalModelConfigurationCommandSchemaVersion;
type: typeof externalModelConfigurationUpdateCommandType;
}
export function createExternalModelConfigurationUpdateProjectCommand(
expected: ExternalModelConfigurationSnapshot | null,
target: ExternalModelConfigurationSnapshot | null
): ExternalModelConfigurationUpdateProjectCommand {
const command: ExternalModelConfigurationUpdateProjectCommand = {
schemaVersion: externalModelConfigurationCommandSchemaVersion,
type: externalModelConfigurationUpdateCommandType,
payload: { expected, target },
};
assertExternalModelConfigurationUpdateProjectCommand(command);
return command;
}
export function assertExternalModelConfigurationUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalModelConfigurationUpdateProjectCommand {
if (
command.schemaVersion !== externalModelConfigurationCommandSchemaVersion ||
command.type !== externalModelConfigurationUpdateCommandType ||
!isRecord(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Nicht unterstützter Revit-XLSX-Konfigurationscommand.");
}
assertSnapshot(command.payload.expected, "expected");
assertSnapshot(command.payload.target, "target");
if (command.payload.expected === null && command.payload.target === null) {
throw new Error("Der Konfigurationscommand muss den Zustand ändern.");
}
if (
command.payload.expected !== null &&
command.payload.target !== null &&
(command.payload.expected.id !== command.payload.target.id ||
command.payload.expected.projectId !== command.payload.target.projectId)
) {
throw new Error("Die Identität der Revit-XLSX-Konfiguration muss stabil bleiben.");
}
}
function assertSnapshot(
value: unknown,
field: string
): asserts value is ExternalModelConfigurationSnapshot | null {
if (value === null) return;
if (!isRecord(value) || Object.keys(value).length !== 3) {
throw new Error(`${field} muss eine vollständige Konfiguration oder null sein.`);
}
assertId(value.id, `${field}.id`);
assertId(value.projectId, `${field}.projectId`);
assertExternalModelConfiguration(value.configuration);
}
function assertId(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} muss eine nichtleere ID sein.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -28,6 +28,10 @@ import {
} from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import {
validateExternalModelConfiguration,
type ExternalModelConfiguration,
} from "../../external-model/configuration/external-model-configuration.js";
import {
externalImportKinds,
externalModelSourceTypes,
@ -35,7 +39,8 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 9 as const;
export const projectStateSnapshotSchemaVersion = 10 as const;
const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const;
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
@ -423,14 +428,14 @@ const externalCsvDocumentSchema = z.object({
});
});
const externalModelSourceSchema = z.object({
const legacyExternalModelSourceSchema = z.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
sourceType: z.literal("revit_csv"),
}).strict();
const externalImportBatchSchema = z.object({
const legacyExternalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
@ -461,7 +466,7 @@ const externalRoomMappingSchema = z.object({
defaultDistributionBoardId: idSchema.nullable(),
}).strict();
const externalPlanningFieldNames = [
const legacyExternalPlanningFieldNames = [
"displayName",
"internalDeviceType",
"category",
@ -474,7 +479,7 @@ const externalPlanningFieldNames = [
"remark",
] as const;
const externalModelObjectSchema = z.object({
const legacyExternalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
@ -504,6 +509,170 @@ const externalModelObjectSchema = z.object({
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).strict(),
overriddenFields: z.array(z.enum(legacyExternalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length
),
externalRoomMappingId: idSchema.nullable(),
distributionBoardId: idSchema.nullable(),
linkedProjectDeviceId: idSchema.nullable(),
circuitDeviceRowId: idSchema.nullable(),
presenceStatus: z.enum(externalObjectPresenceStatuses),
}).strict();
const legacyExternalModelStateSchema = z.object({
source: legacyExternalModelSourceSchema.nullable(),
importBatches: z.array(legacyExternalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(legacyExternalModelObjectSchema),
}).strict();
const externalModelConfigurationSnapshotSchema = z.object({
id: idSchema,
projectId: idSchema,
configuration: z.custom<ExternalModelConfiguration>(
(value) => validateExternalModelConfiguration(value).success
),
}).strict();
const externalXlsxScalarSchema = z.union([
z.string(),
finiteNumberSchema,
z.boolean(),
z.null(),
]);
const externalXlsxDocumentSchema = z.object({
sheetName: z.string().min(1).max(31),
headerRowNumber: z.number().int().positive(),
firstDataRowNumber: z.number().int().positive(),
firstDataColumnNumber: z.number().int().positive(),
columns: z.array(z.object({
columnNumber: z.number().int().positive(),
header: z.string(),
comment: z.string().nullable(),
width: finiteNumberSchema.positive().nullable(),
}).strict()),
rows: z.array(z.object({
rowNumber: z.number().int().positive(),
classification: z.enum(["object", "suspect-object", "passthrough"]),
cells: z.array(z.object({
columnNumber: z.number().int().positive(),
value: externalXlsxScalarSchema,
valueType: z.enum(["empty", "string", "number", "boolean"]),
numberFormat: z.string().nullable(),
formula: z.string().nullable(),
usedCachedFormulaValue: z.boolean(),
}).strict()),
}).strict()),
issues: z.array(z.object({
code: z.enum([
"cached-formula-value-used",
"formula-without-cached-value",
"suspect-object-skipped",
"duplicate-ifc-guid",
"optional-return-column-missing",
]),
message: z.string().min(1),
rowNumber: z.number().int().positive().optional(),
columnNumber: z.number().int().positive().optional(),
field: z.enum([
"worksharingId", "ifcGuid", "roomNumber", "roomName",
"familyAndType", "selectionMarker", "socketCount", "sourceRemark",
"circuitCombined", "boardName", "circuitIdentifier", "designation",
"electricalPowerW", "voltageV", "currentA",
]).optional(),
}).strict()),
}).strict();
const externalModelSourceSchema = z.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
}).strict();
const externalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
importKind: z.enum(externalImportKinds),
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
objectCount: z.number().int().nonnegative(),
appliedProjectRevision: z.number().int().nonnegative().nullable(),
configurationSnapshot: z.custom<ExternalModelConfiguration>(
(value) => value === null || validateExternalModelConfiguration(value).success
).nullable(),
originalContentBase64: z.string().min(1).regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
).nullable(),
document: externalXlsxDocumentSchema.nullable(),
}).strict().superRefine((batch, context) => {
const complete = [
batch.appliedProjectRevision,
batch.configurationSnapshot,
batch.originalContentBase64,
batch.document,
].map((value) => value !== null);
if (!complete.every((value) => value === complete[0])) {
context.addIssue({
code: "custom",
message: "Importbatch muss vollständig oder auf Metadaten reduziert sein.",
});
}
});
const externalPlanningFieldNames = [
"displayName",
"category",
"connectionKind",
"effectiveQuantity",
"powerPerUnitW",
"simultaneityFactor",
"cosPhi",
"costGroup",
"remark",
] as const;
const externalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
worksharingId: z.string().regex(
/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/
),
ifcGuid: externalIfcGuidSchema.nullable(),
deviceTypeId: idSchema,
lastSeenImportBatchId: idSchema,
lastAcceptedImportBatchId: idSchema,
acceptedSourceValues: z.object({
rowNumber: z.number().int().positive(),
roomNumber: externalXlsxScalarSchema,
roomName: externalXlsxScalarSchema,
familyAndType: z.string().min(1),
selectionMarker: externalXlsxScalarSchema,
socketCount: externalXlsxScalarSchema,
sourceRemark: externalXlsxScalarSchema,
circuitCombined: externalXlsxScalarSchema,
boardName: externalXlsxScalarSchema,
circuitIdentifier: externalXlsxScalarSchema,
designation: externalXlsxScalarSchema,
electricalPowerW: externalXlsxScalarSchema,
voltageV: externalXlsxScalarSchema,
currentA: externalXlsxScalarSchema,
}).strict(),
planningValues: z.object({
displayName: nullableStringSchema,
category: z.enum(circuitGroupCategories).nullable(),
connectionKind: nullableStringSchema,
effectiveQuantity: finiteNumberSchema.positive(),
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
simultaneityFactor: finiteNumberSchema.min(0).max(1).nullable(),
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).strict(),
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length
),
@ -519,7 +688,21 @@ const externalModelStateSchema = z.object({
importBatches: z.array(externalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(externalModelObjectSchema),
}).strict();
}).strict().superRefine((state, context) => {
const fullBatchIndexes = state.importBatches.flatMap((batch, index) =>
batch.originalContentBase64 === null ? [] : [index]
);
if (
state.importBatches.length > 0 &&
(fullBatchIndexes.length !== 1 ||
fullBatchIndexes[0] !== state.importBatches.length - 1)
) {
context.addIssue({
code: "custom",
message: "Nur der letzte Importbatch darf vollständig gespeichert sein.",
});
}
});
export function parseExternalModelStateSnapshot(
value: unknown
@ -565,19 +748,30 @@ export const projectStateSnapshotSchema = z
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const legacyCsvProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const roomAreaProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
@ -588,7 +782,7 @@ const buildingProjectStateSnapshotSchema = z
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...buildingProjectStateSnapshotContents,
})
.strict();
@ -599,7 +793,7 @@ const coverProjectStateSnapshotSchema = z
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -610,7 +804,7 @@ const boardInfoProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -621,7 +815,7 @@ const previousProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -684,6 +878,39 @@ export function parseProjectStateSnapshot(
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value);
if (
legacyCsv !== null &&
typeof legacyCsv === "object" &&
!Array.isArray(legacyCsv) &&
(legacyCsv as { schemaVersion?: unknown }).schemaVersion ===
legacyCsvProjectStateSnapshotSchemaVersion
) {
const previous = legacyCsvProjectStateSnapshotSchema.parse(legacyCsv);
if (
previous.externalCsvConfiguration !== null ||
!isEmptyExternalModelState(previous.externalModel)
) {
throw new Error(
"Dieser alte Projektstand enthält Revit-CSV-Daten und kann nicht auf die Revit-XLSX-Struktur aktualisiert werden."
);
}
const {
externalCsvConfiguration: _externalCsvConfiguration,
externalModel: _legacyExternalModel,
...remaining
} = previous;
return {
...remaining,
schemaVersion: projectStateSnapshotSchemaVersion,
externalModelConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
return legacyCsv;
}
function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown {
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
if (
roomArea !== null &&
@ -695,7 +922,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardFeeding(
previous.distributionBoards
),
@ -830,6 +1057,20 @@ function emptyExternalModelState() {
};
}
function isEmptyExternalModelState(state: {
source: unknown;
importBatches: unknown[];
roomMappings: unknown[];
objects: unknown[];
}) {
return (
state.source === null &&
state.importBatches.length === 0 &&
state.roomMappings.length === 0 &&
state.objects.length === 0
);
}
function normalizeSnapshotPhaseTypes(
snapshot: ProjectStateSnapshot
): ProjectStateSnapshot {
@ -891,10 +1132,10 @@ function assertProjectStateSnapshotRelations(
) {
const projectId = snapshot.project.id;
if (
snapshot.externalCsvConfiguration !== null &&
snapshot.externalCsvConfiguration.projectId !== projectId
snapshot.externalModelConfiguration !== null &&
snapshot.externalModelConfiguration.projectId !== projectId
) {
throw new Error("Snapshot external CSV configuration belongs to a different project.");
throw new Error("Snapshot Revit-XLSX configuration belongs to a different project.");
}
const boardIds = uniqueIds(
snapshot.distributionBoards,
@ -1235,6 +1476,12 @@ function assertExternalModelRelations(
}
return;
}
if (snapshot.externalModelConfiguration === null) {
throw new Error("Snapshot external model requires a Revit-XLSX configuration.");
}
if (importBatches.length === 0) {
throw new Error("Snapshot external model source requires an import batch.");
}
assertProjectOwnership(source.projectId, references.projectId, "external model source");
const batchIds = uniqueIds(importBatches, "external import batch");
const mappingIds = uniqueIds(roomMappings, "external room mapping");
@ -1270,17 +1517,23 @@ function assertExternalModelRelations(
}
}
const ifcGuids = new Set<string>();
const worksharingIds = new Set<string>();
const deviceTypeIds = new Set(
snapshot.externalModelConfiguration.configuration.deviceTypes.map(
(deviceType) => deviceType.id
)
);
for (const object of objects) {
assertProjectOwnership(object.projectId, references.projectId, "external model object");
if (object.sourceId !== source.id) {
throw new Error("Snapshot external model object belongs to a different source.");
}
assertUniqueKey(
ifcGuids,
object.ifcGuid,
"Snapshot contains duplicate external IFCGUIDs."
worksharingIds,
object.worksharingId,
"Snapshot contains duplicate external Worksharing-IDs."
);
assertReference(deviceTypeIds, object.deviceTypeId, "external object device type");
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
if (object.externalRoomMappingId !== null) {

View file

@ -146,16 +146,35 @@ export function remapProjectState(
createId
);
const externalObjectIds = idMap(source.externalModel.objects, createId);
const configurations = [
source.externalModelConfiguration?.configuration ?? null,
...source.externalModel.importBatches.map(
(batch) => batch.configurationSnapshot
),
].filter((configuration) => configuration !== null);
const deviceTypeIds = uniqueIdMap(
configurations.flatMap((configuration) => configuration.deviceTypes),
createId
);
const familyRuleIds = uniqueIdMap(
configurations.flatMap((configuration) => configuration.familyTypeRules),
createId
);
return parseProjectStateSnapshot({
...source,
project: { ...source.project, id: targetProjectId, name },
externalCsvConfiguration:
source.externalCsvConfiguration === null
externalModelConfiguration:
source.externalModelConfiguration === null
? null
: {
...source.externalCsvConfiguration,
...source.externalModelConfiguration,
id: createId(),
projectId: targetProjectId,
configuration: remapExternalModelConfiguration(
source.externalModelConfiguration.configuration,
deviceTypeIds,
familyRuleIds
),
},
externalModel: {
source: source.externalModel.source === null
@ -170,6 +189,14 @@ export function remapProjectState(
id: requiredId(externalBatchIds, batch.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
configurationSnapshot:
batch.configurationSnapshot === null
? null
: remapExternalModelConfiguration(
batch.configurationSnapshot,
deviceTypeIds,
familyRuleIds
),
})),
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
...mapping,
@ -190,6 +217,7 @@ export function remapProjectState(
id: requiredId(externalObjectIds, object.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId),
lastSeenImportBatchId: requiredId(
externalBatchIds,
object.lastSeenImportBatchId
@ -322,6 +350,36 @@ export function remapProjectState(
});
}
function remapExternalModelConfiguration(
configuration: NonNullable<
ProjectStateSnapshot["externalModelConfiguration"]
>["configuration"],
deviceTypeIds: ReadonlyMap<string, string>,
familyRuleIds: ReadonlyMap<string, string>
) {
return {
...configuration,
deviceTypes: configuration.deviceTypes.map((deviceType) => ({
...deviceType,
id: requiredId(deviceTypeIds, deviceType.id),
})),
familyTypeRules: configuration.familyTypeRules.map((rule) => ({
...rule,
id: requiredId(familyRuleIds, rule.id),
deviceTypeId: requiredId(deviceTypeIds, rule.deviceTypeId),
})),
};
}
function uniqueIdMap(
entries: ReadonlyArray<{ id: string }>,
createId: () => string
) {
return new Map(
[...new Set(entries.map((entry) => entry.id))].map((id) => [id, createId()])
);
}
function idMap(
entries: ReadonlyArray<{ id: string }>,
createId: () => string

View file

@ -1,19 +0,0 @@
import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalCsvConfigurationCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalCsvConfigurationUpdateProjectCommand;
}
export interface ExternalCsvConfigurationProjectCommandStore {
execute(input: ExecuteExternalCsvConfigurationCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalCsvConfigurationUpdateProjectCommand;
};
}

View file

@ -1,10 +0,0 @@
import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js";
export interface ExternalCsvConfigurationReadResult {
projectExists: boolean;
configuration: ExternalCsvConfigurationSnapshot | null;
}
export interface ExternalCsvConfigurationReader {
getByProject(projectId: string): ExternalCsvConfigurationReadResult;
}

View file

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

View file

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

View file

@ -0,0 +1,10 @@
import type { ExternalModelConfigurationSnapshot } from "../models/external-model-configuration-project-command.model.js";
export interface ExternalModelConfigurationReadResult {
projectExists: boolean;
configuration: ExternalModelConfigurationSnapshot | null;
}
export interface ExternalModelConfigurationReader {
getByProject(projectId: string): ExternalModelConfigurationReadResult;
}

View file

@ -173,15 +173,10 @@ import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
externalCsvConfigurationUpdateCommandType,
} from "../models/external-csv-configuration-project-command.model.js";
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js";
import {
assertExternalInitialImportProjectCommand,
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
assertExternalModelConfigurationUpdateProjectCommand,
externalModelConfigurationUpdateCommandType,
} from "../models/external-model-configuration-project-command.model.js";
import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
@ -243,8 +238,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalModelConfigurationStore?: ExternalModelConfigurationProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
@ -308,6 +302,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
);
}
assertNoLegacyExternalCsvHistoryCommand(step.command);
return this.dispatch({
...input,
source: direction,
@ -625,22 +621,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalCsvConfigurationUpdateCommandType: {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
if (!this.externalCsvConfigurationStore) {
throw new Error("External CSV configuration store is not available.");
case externalModelConfigurationUpdateCommandType: {
assertExternalModelConfigurationUpdateProjectCommand(input.command);
if (!this.externalModelConfigurationStore) {
throw new Error("External model configuration store is not available.");
}
return this.externalCsvConfigurationStore.execute({
...input,
command: input.command,
}).revision;
}
case externalInitialImportCommandType: {
assertExternalInitialImportProjectCommand(input.command);
if (!this.externalInitialImportStore) {
throw new Error("External initial import store is not available.");
}
return this.externalInitialImportStore.execute({
return this.externalModelConfigurationStore.execute({
...input,
command: input.command,
}).revision;
@ -720,3 +706,16 @@ function assertExpectedRevision(expectedRevision: number) {
);
}
}
function assertNoLegacyExternalCsvHistoryCommand(
command: SerializedProjectCommand<unknown>
) {
if (
command.type === "external-csv-configuration.update" ||
command.type === "external-import.apply-initial"
) {
throw new Error(
"Dieser Verlaufseintrag stammt aus der alten Revit-CSV-Anbindung und kann nach der Umstellung auf Linear-XLSX nicht mehr ausgef\u00fchrt werden."
);
}
}

View file

@ -1,88 +0,0 @@
import type {
ExternalModelStateSnapshot,
} from "../domain/external-model-contracts.js";
export interface ExternalCircuitListObjectDto {
id: string;
ifcGuid: string;
selectionMarker: string;
familyAndType: string;
sourceCircuitIdentifier: string;
displayName: string | null;
category: string | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
sourceRoomNumber: string;
sourceRoomName: string;
roomId: string | null;
linkedProjectDeviceId: string | null;
circuitDeviceRowId: string | null;
presenceStatus: "present" | "missing";
}
export interface ExternalCircuitListObjectsDto {
sourceName: string | null;
objectCount: number;
unassignedObjectCount: number;
objects: ExternalCircuitListObjectDto[];
}
export function createExternalCircuitListObjects(input: {
state: ExternalModelStateSnapshot;
distributionBoardId: string;
}): ExternalCircuitListObjectsDto {
const mappingById = new Map(
input.state.roomMappings.map((mapping) => [mapping.id, mapping])
);
const objects = input.state.objects
.filter((object) => object.distributionBoardId === input.distributionBoardId)
.map((object) => {
const mapping = object.externalRoomMappingId === null
? null
: mappingById.get(object.externalRoomMappingId) ?? null;
return {
id: object.id,
ifcGuid: object.ifcGuid,
selectionMarker: object.acceptedSourceValues.selectionMarker,
familyAndType: object.acceptedSourceValues.familyAndType,
sourceCircuitIdentifier: object.acceptedSourceValues.circuitIdentifier,
displayName: object.planningValues.displayName,
category: object.planningValues.category,
connectionKind: object.planningValues.connectionKind,
effectiveQuantity: object.planningValues.effectiveQuantity,
powerPerUnitW: object.planningValues.powerPerUnitW,
sourceRoomNumber:
mapping?.sourceRoomNumber ?? object.acceptedSourceValues.roomNumber,
sourceRoomName:
mapping?.sourceRoomName ?? object.acceptedSourceValues.roomName,
roomId: mapping?.roomId ?? null,
linkedProjectDeviceId: object.linkedProjectDeviceId,
circuitDeviceRowId: object.circuitDeviceRowId,
presenceStatus: object.presenceStatus,
} satisfies ExternalCircuitListObjectDto;
})
.sort(compareExternalObjects);
return {
sourceName: input.state.source?.name ?? null,
objectCount: objects.length,
unassignedObjectCount: objects.filter(
(object) => object.circuitDeviceRowId === null
).length,
objects,
};
}
function compareExternalObjects(
left: ExternalCircuitListObjectDto,
right: ExternalCircuitListObjectDto
) {
const assignmentOrder = Number(left.circuitDeviceRowId !== null) -
Number(right.circuitDeviceRowId !== null);
if (assignmentOrder !== 0) return assignmentOrder;
return left.sourceRoomNumber.localeCompare(right.sourceRoomNumber, "de", {
numeric: true,
}) || left.selectionMarker.localeCompare(right.selectionMarker, "de", {
numeric: true,
}) || left.ifcGuid.localeCompare(right.ifcGuid);
}

View file

@ -1,91 +0,0 @@
import { createHash } from "node:crypto";
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import { parseExternalCsv } from "../csv/external-csv-transport.js";
export interface ExternalCsvPreviewObject {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export interface ExternalCsvPreview {
fileName: string;
byteLength: number;
sha256: string;
dialect: ReturnType<typeof parseExternalCsv>["dialect"];
headerRowNumber: number;
headerColumns: string[];
rowCount: number;
objectCount: number;
passthroughCount: number;
nonEmptyPassthroughCount: number;
suspectObjectCount: number;
objects: ExternalCsvPreviewObject[];
suspectRowNumbers: number[];
}
export function createExternalCsvPreview(input: {
fileName: string;
bytes: Uint8Array;
configuration: ExternalCsvConfiguration;
}): ExternalCsvPreview {
const document = parseExternalCsv(input.bytes, input.configuration);
const header = document.rows[document.headerRowIndex];
const headerIndex = new Map(
header.cells.map((cell, index) => [cell.value, index])
);
const valueAt = (row: (typeof document.rows)[number], column: string) =>
row.cells[headerIndex.get(column)!]?.value ?? "";
const objects = document.rows
.filter((row) => row.classification === "object")
.map((row): ExternalCsvPreviewObject => ({
rowNumber: row.index + 1,
ifcGuid: valueAt(row, input.configuration.columns.ifcGuid),
roomNumber: valueAt(row, input.configuration.columns.roomNumber),
roomName: valueAt(row, input.configuration.columns.roomName),
familyAndType: valueAt(row, input.configuration.columns.familyAndType),
selectionMarker: valueAt(row, input.configuration.columns.selectionMarker),
circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier),
power: valueAt(row, input.configuration.columns.power),
quantity:
input.configuration.columns.quantity === null
? null
: valueAt(row, input.configuration.columns.quantity),
additionalSourceValues: Object.fromEntries(
input.configuration.additionalSourceMappings.map((mapping) => [
mapping.targetField,
valueAt(row, mapping.sourceColumn),
])
),
}));
const passthroughRows = document.rows.filter(
(row) => row.classification === "passthrough"
);
const suspectRows = document.rows.filter(
(row) => row.classification === "suspect-object"
);
return {
fileName: input.fileName,
byteLength: input.bytes.byteLength,
sha256: createHash("sha256").update(input.bytes).digest("hex"),
dialect: document.dialect,
headerRowNumber: document.headerRowIndex + 1,
headerColumns: header.cells.map((cell) => cell.value),
rowCount: document.rows.length,
objectCount: objects.length,
passthroughCount: passthroughRows.length,
nonEmptyPassthroughCount: passthroughRows.filter((row) =>
row.cells.some((cell) => cell.value !== "")
).length,
suspectObjectCount: suspectRows.length,
objects,
suspectRowNumbers: suspectRows.map((row) => row.index + 1),
};
}

View file

@ -1,164 +0,0 @@
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import {
createExternalRoomKey,
normalizeExternalRoomPart,
projectInitialExternalObject,
} from "../domain/external-model-matching.js";
import { createExternalCsvPreview } from "./external-csv-preview.js";
interface ExistingRoom {
id: string;
floorId: string | null;
roomNumber: string;
roomName: string;
}
interface ExistingFloor {
id: string;
name: string;
sortOrder: number;
}
interface ExistingDistributionBoard {
id: string;
name: string;
floorId: string | null;
}
interface ExistingProjectDevice {
id: string;
name: string;
displayName: string;
category: string | null;
connectionKind: string | null;
powerPerUnit: number;
}
export function createExternalInitialImportPlan(input: {
fileName: string;
bytes: Uint8Array;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
rooms: ExistingRoom[];
floors: ExistingFloor[];
distributionBoards: ExistingDistributionBoard[];
projectDevices: ExistingProjectDevice[];
}) {
const preview = createExternalCsvPreview(input);
const roomCandidatesByNumber = new Map<string, ExistingRoom[]>();
for (const room of input.rooms) {
const key = normalizeExternalRoomPart(room.roomNumber);
if (!key) continue;
const candidates = roomCandidatesByNumber.get(key) ?? [];
candidates.push(room);
roomCandidatesByNumber.set(key, candidates);
}
const objects = preview.objects.map((object) => {
const projected = projectInitialExternalObject(object, input.configuration);
return {
ifcGuid: projected.ifcGuid,
sourceRoomKey: createExternalRoomKey(object.roomNumber, object.roomName),
sourceValues: projected.sourceValues,
suggestedPlanningValues: projected.planningValues,
issues: projected.issues,
};
});
const sourceRooms = new Map<string, {
sourceRoomKey: string;
roomNumber: string;
roomName: string;
objectCount: number;
}>();
for (const object of objects) {
if (object.sourceRoomKey === null) continue;
const existing = sourceRooms.get(object.sourceRoomKey);
if (existing) {
existing.objectCount += 1;
} else {
sourceRooms.set(object.sourceRoomKey, {
sourceRoomKey: object.sourceRoomKey,
roomNumber: object.sourceValues.roomNumber,
roomName: object.sourceValues.roomName,
objectCount: 1,
});
}
}
const roomPlans = [...sourceRooms.values()]
.map((room) => {
const candidates = roomCandidatesByNumber.get(
normalizeExternalRoomPart(room.roomNumber)
) ?? [];
return {
...room,
suggestedRoomId: candidates.length === 1 ? candidates[0].id : null,
matchStatus:
candidates.length === 1
? "exact-number"
: candidates.length > 1
? "ambiguous-number"
: "new",
candidateRoomIds: candidates.map((candidate) => candidate.id),
} as const;
})
.sort((left, right) =>
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
left.roomName.localeCompare(right.roomName, "de")
);
const familyGroups = new Map<string, {
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
}>();
for (const object of objects) {
const key = object.sourceValues.familyAndType;
const group = familyGroups.get(key);
if (group) {
group.objectCount += 1;
} else {
familyGroups.set(key, {
familyAndType: key,
objectCount: 1,
classified: !object.issues.includes("unknown-family-and-type"),
category: object.suggestedPlanningValues.category,
connectionKind: object.suggestedPlanningValues.connectionKind,
internalDeviceType: object.suggestedPlanningValues.internalDeviceType,
});
}
}
return {
configurationVersion: input.configurationVersion,
fileName: preview.fileName,
sha256: preview.sha256,
byteLength: preview.byteLength,
rowCount: preview.rowCount,
objectCount: preview.objectCount,
passthroughCount: preview.passthroughCount,
suspectObjectCount: preview.suspectObjectCount,
suspectRowNumbers: preview.suspectRowNumbers,
sourceRooms: roomPlans,
familyGroups: [...familyGroups.values()].sort((left, right) =>
left.familyAndType.localeCompare(right.familyAndType, "de")
),
issueCounts: {
unknownFamilyAndType: objects.filter((object) =>
object.issues.includes("unknown-family-and-type")
).length,
invalidPower: objects.filter((object) => object.issues.includes("invalid-power")).length,
invalidQuantity: objects.filter((object) => object.issues.includes("invalid-quantity")).length,
missingRoom: objects.filter((object) => object.issues.includes("missing-room")).length,
},
objects,
existing: {
floors: input.floors,
rooms: input.rooms,
distributionBoards: input.distributionBoards,
projectDevices: input.projectDevices,
},
};
}

View file

@ -1,130 +0,0 @@
import type { ExternalCsvConfiguration, ExternalCsvDocument } from "../csv/external-csv-contracts.js";
import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { createExternalInitialImportPlan } from "./external-initial-import-plan.js";
type ExternalInitialImportPlan = ReturnType<typeof createExternalInitialImportPlan>;
export interface ExternalRoomDecision {
sourceRoomKey: string;
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export interface ExternalFamilyProjectDeviceDecision {
familyAndType: string;
projectDeviceId: string | null;
}
export function buildExternalInitialImportTarget(input: {
projectId: string;
expectedRevision: number;
sourceName: string;
importedAtIso: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
plan: ExternalInitialImportPlan;
roomDecisions: ExternalRoomDecision[];
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
createId: () => string;
}): ExternalModelStateSnapshot {
if (input.plan.familyGroups.some((group) => !group.classified)) {
throw new Error("Alle Familie-und-Typ-Werte müssen vor dem Erstimport klassifiziert sein.");
}
const roomDecisionByKey = exactDecisionMap(
input.plan.sourceRooms.map((room) => room.sourceRoomKey),
input.roomDecisions,
(decision) => decision.sourceRoomKey,
"Quellraum"
);
const familyDecisionByName = exactDecisionMap(
input.plan.familyGroups.map((group) => group.familyAndType),
input.familyProjectDeviceDecisions,
(decision) => decision.familyAndType,
"Familie und Typ"
);
const sourceId = input.createId();
const batchId = input.createId();
const roomMappingIdByKey = new Map(
input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()])
);
return {
source: {
id: sourceId,
projectId: input.projectId,
name: input.sourceName.trim(),
sourceType: "revit_csv",
},
importBatches: [{
id: batchId,
projectId: input.projectId,
sourceId,
importKind: "initial",
importedAtIso: input.importedAtIso,
fileName: input.plan.fileName,
sha256: input.plan.sha256,
appliedProjectRevision: input.expectedRevision + 1,
configurationVersion: input.configurationVersion,
configurationSnapshot: input.configuration,
originalContentBase64: input.originalContentBase64,
document: input.document,
}],
roomMappings: input.plan.sourceRooms.map((room) => {
const decision = roomDecisionByKey.get(room.sourceRoomKey)!;
return {
id: roomMappingIdByKey.get(room.sourceRoomKey)!,
projectId: input.projectId,
sourceId,
normalizedSourceRoomKey: room.sourceRoomKey,
sourceFloorName: null,
sourceRoomNumber: room.roomNumber,
sourceRoomName: room.roomName,
roomId: decision.roomId,
defaultDistributionBoardId: decision.defaultDistributionBoardId,
};
}),
objects: input.plan.objects.map((object) => ({
id: input.createId(),
projectId: input.projectId,
sourceId,
ifcGuid: object.ifcGuid,
lastSeenImportBatchId: batchId,
lastAcceptedImportBatchId: batchId,
acceptedSourceValues: object.sourceValues,
planningValues: object.suggestedPlanningValues,
overriddenFields: [],
externalRoomMappingId:
object.sourceRoomKey === null
? null
: roomMappingIdByKey.get(object.sourceRoomKey)!,
distributionBoardId: null,
linkedProjectDeviceId:
familyDecisionByName.get(object.sourceValues.familyAndType)!
.projectDeviceId,
circuitDeviceRowId: null,
presenceStatus: "present",
})),
};
}
function exactDecisionMap<T>(
expectedKeys: string[],
decisions: T[],
keyOf: (decision: T) => string,
label: string
) {
const expected = new Set(expectedKeys);
const result = new Map<string, T>();
for (const decision of decisions) {
const key = keyOf(decision);
if (!expected.has(key) || result.has(key)) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
result.set(key, decision);
}
if (result.size !== expected.size) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
return result;
}

View file

@ -0,0 +1,246 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxColumnConfiguration,
type ExternalXlsxTransportConfiguration,
} from "../xlsx/external-xlsx-contracts.js";
export const externalModelConfigurationSchemaVersion = 1 as const;
export const externalQuantityRules = ["one", "socketCount"] as const;
export type ExternalQuantityRule = (typeof externalQuantityRules)[number];
export interface ExternalDeviceTypeDefaults {
powerPerUnitW: number | null;
simultaneityFactor: number | null;
cosPhi: number | null;
costGroup: string | null;
}
export interface ExternalDeviceTypeConfiguration {
id: string;
name: string;
category: CircuitGroupCategory;
defaults: ExternalDeviceTypeDefaults;
}
export interface ExternalFamilyTypeRule {
id: string;
exactFamilyAndType: string;
deviceTypeId: string;
quantityRule: ExternalQuantityRule;
}
export interface ExternalModelConfiguration
extends ExternalXlsxTransportConfiguration {
schemaVersion: typeof externalModelConfigurationSchemaVersion;
deviceTypes: ExternalDeviceTypeConfiguration[];
familyTypeRules: ExternalFamilyTypeRule[];
}
export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration {
return {
schemaVersion: externalModelConfigurationSchemaVersion,
...createDefaultExternalXlsxTransportConfiguration(),
deviceTypes: [],
familyTypeRules: [],
};
}
export function assertExternalModelConfiguration(
value: unknown
): asserts value is ExternalModelConfiguration {
if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) {
throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration.");
}
assertExactKeys(value, [
"schemaVersion",
"layout",
"circuitMode",
"columns",
"deviceTypes",
"familyTypeRules",
], "Revit-XLSX-Konfiguration");
assertLayout(value.layout);
if (value.circuitMode !== "combined" && value.circuitMode !== "separate") {
throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig.");
}
assertColumns(value.columns, value.circuitMode);
if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) {
throw new Error("Gerätearten und Familienregeln müssen Listen sein.");
}
const deviceTypeIds = new Set<string>();
const deviceTypeNames = new Set<string>();
for (const [index, deviceType] of value.deviceTypes.entries()) {
assertDeviceType(deviceType, index);
assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID");
assertUnique(deviceTypeNames, deviceType.name, "Geräteartname");
}
const ruleIds = new Set<string>();
const familyNames = new Set<string>();
for (const [index, rule] of value.familyTypeRules.entries()) {
assertFamilyRule(rule, index);
assertUnique(ruleIds, rule.id, "Familienregel-ID");
assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ");
if (!deviceTypeIds.has(rule.deviceTypeId)) {
throw new Error(
`Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.`
);
}
}
}
export function validateExternalModelConfiguration(value: unknown):
| { success: true; data: ExternalModelConfiguration }
| { success: false; error: string } {
try {
assertExternalModelConfiguration(value);
return { success: true, data: value };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Ungültige Konfiguration.",
};
}
}
function assertLayout(value: unknown) {
if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt.");
assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout");
assertPositiveInteger(value.headerRow, "Kopfzeile");
assertPositiveInteger(value.firstDataRow, "erste Datenzeile");
assertPositiveInteger(value.firstDataColumn, "erste Datenspalte");
if ((value.firstDataRow as number) <= (value.headerRow as number)) {
throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen.");
}
}
function assertColumns(
value: unknown,
circuitMode: ExternalModelConfiguration["circuitMode"]
) {
if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt.");
const keys = [
"worksharingId",
"ifcGuid",
"roomNumber",
"roomName",
"familyAndType",
"selectionMarker",
"socketCount",
"sourceRemark",
"circuitCombined",
"boardName",
"circuitIdentifier",
"designation",
"electricalPowerW",
"voltageV",
"currentA",
] satisfies (keyof ExternalXlsxColumnConfiguration)[];
assertExactKeys(value, keys, "XLSX-Spaltenzuordnung");
const mappedNames = new Set<string>();
for (const key of keys) {
const columnName = value[key];
const required =
key === "worksharingId" ||
key === "familyAndType" ||
(circuitMode === "combined" && key === "circuitCombined") ||
(circuitMode === "separate" &&
(key === "boardName" || key === "circuitIdentifier"));
if (columnName === null && !required) continue;
assertNonEmptyString(columnName, `Spalte ${key}`);
assertUnique(mappedNames, columnName, "Spaltenzuordnung");
}
}
function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration {
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`);
assertUuid(value.id, `Geräteart ${index + 1}.id`);
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {
throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`);
}
if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`);
assertExactKeys(
value.defaults,
["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"],
`Geräteart ${index + 1}.defaults`
);
assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung");
assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF");
assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi");
if (value.defaults.costGroup !== null) {
assertNonEmptyString(value.defaults.costGroup, "Kostengruppe");
}
}
function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule {
if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`);
assertExactKeys(
value,
["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"],
`Familienregel ${index + 1}`
);
assertUuid(value.id, `Familienregel ${index + 1}.id`);
assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`);
assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`);
if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) {
throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`);
}
}
function assertPositiveInteger(value: unknown, label: string) {
if (!Number.isSafeInteger(value) || (value as number) < 1) {
throw new Error(`${label} muss eine positive Ganzzahl sein.`);
}
}
function assertNullableNumber(
value: unknown,
minimum: number,
maximum: number,
label: string
) {
if (value === null) return;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
throw new Error(`${label} ist ungültig.`);
}
}
function assertNonEmptyString(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`);
}
}
function assertUuid(value: unknown, label: string): asserts value is string {
if (
typeof value !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
) {
throw new Error(`${label} muss eine UUID sein.`);
}
}
function assertUnique(values: Set<string>, value: string, label: string) {
if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`);
values.add(value);
}
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string) {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error(`${label} enthält unbekannte oder fehlende Felder.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,356 +0,0 @@
import { assertExternalCsvConfiguration } from "./external-csv-configuration.js";
import type {
ExternalCsvCell,
ExternalCsvConfiguration,
ExternalCsvDocument,
ExternalCsvLineEnding,
ExternalCsvRow,
} from "./external-csv-contracts.js";
export type ExternalCsvParseErrorCode =
| "invalid-encoding"
| "invalid-csv"
| "header-not-found"
| "ambiguous-header"
| "invalid-ifc-guid"
| "duplicate-ifc-guid";
export class ExternalCsvParseError extends Error {
constructor(
public readonly code: ExternalCsvParseErrorCode,
message: string
) {
super(message);
this.name = "ExternalCsvParseError";
}
}
export function parseExternalCsv(
input: Uint8Array,
configuration: ExternalCsvConfiguration
): ExternalCsvDocument {
assertExternalCsvConfiguration(configuration);
const hasBom = hasUtf8Bom(input);
const contentBytes = hasBom ? input.subarray(3) : input;
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(contentBytes);
} catch {
throw new ExternalCsvParseError(
"invalid-encoding",
"CSV is not valid UTF-8."
);
}
const parsed = parseRows(text, configuration.delimiter);
const headerMatches = findHeaderRows(parsed.rows, configuration);
if (headerMatches.length === 0) {
throw new ExternalCsvParseError(
"header-not-found",
"CSV does not contain exactly the configured required columns in one header row."
);
}
if (headerMatches.length > 1) {
throw new ExternalCsvParseError(
"ambiguous-header",
"CSV contains more than one possible header row."
);
}
const headerRowIndex = headerMatches[0];
const configuredColumnNames = [
...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
];
const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value);
if (
configuredColumnNames.some(
(columnName) => headerValues.filter((value) => value === columnName).length !== 1
)
) {
throw new ExternalCsvParseError(
"ambiguous-header",
"CSV header contains a configured column more than once."
);
}
const headerLookup = createHeaderLookup(parsed.rows[headerRowIndex]);
const ifcGuidColumnIndex = headerLookup.get(configuration.columns.ifcGuid)!;
const objectBearingColumnIndexes = [
configuration.columns.roomNumber,
configuration.columns.roomName,
configuration.columns.familyAndType,
configuration.columns.selectionMarker,
configuration.columns.power,
...(configuration.columns.quantity === null
? []
: [configuration.columns.quantity]),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
].map((column) => headerLookup.get(column)!);
const seenIfcGuids = new Set<string>();
const rows: ExternalCsvRow[] = parsed.rows.map((cells, index) => {
if (index < headerRowIndex) {
return { index, cells, classification: "metadata" };
}
if (index === headerRowIndex) {
return { index, cells, classification: "header" };
}
const rawIfcGuid = cells[ifcGuidColumnIndex]?.value ?? "";
const ifcGuid = rawIfcGuid.trim();
if (ifcGuid) {
if (ifcGuid !== rawIfcGuid || !isIfcGuid(ifcGuid)) {
throw new ExternalCsvParseError(
"invalid-ifc-guid",
`CSV row ${index + 1} contains an invalid IfcGUID.`
);
}
if (seenIfcGuids.has(ifcGuid)) {
throw new ExternalCsvParseError(
"duplicate-ifc-guid",
`CSV contains the IfcGUID more than once: ${ifcGuid}`
);
}
seenIfcGuids.add(ifcGuid);
return { index, cells, classification: "object" };
}
const looksLikeObject = objectBearingColumnIndexes.some(
(columnIndex) => (cells[columnIndex]?.value.trim() ?? "") !== ""
);
return {
index,
cells,
classification: looksLikeObject ? "suspect-object" : "passthrough",
};
});
return {
dialect: {
encoding: "utf-8",
hasBom,
delimiter: configuration.delimiter,
lineEnding: resolveLineEnding(parsed.lineEndings),
quoteCharacter: '"',
quoteAllFields:
rows.flatMap((row) => row.cells).length > 0 &&
rows.flatMap((row) => row.cells).every((cell) => cell.wasQuoted),
hasTrailingLineEnding: parsed.hasTrailingLineEnding,
},
headerRowIndex,
rows,
};
}
export function serializeExternalCsv(document: ExternalCsvDocument): Uint8Array {
assertSerializableDocument(document);
const serializedRows = document.rows.map((row) =>
row.cells
.map((cell) => serializeCell(cell, document.dialect.delimiter))
.join(document.dialect.delimiter)
);
let text = serializedRows.join(document.dialect.lineEnding);
if (document.dialect.hasTrailingLineEnding && document.rows.length > 0) {
text += document.dialect.lineEnding;
}
const encoded = new TextEncoder().encode(text);
if (!document.dialect.hasBom) {
return encoded;
}
const result = new Uint8Array(encoded.length + 3);
result.set([0xef, 0xbb, 0xbf]);
result.set(encoded, 3);
return result;
}
function parseRows(text: string, delimiter: string) {
const rows: ExternalCsvCell[][] = [];
const lineEndings: ExternalCsvLineEnding[] = [];
let cells: ExternalCsvCell[] = [];
let value = "";
let wasQuoted = false;
let inQuotes = false;
let afterClosingQuote = false;
let fieldStarted = false;
let hasTrailingLineEnding = false;
const finishCell = () => {
cells.push({ value, wasQuoted });
value = "";
wasQuoted = false;
inQuotes = false;
afterClosingQuote = false;
fieldStarted = false;
};
const finishRow = (lineEnding: ExternalCsvLineEnding) => {
finishCell();
rows.push(cells);
cells = [];
lineEndings.push(lineEnding);
hasTrailingLineEnding = true;
};
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (inQuotes) {
if (character === '"') {
if (text[index + 1] === '"') {
value += '"';
index += 1;
} else {
inQuotes = false;
afterClosingQuote = true;
}
} else {
value += character;
}
hasTrailingLineEnding = false;
continue;
}
if (afterClosingQuote) {
if (character !== delimiter && character !== "\r" && character !== "\n") {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains characters after a closing quote."
);
}
}
if (character === delimiter) {
finishCell();
hasTrailingLineEnding = false;
continue;
}
if (character === "\r" || character === "\n") {
const lineEnding: ExternalCsvLineEnding =
character === "\r" && text[index + 1] === "\n"
? "\r\n"
: character;
if (lineEnding === "\r\n") {
index += 1;
}
finishRow(lineEnding);
continue;
}
if (character === '"') {
if (fieldStarted || value.length > 0 || afterClosingQuote) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains a quote inside an unquoted field."
);
}
wasQuoted = true;
inQuotes = true;
fieldStarted = true;
hasTrailingLineEnding = false;
continue;
}
if (afterClosingQuote) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains invalid data after a quoted field."
);
}
value += character;
fieldStarted = true;
hasTrailingLineEnding = false;
}
if (inQuotes) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains an unterminated quoted field."
);
}
if (!hasTrailingLineEnding || cells.length > 0 || fieldStarted || value.length > 0) {
finishCell();
rows.push(cells);
}
return { rows, lineEndings, hasTrailingLineEnding };
}
function findHeaderRows(
rows: ExternalCsvCell[][],
configuration: ExternalCsvConfiguration
) {
const requiredColumns = new Set([
...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
]);
const matches: number[] = [];
rows.forEach((row, index) => {
const values = new Set(row.map((cell) => cell.value));
if ([...requiredColumns].every((column) => values.has(column))) {
matches.push(index);
}
});
return matches;
}
function createHeaderLookup(cells: ExternalCsvCell[]) {
return new Map(cells.map((cell, index) => [cell.value, index]));
}
function isIfcGuid(value: string) {
return /^[0-9A-Za-z_$]{22}$/.test(value);
}
function resolveLineEnding(lineEndings: ExternalCsvLineEnding[]): ExternalCsvLineEnding {
if (lineEndings.length === 0) {
return "\r\n";
}
const unique = new Set(lineEndings);
if (unique.size !== 1) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains mixed line endings that cannot be preserved reliably."
);
}
return lineEndings[0];
}
function serializeCell(cell: ExternalCsvCell, delimiter: string) {
const mustQuote =
cell.wasQuoted ||
cell.value.includes(delimiter) ||
cell.value.includes('"') ||
cell.value.includes("\r") ||
cell.value.includes("\n");
if (!mustQuote) {
return cell.value;
}
return `"${cell.value.replaceAll('"', '""')}"`;
}
function assertSerializableDocument(document: ExternalCsvDocument) {
if (document.dialect.encoding !== "utf-8") {
throw new Error("Only UTF-8 external CSV documents can be serialized.");
}
if (
document.headerRowIndex < 0 ||
document.headerRowIndex >= document.rows.length ||
document.rows[document.headerRowIndex]?.classification !== "header"
) {
throw new Error("External CSV document has an invalid header row.");
}
document.rows.forEach((row, index) => {
if (row.index !== index) {
throw new Error("External CSV row indexes must be contiguous and stable.");
}
for (const cell of row.cells) {
if (typeof cell.value !== "string" || typeof cell.wasQuoted !== "boolean") {
throw new Error("External CSV document contains an invalid cell.");
}
}
});
}
function hasUtf8Bom(input: Uint8Array) {
return input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf;
}

View file

@ -1,10 +1,11 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
import type {
ExternalCsvConfiguration,
ExternalCsvDocument,
} from "../csv/external-csv-contracts.js";
ExternalXlsxDocument,
ExternalXlsxScalar,
} from "../xlsx/external-xlsx-contracts.js";
export const externalModelSourceTypes = ["revit_csv"] as const;
export const externalModelSourceTypes = ["revit_xlsx"] as const;
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
export const externalImportKinds = ["initial", "follow_up"] as const;
@ -29,11 +30,11 @@ export interface ExternalImportBatchSnapshot {
importedAtIso: string;
fileName: string;
sha256: string;
appliedProjectRevision: number;
configurationVersion: number;
configurationSnapshot: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
objectCount: number;
appliedProjectRevision: number | null;
configurationSnapshot: ExternalModelConfiguration | null;
originalContentBase64: string | null;
document: ExternalXlsxDocument | null;
}
export interface ExternalRoomMappingSnapshot {
@ -50,24 +51,28 @@ export interface ExternalRoomMappingSnapshot {
export interface ExternalModelObjectSourceValues {
rowNumber: number;
roomNumber: string;
roomName: string;
roomNumber: ExternalXlsxScalar;
roomName: ExternalXlsxScalar;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
selectionMarker: ExternalXlsxScalar;
socketCount: ExternalXlsxScalar;
sourceRemark: ExternalXlsxScalar;
circuitCombined: ExternalXlsxScalar;
boardName: ExternalXlsxScalar;
circuitIdentifier: ExternalXlsxScalar;
designation: ExternalXlsxScalar;
electricalPowerW: ExternalXlsxScalar;
voltageV: ExternalXlsxScalar;
currentA: ExternalXlsxScalar;
}
export interface ExternalModelObjectPlanningValues {
displayName: string | null;
internalDeviceType: string | null;
category: CircuitGroupCategory | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
simultaneityFactor: number;
simultaneityFactor: number | null;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
@ -80,7 +85,9 @@ export interface ExternalModelObjectSnapshot {
id: string;
projectId: string;
sourceId: string;
ifcGuid: string;
worksharingId: string;
ifcGuid: string | null;
deviceTypeId: string;
lastSeenImportBatchId: string;
lastAcceptedImportBatchId: string;
acceptedSourceValues: ExternalModelObjectSourceValues;

View file

@ -1,147 +0,0 @@
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import type {
ExternalModelObjectPlanningValues,
ExternalModelObjectSourceValues,
} from "./external-model-contracts.js";
export type InitialExternalObjectIssue =
| "unknown-family-and-type"
| "invalid-power"
| "invalid-quantity"
| "missing-room";
export interface InitialExternalObjectProjection {
ifcGuid: string;
sourceValues: ExternalModelObjectSourceValues;
planningValues: ExternalModelObjectPlanningValues;
issues: InitialExternalObjectIssue[];
}
export interface ExternalObjectImportCandidate {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export function normalizeExternalRoomPart(value: string): string {
return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE");
}
export function createExternalRoomKey(
roomNumber: string,
roomName: string
): string | null {
const normalizedNumber = normalizeExternalRoomPart(roomNumber);
if (normalizedNumber) return `number:${normalizedNumber}`;
const normalizedName = normalizeExternalRoomPart(roomName);
return normalizedName ? `name:${normalizedName}` : null;
}
export function indexExternalObjectsByIfcGuid<T extends { ifcGuid: string }>(
objects: readonly T[]
): Map<string, T> {
const index = new Map<string, T>();
for (const object of objects) {
if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) {
throw new Error("IfcGUID must be non-empty and must not contain outer whitespace.");
}
if (index.has(object.ifcGuid)) {
throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`);
}
index.set(object.ifcGuid, object);
}
return index;
}
export function projectInitialExternalObject(
object: ExternalObjectImportCandidate,
configuration: ExternalCsvConfiguration
): InitialExternalObjectProjection {
const rule = configuration.familyTypeRules.find(
(candidate) => candidate.exactFamilyAndType === object.familyAndType
);
const issues: InitialExternalObjectIssue[] = [];
if (!rule) issues.push("unknown-family-and-type");
if (!createExternalRoomKey(object.roomNumber, object.roomName)) {
issues.push("missing-room");
}
const parsedPower = parseConfiguredNumber(object.power, configuration);
if (object.power.trim() && parsedPower === null) issues.push("invalid-power");
let effectiveQuantity = 1;
if (rule?.quantityRule.kind === "fixed") {
effectiveQuantity = rule.quantityRule.quantity;
} else if (rule?.quantityRule.kind === "mapped-column") {
const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration);
if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) {
issues.push("invalid-quantity");
} else {
effectiveQuantity = parsedQuantity;
}
}
return {
ifcGuid: object.ifcGuid,
sourceValues: {
rowNumber: object.rowNumber,
roomNumber: object.roomNumber,
roomName: object.roomName,
familyAndType: object.familyAndType,
selectionMarker: object.selectionMarker,
circuitIdentifier: object.circuitIdentifier,
power: object.power,
quantity: object.quantity,
additionalSourceValues: { ...object.additionalSourceValues },
},
planningValues: {
displayName: rule
? resolveDisplayName(rule.displayNameSuggestion, object)
: null,
internalDeviceType: rule?.internalDeviceType ?? null,
category: rule?.category ?? null,
connectionKind: rule?.connectionKind ?? null,
effectiveQuantity,
powerPerUnitW:
parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
issues,
};
}
function parseConfiguredNumber(
value: string,
configuration: ExternalCsvConfiguration
): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const normalized = configuration.decimalSeparator === ","
? trimmed.replace(",", ".")
: trimmed;
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function resolveDisplayName(
suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"],
object: ExternalObjectImportCandidate
): string | null {
if (suggestion === null) return null;
if (suggestion.kind === "fixed") return suggestion.value;
if (suggestion.kind === "selection-marker") {
return object.selectionMarker.trim() || null;
}
return object.familyAndType.trim() || null;
}

View file

@ -14,7 +14,6 @@ import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
export interface ExternalCsvConfigurationSnapshotDto {
id: string;
@ -52,9 +51,80 @@ export interface ExternalCsvPreviewDto {
suspectRowNumbers: number[];
}
export type ExternalInitialImportPlanDto = ReturnType<
typeof createExternalInitialImportPlan
>;
export interface ExternalInitialImportPlanDto {
configurationVersion: number;
fileName: string;
sha256: string;
byteLength: number;
rowCount: number;
objectCount: number;
passthroughCount: number;
suspectObjectCount: number;
suspectRowNumbers: number[];
sourceRooms: Array<{
sourceRoomKey: string;
roomNumber: string;
roomName: string;
objectCount: number;
suggestedRoomId: string | null;
matchStatus: "exact-number" | "ambiguous-number" | "new";
candidateRoomIds: string[];
}>;
familyGroups: Array<{
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
}>;
issueCounts: {
unknownFamilyAndType: number;
invalidPower: number;
invalidQuantity: number;
missingRoom: number;
};
objects: Array<{
ifcGuid: string;
sourceRoomKey: string | null;
sourceValues: ExternalCsvPreviewObjectDto;
suggestedPlanningValues: {
displayName: string | null;
internalDeviceType: string | null;
category: CircuitGroupCategory | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
simultaneityFactor: number;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
};
issues: string[];
}>;
existing: {
floors: Array<{ id: string; name: string; sortOrder: number }>;
rooms: Array<{
id: string;
floorId: string | null;
roomNumber: string;
roomName: string;
}>;
distributionBoards: Array<{
id: string;
name: string;
floorId: string | null;
}>;
projectDevices: Array<{
id: string;
name: string;
displayName: string;
category: string | null;
connectionKind: string | null;
powerPerUnit: number;
}>;
};
}
export interface ApplyExternalInitialImportInput {
expectedRevision: number;

View file

@ -13,7 +13,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js";
@ -36,8 +36,8 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db);
export const projectRepository = new ProjectRepository(db);
export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelConfigurationRepository =
new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db);

View file

@ -22,8 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -71,10 +70,8 @@ export const projectSettingsProjectCommandStore =
export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const externalCsvConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
@ -106,8 +103,7 @@ export const projectCommandService = new ProjectCommandService(
circuitGroupSubtreeProjectCommandStore,
circuitProtectionProjectCommandStore,
projectHistoryStore,
externalCsvConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore,
externalModelConfigurationProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore,

View file

@ -1,298 +0,0 @@
import crypto from "node:crypto";
import type { Request, Response } from "express";
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../../external-model/application/external-initial-import-target.js";
import { createExternalCircuitListObjects } from "../../external-model/application/external-circuit-list-objects.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import {
createEmptyExternalModelState,
createExternalInitialImportProjectCommand,
} from "../../domain/models/external-initial-import-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
import {
previewExternalCsvSchema,
planExternalInitialImportSchema,
applyExternalInitialImportSchema,
updateExternalCsvConfigurationSchema,
} from "../../shared/validation/external-csv.schemas.js";
import {
distributionBoardRepository,
externalCsvConfigurationRepository,
externalModelStateRepository,
floorRepository,
projectDeviceRepository,
roomRepository,
circuitListRepository,
} from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export function getExternalCsvConfiguration(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const result = externalCsvConfigurationRepository.getByProject(projectId);
if (!result.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
return res.json({ configuration: result.configuration });
}
export async function getExternalCircuitListObjects(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const { circuitListId } = req.params;
if (typeof circuitListId !== "string" || !circuitListId.trim()) {
return res.status(400).json({ error: "Invalid circuitListId" });
}
const circuitList = await circuitListRepository.findById(projectId, circuitListId);
if (!circuitList) {
return res.status(404).json({ error: "Circuit list not found" });
}
const state = externalModelStateRepository.getByProject(projectId);
if (!state.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(createExternalCircuitListObjects({
state: state.state,
distributionBoardId: circuitList.distributionBoardId,
}));
}
export function updateExternalCsvConfiguration(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
assertExternalCsvConfiguration(parsed.data.configuration);
const current = externalCsvConfigurationRepository.getByProject(projectId);
if (!current.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
const target = {
id: current.configuration?.id ?? crypto.randomUUID(),
projectId,
configurationVersion:
(current.configuration?.configurationVersion ?? 0) + 1,
configuration: parsed.data.configuration,
};
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Revit-CSV-Konfiguration bearbeiten",
command: createExternalCsvConfigurationUpdateProjectCommand(
current.configuration,
target
),
});
return res.json({ ...result, configuration: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function previewExternalCsv(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = previewExternalCsvSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
return res.json(
createExternalCsvPreview({
fileName: parsed.data.fileName,
bytes,
configuration: stored.configuration.configuration,
})
);
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return res.status(400).json({
error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.",
});
}
}
export async function planExternalInitialImport(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = planExternalInitialImportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
const externalState = externalModelStateRepository.getByProject(projectId);
if (externalState.state.source !== null) {
return res.status(409).json({
error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.",
code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
floorRepository.listByProject(projectId),
roomRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
projectDeviceRepository.listByProject(projectId),
]);
return res.json(createExternalInitialImportPlan({
fileName: parsed.data.fileName,
bytes,
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
floors,
rooms,
distributionBoards,
projectDevices,
}));
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return res.status(400).json({
error: error instanceof Error ? error.message : "Erstimport-Planung fehlgeschlagen.",
});
}
}
export async function applyExternalInitialImport(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = applyExternalInitialImportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
if (
stored.configuration.configurationVersion !==
parsed.data.expectedConfigurationVersion
) {
return res.status(409).json({
error: "Die Revit-CSV-Konfiguration wurde seit der Planung geändert.",
code: "EXTERNAL_CSV_CONFIGURATION_CHANGED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
floorRepository.listByProject(projectId),
roomRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
projectDeviceRepository.listByProject(projectId),
]);
const plan = createExternalInitialImportPlan({
fileName: parsed.data.fileName,
bytes,
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
floors,
rooms,
distributionBoards,
projectDevices,
});
if (plan.sha256 !== parsed.data.expectedSha256) {
return res.status(409).json({
error: "Die CSV-Datei stimmt nicht mehr mit der geprüften Planung überein.",
code: "EXTERNAL_CSV_FILE_CHANGED",
});
}
const target = buildExternalInitialImportTarget({
projectId,
expectedRevision: parsed.data.expectedRevision,
sourceName: parsed.data.sourceName,
importedAtIso: new Date().toISOString(),
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
originalContentBase64: parsed.data.contentBase64,
document: parseExternalCsv(bytes, stored.configuration.configuration),
plan,
roomDecisions: parsed.data.roomDecisions,
familyProjectDeviceDecisions:
parsed.data.familyProjectDeviceDecisions,
createId: () => crypto.randomUUID(),
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: `Revit-Erstimport ${parsed.data.fileName}`,
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
});
return res.status(201).json({
...result,
import: {
sourceId: target.source!.id,
importBatchId: target.importBatches[0].id,
objectCount: target.objects.length,
},
});
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return respondWithProjectCommandError(error, res);
}
}
function decodeBase64(value: string) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
}
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert.");
}
if (bytes.length > 18 * 1024 * 1024) {
throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB.");
}
return bytes;
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}

View file

@ -53,14 +53,6 @@ import {
importProjectTransfer,
importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js";
import {
getExternalCsvConfiguration,
applyExternalInitialImport,
previewExternalCsv,
planExternalInitialImport,
updateExternalCsvConfiguration,
getExternalCircuitListObjects,
} from "../controllers/external-csv.controller.js";
export const projectRouter = Router();
@ -70,21 +62,6 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/export", exportProjectTransfer);
projectRouter.post("/:projectId/import", importProjectTransfer);
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
projectRouter.post(
"/:projectId/external-csv/initial-import/plan",
planExternalInitialImport
);
projectRouter.get(
"/:projectId/circuit-lists/:circuitListId/external-objects",
getExternalCircuitListObjects
);
projectRouter.post(
"/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport
);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);

View file

@ -1,40 +0,0 @@
import { z } from "zod";
// Base64 length of the 18 MiB transport limit documented for CSV uploads
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
// padding). Keep in sync with the controller's raw byte-length check.
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
export const updateExternalCsvConfigurationSchema = z
.object({
expectedRevision: z.number().int().nonnegative(),
configuration: z.unknown(),
})
.strict();
export const previewExternalCsvSchema = z
.object({
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
})
.strict();
export const planExternalInitialImportSchema = previewExternalCsvSchema;
export const applyExternalInitialImportSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1),
roomId: z.string().trim().min(1).nullable(),
defaultDistributionBoardId: z.string().trim().min(1).nullable(),
}).strict()),
familyProjectDeviceDecisions: z.array(z.object({
familyAndType: z.string(),
projectDeviceId: z.string().trim().min(1).nullable(),
}).strict()),
}).strict();