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

@ -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."
);
}
}