Add external model persistence foundation

This commit is contained in:
2026-08-02 17:18:24 +02:00
parent d0fc1a7caf
commit 76493b573e
15 changed files with 3207 additions and 8 deletions
+7
View File
@@ -269,6 +269,13 @@ complete transport/column/family-rule configuration through the revision-safe
endpoint and requests a stateless preview. Preview responses include all mapped
objects, while the UI renders only the first 25. No preview creates external
objects, rows, revisions or server-side drafts.
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The current repository is read-only; no runtime import command writes these
tables yet, and snapshot/transfer schema remains version 3 until the next work
package adds version 4 atomically.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
+16 -3
View File
@@ -500,8 +500,20 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. Persistente externe Objekte
und die bestätigte Importübernahme sind noch nicht implementiert.
werden Konfigurations-UUID und Projektlink remapped.
Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
Zellmatrix zusammen. Quellwerte, lokale Planungswerte und Override-Metadaten
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch
nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -519,7 +531,8 @@ Response verfügbar, ohne alle Zeilen in den DOM einzufügen.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- vollständiger Revit-/CSV-/IFCGUID-Round-trip über die vorhandene Grundlage hinaus
- bestätigter Revit-Erstimport und vollständiger CSV-/IFCGUID-Round-trip über
die vorhandene Transport- und Persistenzgrundlage hinaus
- vollständige elektrische Dimensionierung
- Produktionsdeployment
@@ -3,10 +3,10 @@
## Status
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in
Umsetzung; Transportverträge, Konfigurationsvalidatoren, die zustandsfreie
CSV-Transportgrundlage und die persistente projektweite Konfiguration sind
vorhanden.
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
noch nicht.
Fachliche Quelle bleibt
[Revit-CSV-Integration Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
@@ -81,7 +81,8 @@ jeweilige englische Pluralform in `snake_case`.
- stabile interne UUID;
- `projectId`;
- technischer Name und Quelltyp `revit_csv`;
- Verweis auf den zuletzt bestätigten Importbatch.
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
abgeleitet und nicht als redundanter Zeiger gespeichert.
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
@@ -268,6 +269,9 @@ einen Commit aufgenommen.
### 14.2 Persistente Objekte und Erstimport
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
weiterhin keinen Import-Write außerhalb eines Commands.
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
@@ -0,0 +1,74 @@
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,
`applied_project_revision` integer NOT NULL,
`configuration_snapshot` text NOT NULL,
`original_bytes` blob NOT NULL,
`document` text 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
);
--> 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_model_objects` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`ifc_guid` 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_ifc_guid_unique` ON `external_model_objects` (`source_id`,`ifc_guid`);--> 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_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_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_csv' 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_type_unique` ON `external_model_sources` (`project_id`,`source_type`);--> 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`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1785680771603,
"tag": "0002_awesome_madripoor",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1785683710155,
"tag": "0003_outstanding_maddog",
"breakpoints": true
}
]
}
@@ -0,0 +1,74 @@
import { asc, eq } from "drizzle-orm";
import type { ExternalModelStateReader } from "../../domain/ports/external-model-state.reader.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.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 { projects } from "../schema/projects.js";
const emptyState = (): ExternalModelStateSnapshot => ({
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
export class ExternalModelStateRepository implements ExternalModelStateReader {
constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) return { projectExists: false, state: emptyState() };
const source = this.database
.select()
.from(externalModelSources)
.where(eq(externalModelSources.projectId, projectId))
.get() ?? null;
if (!source) return { projectExists: true, state: emptyState() };
const batches = this.database
.select()
.from(externalImportBatches)
.where(eq(externalImportBatches.sourceId, source.id))
.orderBy(
asc(externalImportBatches.appliedProjectRevision),
asc(externalImportBatches.id)
)
.all();
const roomMappings = this.database
.select()
.from(externalRoomMappings)
.where(eq(externalRoomMappings.sourceId, source.id))
.orderBy(
asc(externalRoomMappings.normalizedSourceRoomKey),
asc(externalRoomMappings.id)
)
.all();
const objects = this.database
.select()
.from(externalModelObjects)
.where(eq(externalModelObjects.sourceId, source.id))
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
.all();
return {
projectExists: true,
state: {
source,
importBatches: batches.map(({ originalBytes, ...batch }) => ({
...batch,
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
})),
roomMappings,
objects,
},
};
}
}
+43
View File
@@ -0,0 +1,43 @@
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
ExternalCsvConfiguration,
ExternalCsvDocument,
} from "../../external-model/csv/external-csv-contracts.js";
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
import { externalModelSources } from "./external-model-sources.js";
import { projects } from "./projects.js";
export const externalImportBatches = sqliteTable(
"external_import_batches",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceId: text("source_id")
.notNull()
.references(() => externalModelSources.id, { onDelete: "cascade" }),
importKind: text("import_kind", { enum: externalImportKinds }).notNull(),
importedAtIso: text("imported_at_iso").notNull(),
fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(),
appliedProjectRevision: integer("applied_project_revision").notNull(),
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.notNull(),
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
document: text("document", { mode: "json" })
.$type<ExternalCsvDocument>()
.notNull(),
},
(table) => [
index("external_import_batches_project_revision_idx").on(
table.projectId,
table.appliedProjectRevision
),
index("external_import_batches_source_imported_idx").on(
table.sourceId,
table.importedAtIso
),
]
);
+80
View File
@@ -0,0 +1,80 @@
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type {
ExternalModelObjectOverrideField,
ExternalModelObjectPlanningValues,
ExternalModelObjectSourceValues,
} from "../../external-model/domain/external-model-contracts.js";
import { externalObjectPresenceStatuses } from "../../external-model/domain/external-model-contracts.js";
import { circuitDeviceRows } from "./circuit-device-rows.js";
import { distributionBoards } from "./distribution-boards.js";
import { externalImportBatches } from "./external-import-batches.js";
import { externalModelSources } from "./external-model-sources.js";
import { externalRoomMappings } from "./external-room-mappings.js";
import { projectDevices } from "./project-devices.js";
import { projects } from "./projects.js";
export const externalModelObjects = sqliteTable(
"external_model_objects",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceId: text("source_id")
.notNull()
.references(() => externalModelSources.id, { onDelete: "cascade" }),
ifcGuid: text("ifc_guid").notNull(),
lastSeenImportBatchId: text("last_seen_import_batch_id")
.notNull()
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
lastAcceptedImportBatchId: text("last_accepted_import_batch_id")
.notNull()
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
acceptedSourceValues: text("accepted_source_values", { mode: "json" })
.$type<ExternalModelObjectSourceValues>()
.notNull(),
planningValues: text("planning_values", { mode: "json" })
.$type<ExternalModelObjectPlanningValues>()
.notNull(),
overriddenFields: text("overridden_fields", { mode: "json" })
.$type<ExternalModelObjectOverrideField[]>()
.notNull(),
externalRoomMappingId: text("external_room_mapping_id").references(
() => externalRoomMappings.id,
{ onDelete: "set null" }
),
distributionBoardId: text("distribution_board_id").references(
() => distributionBoards.id,
{ onDelete: "set null" }
),
linkedProjectDeviceId: text("linked_project_device_id").references(
() => projectDevices.id,
{ onDelete: "set null" }
),
circuitDeviceRowId: text("circuit_device_row_id").references(
() => circuitDeviceRows.id,
{ onDelete: "set null" }
),
presenceStatus: text("presence_status", {
enum: externalObjectPresenceStatuses,
})
.notNull()
.default("present"),
},
(table) => [
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
table.sourceId,
table.ifcGuid
),
index("external_model_objects_project_presence_idx").on(
table.projectId,
table.presenceStatus
),
index("external_model_objects_distribution_board_idx").on(
table.distributionBoardId
),
index("external_model_objects_circuit_device_row_idx").on(
table.circuitDeviceRowId
),
]
);
+23
View File
@@ -0,0 +1,23 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { externalModelSourceTypes } from "../../external-model/domain/external-model-contracts.js";
import { projects } from "./projects.js";
export const externalModelSources = sqliteTable(
"external_model_sources",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sourceType: text("source_type", { enum: externalModelSourceTypes })
.notNull()
.default("revit_csv"),
},
(table) => [
uniqueIndex("external_model_sources_project_type_unique").on(
table.projectId,
table.sourceType
),
]
);
+37
View File
@@ -0,0 +1,37 @@
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { distributionBoards } from "./distribution-boards.js";
import { externalModelSources } from "./external-model-sources.js";
import { projects } from "./projects.js";
import { rooms } from "./rooms.js";
export const externalRoomMappings = sqliteTable(
"external_room_mappings",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceId: text("source_id")
.notNull()
.references(() => externalModelSources.id, { onDelete: "cascade" }),
normalizedSourceRoomKey: text("normalized_source_room_key").notNull(),
sourceFloorName: text("source_floor_name"),
sourceRoomNumber: text("source_room_number").notNull(),
sourceRoomName: text("source_room_name").notNull(),
roomId: text("room_id").references(() => rooms.id, { onDelete: "set null" }),
defaultDistributionBoardId: text("default_distribution_board_id").references(
() => distributionBoards.id,
{ onDelete: "set null" }
),
},
(table) => [
uniqueIndex("external_room_mappings_source_key_unique").on(
table.sourceId,
table.normalizedSourceRoomKey
),
index("external_room_mappings_project_room_idx").on(
table.projectId,
table.roomId
),
]
);
@@ -0,0 +1,10 @@
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
export interface ExternalModelStateReadResult {
projectExists: boolean;
state: ExternalModelStateSnapshot;
}
export interface ExternalModelStateReader {
getByProject(projectId: string): ExternalModelStateReadResult;
}
@@ -0,0 +1,100 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
ExternalCsvConfiguration,
ExternalCsvDocument,
} from "../csv/external-csv-contracts.js";
export const externalModelSourceTypes = ["revit_csv"] as const;
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
export const externalImportKinds = ["initial", "follow_up"] as const;
export type ExternalImportKind = (typeof externalImportKinds)[number];
export const externalObjectPresenceStatuses = ["present", "missing"] as const;
export type ExternalObjectPresenceStatus =
(typeof externalObjectPresenceStatuses)[number];
export interface ExternalModelSourceSnapshot {
id: string;
projectId: string;
name: string;
sourceType: ExternalModelSourceType;
}
export interface ExternalImportBatchSnapshot {
id: string;
projectId: string;
sourceId: string;
importKind: ExternalImportKind;
importedAtIso: string;
fileName: string;
sha256: string;
appliedProjectRevision: number;
configurationSnapshot: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
}
export interface ExternalRoomMappingSnapshot {
id: string;
projectId: string;
sourceId: string;
normalizedSourceRoomKey: string;
sourceFloorName: string | null;
sourceRoomNumber: string;
sourceRoomName: string;
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export interface ExternalModelObjectSourceValues {
rowNumber: number;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export interface ExternalModelObjectPlanningValues {
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;
}
export type ExternalModelObjectOverrideField =
keyof ExternalModelObjectPlanningValues;
export interface ExternalModelObjectSnapshot {
id: string;
projectId: string;
sourceId: string;
ifcGuid: string;
lastSeenImportBatchId: string;
lastAcceptedImportBatchId: string;
acceptedSourceValues: ExternalModelObjectSourceValues;
planningValues: ExternalModelObjectPlanningValues;
overriddenFields: ExternalModelObjectOverrideField[];
externalRoomMappingId: string | null;
distributionBoardId: string | null;
linkedProjectDeviceId: string | null;
circuitDeviceRowId: string | null;
presenceStatus: ExternalObjectPresenceStatus;
}
export interface ExternalModelStateSnapshot {
source: ExternalModelSourceSnapshot | null;
importBatches: ExternalImportBatchSnapshot[];
roomMappings: ExternalRoomMappingSnapshot[];
objects: ExternalModelObjectSnapshot[];
}
@@ -0,0 +1,147 @@
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;
}
+191
View File
@@ -0,0 +1,191 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../src/external-model/domain/external-model-matching.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
function configuredCsv() {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Doppelsteckdose",
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: { kind: "mapped-column" },
displayNameSuggestion: { kind: "selection-marker" },
});
return configuration;
}
describe("external model matching", () => {
it("normalizes source rooms and rejects duplicate IFC identities", () => {
assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101");
assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD");
assert.equal(createExternalRoomKey(" ", " "), null);
assert.throws(
() => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]),
/Duplicate IfcGUID/
);
});
it("derives initial planning values from an exact family rule", () => {
const configuration = configuredCsv();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes: utf8Bytes(quotedRevitCsv, true),
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
assert.deepEqual(projected.issues, []);
assert.equal(projected.ifcGuid, firstIfcGuid);
assert.equal(projected.planningValues.displayName, "Arbeitsplatz");
assert.equal(projected.planningValues.category, "single_phase");
assert.equal(projected.planningValues.effectiveQuantity, 2);
assert.equal(projected.planningValues.powerPerUnitW, 120.5);
});
it("keeps unclassified or invalid values visible as issues", () => {
const projected = projectInitialExternalObject(
{
rowNumber: 4,
ifcGuid: firstIfcGuid,
roomNumber: "",
roomName: "",
familyAndType: "Unbekannt",
selectionMarker: "",
circuitIdentifier: "",
power: "12,3,4",
quantity: "0",
additionalSourceValues: {},
},
externalCsvTestConfiguration
);
assert.deepEqual(projected.issues, [
"unknown-family-and-type",
"missing-room",
"invalid-power",
]);
assert.equal(projected.planningValues.powerPerUnitW, null);
});
});
describe("external model state repository", () => {
it("reads a deterministic complete external state without changing project data", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
context.db.insert(externalModelSources).values({
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
}).run();
const configuration = configuredCsv();
const bytes = utf8Bytes(quotedRevitCsv, true);
const document = parseExternalCsv(bytes, configuration);
context.db.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T15:30:00.000Z",
fileName: "revit.csv",
sha256: "hash",
appliedProjectRevision: 1,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document,
}).run();
context.db.insert(externalRoomMappings).values({
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:01/101",
sourceFloorName: null,
sourceRoomNumber: "01/101",
sourceRoomName: "Technik",
roomId: null,
defaultDistributionBoardId: null,
}).run();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes,
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
context.db.insert(externalModelObjects).values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: projected.ifcGuid,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: projected.sourceValues,
planningValues: projected.planningValues,
overriddenFields: [],
externalRoomMappingId: "mapping-1",
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: null,
presenceStatus: "present",
}).run();
const result = new ExternalModelStateRepository(context.db).getByProject("project-1");
assert.equal(result.projectExists, true);
assert.equal(result.state.source?.id, "source-1");
assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64"));
assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101");
assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid);
assert.equal(result.state.objects[0].circuitDeviceRowId, null);
assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2);
assert.throws(
() => context.db.insert(externalModelObjects).values({
...context.db.select().from(externalModelObjects).get()!,
id: "object-2",
}).run(),
/UNIQUE constraint failed/
);
} finally {
context.close();
}
});
it("distinguishes an empty project from an unknown project", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
const repository = new ExternalModelStateRepository(context.db);
assert.deepEqual(repository.getByProject("project-1"), {
projectExists: true,
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
});
assert.equal(repository.getByProject("missing").projectExists, false);
} finally {
context.close();
}
});
});