diff --git a/AGENTS.md b/AGENTS.md index 6332166..0b7077d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -299,6 +299,12 @@ objects as one external-only row (`manualQuantity = 0`) into an existing circuit. Its history-only inverse removes the row only while its complete snapshot and exact object-link set remain unchanged. Ordinary detach continues to preserve the row. +`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit +with stable BMK/UUIDs, derived voltage, one external-only row, required default +protection and its external object links. Its history-only inverse requires the +complete unchanged circuit snapshot and exact link set. Redo never recalculates +the BMK. All three external assignment stores share +`external-object-assignment.persistence.ts` for compatibility and link safety. Confirmed initial state is written only through `external-import.apply-initial`. The command rechecks configuration version, original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 0a7b076..4f7f660 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -10,10 +10,10 @@ through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. - The Revit/CSV foundation and confirmed initial import are implemented. - Existing-row assignment, move and detach and creation of an external-only row - in an existing circuit have atomic persistent commands. Their editor UI, the - new-circuit path, follow-up import, conflict review and return export are - pending. + Existing-row assignment, move and detach plus creation of an external-only + row in an existing or newly created circuit have atomic persistent commands. + Their API planning and editor UI, follow-up import, conflict review and return + export are pending. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fb4f2ee..a0282de 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -559,6 +559,17 @@ und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind. Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel bestehen. +`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit +vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer +externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks. +Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert +gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein +allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei +unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt +dieselben IDs, dasselbe BMK und denselben Schutzsnapshot. +Die drei Zuordnungsadapter teilen sich +`external-object-assignment.persistence.ts` für Snapshotvergleich, +Kompatibilitätsregeln und link-sichere Updates. `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. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index ffc1b45..b721c3a 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -205,9 +205,9 @@ bleiben und dürfen spätere Quellstände nicht erneut auswerten. - `external-object.update-row-assignment`: Ein oder mehrere Objekte einer bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen und alle betroffenen materialisierten Mengen atomar aktualisieren. -- `external-model-object.assign-to-new-row`: stabile neue Row einfügen, +- `external-object.assign-to-new-row`: stabile neue Row einfügen, Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren. -- `external-model-object.assign-to-new-circuit`: stabilen Circuit samt Schutz, +- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz, Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im Command gespeichert und bei Redo nicht neu berechnet. - `external-model-object.delete-missing`: nur ausdrücklich bestätigte, @@ -330,7 +330,13 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist - als Nutzeraktion gesperrt. Der neue Circuit folgt separat. + als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt + den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK, + Standardschutz und genau einer externen Row wird gemeinsam mit den + Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen + Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt. + BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente + Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload testen. diff --git a/src/db/repositories/external-object-assignment.persistence.ts b/src/db/repositories/external-object-assignment.persistence.ts new file mode 100644 index 0000000..ddc9891 --- /dev/null +++ b/src/db/repositories/external-object-assignment.persistence.ts @@ -0,0 +1,137 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; +import type { AppDatabase } from "../database-context.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; + +export interface ExternalObjectLinkTransition { + expected: ExternalModelObjectSnapshot; + target: ExternalModelObjectSnapshot; +} + +export function loadExpectedExternalObjectTransitions( + database: AppDatabase, + projectId: string, + transitions: ExternalObjectLinkTransition[], + changedMessage: string +) { + const byId = new Map( + transitions.map((transition) => [transition.expected.id, transition]) + ); + const current = database.select().from(externalModelObjects) + .where(inArray(externalModelObjects.id, [...byId.keys()])).all(); + if (current.length !== byId.size) { + throw new Error("One or more external objects no longer exist."); + } + for (const object of current) { + if ( + object.projectId !== projectId || + !snapshotsEqual(toExternalObjectSnapshot(object), byId.get(object.id)!.expected) + ) { + throw new Error(changedMessage); + } + } + return transitions; +} + +export function assertExternalObjectsCompatibleWithRow(input: { + database: AppDatabase; + projectId: string; + row: CircuitDeviceRowSnapshot; + distributionBoardId: string; + category: CircuitGroupCategory; + assignedObjects: ExternalModelObjectSnapshot[]; + allLinkedObjects: ExternalModelObjectSnapshot[]; + confirmedConflictObjectIds: ReadonlySet; +}) { + const mappings = input.database.select().from(externalRoomMappings) + .where(eq(externalRoomMappings.projectId, input.projectId)).all(); + const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId])); + const markers = new Set( + input.allLinkedObjects.map((object) => + object.acceptedSourceValues.selectionMarker.trim() + ) + ); + if (markers.size > 1) { + throw new Error("External objects with different selection markers require separate rows."); + } + for (const object of input.assignedObjects) { + if (object.distributionBoardId !== input.distributionBoardId) { + throw new Error("External object distribution does not match target circuit."); + } + if (object.planningValues.category !== input.category) { + throw new Error("External object category does not match target circuit group."); + } + const roomId = object.externalRoomMappingId === null + ? null + : roomIdByMappingId.get(object.externalRoomMappingId) ?? null; + if (roomId !== input.row.roomId) { + throw new Error("External object room does not match target device row."); + } + if ( + !matchesRowPlanningValues(input.row, object) && + !input.confirmedConflictObjectIds.has(object.id) + ) { + throw new Error("External object planning values require explicit conflict confirmation."); + } + } +} + +export function applyExternalObjectLinks( + database: AppDatabase, + transitions: ExternalObjectLinkTransition[], + changedMessage: string +) { + for (const { expected, target } of transitions) { + const updated = database.update(externalModelObjects) + .set({ circuitDeviceRowId: target.circuitDeviceRowId }) + .where(and( + eq(externalModelObjects.id, expected.id), + expected.circuitDeviceRowId === null + ? isNull(externalModelObjects.circuitDeviceRowId) + : eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId) + )).run(); + if (updated.changes !== 1) throw new Error(changedMessage); + } +} + +export function toExternalObjectSnapshot( + object: typeof externalModelObjects.$inferSelect +): ExternalModelObjectSnapshot { + return { ...object }; +} + +export function snapshotsEqual(left: unknown, right: unknown) { + return canonicalJson(left) === canonicalJson(right); +} + +function matchesRowPlanningValues( + row: CircuitDeviceRowSnapshot, + object: ExternalModelObjectSnapshot +) { + const planning = object.planningValues; + return ( + row.displayName === (planning.displayName ?? row.displayName) && + row.linkedProjectDeviceId === object.linkedProjectDeviceId && + row.category === planning.category && + row.connectionKind === planning.connectionKind && + (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && + row.simultaneityFactor === planning.simultaneityFactor && + row.cosPhi === planning.cosPhi && + row.costGroup === planning.costGroup && + row.remark === planning.remark + ); +} + +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; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/db/repositories/external-object-new-circuit-project-command.repository.ts b/src/db/repositories/external-object-new-circuit-project-command.repository.ts new file mode 100644 index 0000000..c25ac76 --- /dev/null +++ b/src/db/repositories/external-object-new-circuit-project-command.repository.ts @@ -0,0 +1,239 @@ +import { and, asc, eq } from "drizzle-orm"; +import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; +import { + assertExternalObjectNewCircuitProjectCommand, + externalObjectAssignToNewCircuitCommandType, + externalObjectDeleteCreatedCircuitCommandType, + invertExternalObjectNewCircuitProjectCommand, + type ExternalObjectNewCircuitProjectCommand, +} from "../../domain/models/external-object-new-circuit-project-command.model.js"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js"; +import type { ExternalObjectNewCircuitProjectCommandStore } from "../../domain/ports/external-object-new-circuit-project-command.store.js"; +import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js"; +import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { + assertCircuitDeviceRowReferencesInProject, + toCircuitDeviceRowInsertValues, + toCircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; +import { + applyExternalObjectLinks, + assertExternalObjectsCompatibleWithRow, + loadExpectedExternalObjectTransitions, + snapshotsEqual, +} from "./external-object-assignment.persistence.js"; + +export class ExternalObjectNewCircuitProjectCommandRepository + implements ExternalObjectNewCircuitProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalObjectNewCircuitProjectCommand(input.command); + if ( + input.source === "user" && + input.command.type === externalObjectDeleteCreatedCircuitCommandType + ) { + throw new Error("Created external circuits may only be removed through project history."); + } + return executeProjectCommandTransaction(this.database, input, (tx) => { + if (input.command.type === externalObjectAssignToNewCircuitCommandType) { + this.insert(tx, input.projectId, input.command); + } else { + this.remove(tx, input.projectId, input.command); + } + return invertExternalObjectNewCircuitProjectCommand(input.command); + }); + } + + private insert( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewCircuitProjectCommand + ) { + const circuit = command.payload.circuit; + const row = circuit.deviceRows[0]!; + const context = this.loadSectionContext( + database, + projectId, + circuit.circuitListId, + circuit.sectionId + ); + const parsedIdentifier = parseGroupedEquipmentIdentifier(circuit.equipmentIdentifier); + if ( + !parsedIdentifier || + parsedIdentifier.kind !== "circuit" || + parsedIdentifier.category !== context.category || + parsedIdentifier.groupNumber !== context.groupNumber + ) { + throw new Error("External circuit equipment identifier does not match target group."); + } + if (row.category !== context.category || !isElectricalPhaseType(row.phaseType)) { + throw new Error("External device row does not match target circuit group."); + } + assertCircuitDeviceRowReferencesInProject(database, projectId, row); + const voltage = resolveCircuitVoltage(database, projectId, circuit.sectionId, [row.phaseType]); + if (circuit.voltage !== voltage) { + throw new Error("External circuit voltage must match the project phase voltage."); + } + if (database.select({ id: circuits.id }).from(circuits) + .where(eq(circuits.id, circuit.id)).get()) { + throw new Error("External circuit id already exists."); + } + if (database.select({ id: circuits.id }).from(circuits).where(and( + eq(circuits.circuitListId, circuit.circuitListId), + eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier) + )).get()) { + throw new Error("Duplicate equipmentIdentifier in circuit list."); + } + if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, row.id)).get()) { + throw new Error("External device-row id already exists."); + } + + const objects = this.loadExpectedObjects(database, projectId, command); + assertExternalObjectsCompatibleWithRow({ + database, + projectId, + row, + distributionBoardId: context.distributionBoardId, + category: context.category, + assignedObjects: objects.map(({ target }) => target), + allLinkedObjects: objects.map(({ target }) => target), + confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds), + }); + assertCircuitDeviceRowQuantity({ + quantity: row.quantity, + manualQuantity: 0, + externalObjects: objects.map(({ target }) => ({ + effectiveQuantity: target.planningValues.effectiveQuantity, + })), + }); + + database.insert(circuits).values(toCircuitValues(circuit)).run(); + database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run(); + database.insert(circuitProtectionDevices).values(circuit.protectionDevice!).run(); + applyExternalObjectLinks( + database, + objects, + "External object changed during new-circuit assignment." + ); + } + + private remove( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewCircuitProjectCommand + ) { + const expectedCircuit = command.payload.circuit; + this.loadSectionContext( + database, + projectId, + expectedCircuit.circuitListId, + expectedCircuit.sectionId + ); + const current = this.loadCircuitSnapshot(database, expectedCircuit.id); + if (!current || !snapshotsEqual(current, expectedCircuit)) { + throw new Error("Created external circuit changed before history removal."); + } + const objects = this.loadExpectedObjects(database, projectId, command); + const rowId = expectedCircuit.deviceRows[0]!.id; + const linkedObjects = database.select({ id: externalModelObjects.id }) + .from(externalModelObjects) + .where(eq(externalModelObjects.circuitDeviceRowId, rowId)).all(); + if ( + linkedObjects.length !== objects.length || + linkedObjects.some(({ id }) => !objects.some(({ expected }) => expected.id === id)) + ) { + throw new Error("Created external circuit has different object links."); + } + applyExternalObjectLinks( + database, + objects, + "External object changed during new-circuit assignment." + ); + const deleted = database.delete(circuits).where(and( + eq(circuits.id, expectedCircuit.id), + eq(circuits.circuitListId, expectedCircuit.circuitListId) + )).run(); + if (deleted.changes !== 1) { + throw new Error("Created external circuit changed during history removal."); + } + } + + private loadSectionContext( + database: AppDatabase, + projectId: string, + circuitListId: string, + sectionId: string + ) { + const context = database.select({ + projectId: circuitLists.projectId, + distributionBoardId: circuitLists.distributionBoardId, + category: circuitSections.category, + groupNumber: circuitSections.groupNumber, + }).from(circuitSections) + .innerJoin(circuitLists, eq(circuitLists.id, circuitSections.circuitListId)) + .where(and( + eq(circuitSections.id, sectionId), + eq(circuitSections.circuitListId, circuitListId) + )).get(); + if ( + !context || + context.projectId !== projectId || + context.category === null || + context.groupNumber === null + ) { + throw new Error("Target circuit group does not belong to the project."); + } + return { + distributionBoardId: context.distributionBoardId, + category: context.category, + groupNumber: context.groupNumber, + }; + } + + private loadExpectedObjects( + database: AppDatabase, + projectId: string, + command: ExternalObjectNewCircuitProjectCommand + ) { + return loadExpectedExternalObjectTransitions( + database, + projectId, + command.payload.objects, + "External object changed before new-circuit assignment." + ); + } + + private loadCircuitSnapshot(database: AppDatabase, circuitId: string): CircuitSnapshot | null { + const circuit = database.select().from(circuits).where(eq(circuits.id, circuitId)).get(); + if (!circuit) return null; + const rows = database.select().from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)) + .orderBy(asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id)).all(); + const protection = database.select().from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuitId)).get(); + return { + ...circuit, + isReserve: Boolean(circuit.isReserve), + deviceRows: rows.map(toCircuitDeviceRowSnapshot), + protectionDevice: protection ?? null, + }; + } +} + +function toCircuitValues(circuit: CircuitSnapshot) { + const { deviceRows: _deviceRows, protectionDevice: _protectionDevice, ...values } = circuit; + return { ...values, isReserve: circuit.isReserve ? 1 : 0 }; +} diff --git a/src/db/repositories/external-object-new-row-project-command.repository.ts b/src/db/repositories/external-object-new-row-project-command.repository.ts index d2db69c..27146b8 100644 --- a/src/db/repositories/external-object-new-row-project-command.repository.ts +++ b/src/db/repositories/external-object-new-row-project-command.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; import { assertExternalObjectNewRowProjectCommand, @@ -7,17 +7,14 @@ import { invertExternalObjectNewRowProjectCommand, type ExternalObjectNewRowProjectCommand, } from "../../domain/models/external-object-new-row-project-command.model.js"; -import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js"; import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js"; -import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; -import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { assertCircuitDeviceRowReferencesInProject, toCircuitDeviceRowInsertValues, @@ -25,6 +22,12 @@ import { } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; +import { + applyExternalObjectLinks, + assertExternalObjectsCompatibleWithRow, + loadExpectedExternalObjectTransitions, + snapshotsEqual, +} from "./external-object-assignment.persistence.js"; export class ExternalObjectNewRowProjectCommandRepository implements ExternalObjectNewRowProjectCommandStore @@ -68,14 +71,16 @@ export class ExternalObjectNewRowProjectCommandRepository throw new Error("External device-row id already exists."); } const objects = this.loadExpectedObjects(database, projectId, command); - this.assertTargetCompatibility( + assertExternalObjectsCompatibleWithRow({ database, + projectId, row, - context.distributionBoardId, - context.category, - objects.map((object) => object.target), - new Set(command.payload.confirmedConflictObjectIds) - ); + distributionBoardId: context.distributionBoardId, + category: context.category, + assignedObjects: objects.map(({ target }) => target), + allLinkedObjects: objects.map(({ target }) => target), + confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds), + }); assertCircuitDeviceRowQuantity({ quantity: row.quantity, manualQuantity: 0, @@ -86,7 +91,11 @@ export class ExternalObjectNewRowProjectCommandRepository database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run(); this.updateCircuitState(database, projectId, row.circuitId); - this.applyObjectLinks(database, objects); + applyExternalObjectLinks( + database, + objects, + "External object changed during new-row assignment." + ); } private remove( @@ -98,7 +107,7 @@ export class ExternalObjectNewRowProjectCommandRepository this.loadCircuitContext(database, projectId, expectedRow.circuitId); const currentRow = database.select().from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, expectedRow.id)).get(); - if (!currentRow || !same(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) { + if (!currentRow || !snapshotsEqual(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) { throw new Error("Created external device row changed before history removal."); } const objects = this.loadExpectedObjects(database, projectId, command); @@ -111,7 +120,11 @@ export class ExternalObjectNewRowProjectCommandRepository ) { throw new Error("Created external device row has different object links."); } - this.applyObjectLinks(database, objects); + applyExternalObjectLinks( + database, + objects, + "External object changed during new-row assignment." + ); const deleted = database.delete(circuitDeviceRows) .where(eq(circuitDeviceRows.id, expectedRow.id)).run(); if (deleted.changes !== 1) { @@ -144,76 +157,12 @@ export class ExternalObjectNewRowProjectCommandRepository projectId: string, command: ExternalObjectNewRowProjectCommand ) { - const transitions = new Map( - command.payload.objects.map((transition) => [transition.expected.id, transition]) + return loadExpectedExternalObjectTransitions( + database, + projectId, + command.payload.objects, + "External object changed before new-row assignment." ); - const current = database.select().from(externalModelObjects) - .where(inArray(externalModelObjects.id, [...transitions.keys()])).all(); - if (current.length !== transitions.size) { - throw new Error("One or more external objects no longer exist."); - } - for (const object of current) { - const expected = transitions.get(object.id)!.expected; - if (object.projectId !== projectId || !same(toExternalObjectSnapshot(object), expected)) { - throw new Error("External object changed before new-row assignment."); - } - } - return command.payload.objects; - } - - private assertTargetCompatibility( - database: AppDatabase, - row: CircuitDeviceRowSnapshot, - distributionBoardId: string, - category: string, - objects: ExternalModelObjectSnapshot[], - confirmedConflicts: Set - ) { - const roomMappings = database.select().from(externalRoomMappings) - .where(eq(externalRoomMappings.projectId, objects[0]!.projectId)).all(); - const roomIdByMappingId = new Map(roomMappings.map((mapping) => [mapping.id, mapping.roomId])); - const selectionMarkers = new Set( - objects.map((object) => object.acceptedSourceValues.selectionMarker.trim()) - ); - if (selectionMarkers.size > 1) { - throw new Error("External objects with different selection markers require separate rows."); - } - for (const object of objects) { - if (object.distributionBoardId !== distributionBoardId) { - throw new Error("External object distribution does not match target circuit."); - } - if (object.planningValues.category !== category) { - throw new Error("External object category does not match target circuit group."); - } - const objectRoomId = object.externalRoomMappingId === null - ? null - : roomIdByMappingId.get(object.externalRoomMappingId) ?? null; - if (objectRoomId !== row.roomId) { - throw new Error("External object room does not match the new device row."); - } - if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) { - throw new Error("External object planning values require explicit conflict confirmation."); - } - } - } - - private applyObjectLinks( - database: AppDatabase, - objects: ExternalObjectNewRowProjectCommand["payload"]["objects"] - ) { - for (const { expected, target } of objects) { - const result = database.update(externalModelObjects) - .set({ circuitDeviceRowId: target.circuitDeviceRowId }) - .where(and( - eq(externalModelObjects.id, expected.id), - expected.circuitDeviceRowId === null - ? isNull(externalModelObjects.circuitDeviceRowId) - : eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId) - )).run(); - if (result.changes !== 1) { - throw new Error("External object changed during new-row assignment."); - } - } } private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) { @@ -225,42 +174,3 @@ export class ExternalObjectNewRowProjectCommandRepository updateDerivedCircuitVoltage(database, projectId, circuitId); } } - -function matchesRowPlanningValues( - row: CircuitDeviceRowSnapshot, - object: ExternalModelObjectSnapshot -) { - const planning = object.planningValues; - return ( - row.displayName === (planning.displayName ?? row.displayName) && - row.linkedProjectDeviceId === object.linkedProjectDeviceId && - row.category === planning.category && - row.connectionKind === planning.connectionKind && - (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && - row.simultaneityFactor === planning.simultaneityFactor && - row.cosPhi === planning.cosPhi && - row.costGroup === planning.costGroup && - row.remark === planning.remark - ); -} - -function toExternalObjectSnapshot( - object: typeof externalModelObjects.$inferSelect -): ExternalModelObjectSnapshot { - return { ...object }; -} - -function same(left: unknown, right: unknown) { - 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; - return `{${Object.keys(record).sort().map((key) => - `${JSON.stringify(key)}:${canonicalJson(record[key])}` - ).join(",")}}`; - } - return JSON.stringify(value); -} diff --git a/src/db/repositories/external-object-row-assignment-project-command.repository.ts b/src/db/repositories/external-object-row-assignment-project-command.repository.ts index dcf33b1..b6e8123 100644 --- a/src/db/repositories/external-object-row-assignment-project-command.repository.ts +++ b/src/db/repositories/external-object-row-assignment-project-command.repository.ts @@ -6,18 +6,22 @@ import { assertExternalObjectRowAssignmentProjectCommand, invertExternalObjectRowAssignmentProjectCommand, } from "../../domain/models/external-object-row-assignment-project-command.model.js"; -import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalObjectRowAssignmentProjectCommandStore } from "../../domain/ports/external-object-row-assignment-project-command.store.js"; -import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; -import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { + applyExternalObjectLinks, + assertExternalObjectsCompatibleWithRow, + loadExpectedExternalObjectTransitions, + snapshotsEqual, + toExternalObjectSnapshot, +} from "./external-object-assignment.persistence.js"; export class ExternalObjectRowAssignmentProjectCommandRepository implements ExternalObjectRowAssignmentProjectCommandStore @@ -44,29 +48,23 @@ export class ExternalObjectRowAssignmentProjectCommandRepository command.payload.objects.map((transition) => [transition.expected.id, transition]) ); const rowIds = [...rowTransitions.keys()]; - const objectIds = [...objectTransitions.keys()]; const currentRows = database.select().from(circuitDeviceRows) .where(inArray(circuitDeviceRows.id, rowIds)).all(); - const currentObjects = database.select().from(externalModelObjects) - .where(inArray(externalModelObjects.id, objectIds)).all(); - if (currentRows.length !== rowIds.length || currentObjects.length !== objectIds.length) { + if (currentRows.length !== rowIds.length) { throw new Error("External object assignment target no longer exists."); } for (const row of currentRows) { const expected = rowTransitions.get(row.id)!.expected; - if (!same(toCircuitDeviceRowSnapshot(row), expected)) { + if (!snapshotsEqual(toCircuitDeviceRowSnapshot(row), expected)) { throw new Error("Circuit device row changed before external object assignment."); } } - for (const object of currentObjects) { - const expected = objectTransitions.get(object.id)!.expected; - if (!same(toExternalObjectSnapshot(object), expected)) { - throw new Error("External object changed before row assignment."); - } - if (object.projectId !== projectId) { - throw new Error("External object belongs to another project."); - } - } + loadExpectedExternalObjectTransitions( + database, + projectId, + command.payload.objects, + "External object changed before row assignment." + ); const rowContexts = database .select({ @@ -83,7 +81,9 @@ export class ExternalObjectRowAssignmentProjectCommandRepository .all(); if ( rowContexts.length !== rowIds.length || - rowContexts.some((context) => context.projectId !== projectId) + rowContexts.some((context) => + context.projectId !== projectId || context.category === null + ) ) { throw new Error("Circuit device row belongs to another project."); } @@ -95,47 +95,26 @@ export class ExternalObjectRowAssignmentProjectCommandRepository const targetObjects = allProjectObjects.map((object) => objectTransitions.get(object.id)?.target ?? object ); - const mappings = database.select().from(externalRoomMappings) - .where(eq(externalRoomMappings.projectId, projectId)).all(); - const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId])); const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds); - for (const transition of command.payload.objects) { - const object = transition.target; - if (object.circuitDeviceRowId === null) continue; - const row = rowTransitions.get(object.circuitDeviceRowId)?.target; - const context = contextByRowId.get(object.circuitDeviceRowId); - if (!row || !context) throw new Error("External object target row is incomplete."); - if ( - object.distributionBoardId === null || - object.distributionBoardId !== context.distributionBoardId - ) { - throw new Error("External object distribution does not match target row."); - } - if (object.planningValues.category !== context.category) { - throw new Error("External object category does not match target circuit group."); - } - const objectRoomId = object.externalRoomMappingId === null - ? null - : roomIdByMappingId.get(object.externalRoomMappingId) ?? null; - if (objectRoomId !== row.roomId) { - throw new Error("External object room does not match target row."); - } - if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) { - throw new Error("External object planning values require explicit conflict confirmation."); - } - } - for (const transition of command.payload.rows) { const linkedObjects = targetObjects.filter( (object) => object.circuitDeviceRowId === transition.target.id ); - const selectionMarkers = new Set( - linkedObjects.map((object) => object.acceptedSourceValues.selectionMarker.trim()) - ); - if (selectionMarkers.size > 1) { - throw new Error("External objects with different selection markers require separate rows."); - } + const assignedObjects = command.payload.objects + .map(({ target }) => target) + .filter((object) => object.circuitDeviceRowId === transition.target.id); + const context = contextByRowId.get(transition.target.id)!; + assertExternalObjectsCompatibleWithRow({ + database, + projectId, + row: transition.target, + distributionBoardId: context.distributionBoardId, + category: context.category!, + assignedObjects, + allLinkedObjects: linkedObjects, + confirmedConflictObjectIds: confirmedConflicts, + }); assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, @@ -155,50 +134,10 @@ export class ExternalObjectRowAssignmentProjectCommandRepository )).run(); if (result.changes !== 1) throw new Error("Circuit device row changed during assignment."); } - for (const transition of command.payload.objects) { - const result = database.update(externalModelObjects) - .set({ circuitDeviceRowId: transition.target.circuitDeviceRowId }) - .where(eq(externalModelObjects.id, transition.expected.id)).run(); - if (result.changes !== 1) throw new Error("External object changed during assignment."); - } + applyExternalObjectLinks( + database, + command.payload.objects, + "External object changed during assignment." + ); } } - -function matchesRowPlanningValues( - row: CircuitDeviceRowSnapshot, - object: ExternalModelObjectSnapshot -) { - const planning = object.planningValues; - return ( - row.displayName === (planning.displayName ?? row.displayName) && - row.linkedProjectDeviceId === object.linkedProjectDeviceId && - row.category === planning.category && - row.connectionKind === planning.connectionKind && - (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && - row.simultaneityFactor === planning.simultaneityFactor && - row.cosPhi === planning.cosPhi && - row.costGroup === planning.costGroup && - row.remark === planning.remark - ); -} - -function toExternalObjectSnapshot( - object: typeof externalModelObjects.$inferSelect -): ExternalModelObjectSnapshot { - return { ...object }; -} - -function same(left: unknown, right: unknown) { - 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; - return `{${Object.keys(record).sort().map((key) => - `${JSON.stringify(key)}:${canonicalJson(record[key])}` - ).join(",")}}`; - } - return JSON.stringify(value); -} diff --git a/src/domain/models/external-object-new-circuit-project-command.model.ts b/src/domain/models/external-object-new-circuit-project-command.model.ts new file mode 100644 index 0000000..b34a449 --- /dev/null +++ b/src/domain/models/external-object-new-circuit-project-command.model.ts @@ -0,0 +1,168 @@ +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { + assertCircuitInsertProjectCommand, + circuitInsertCommandType, + circuitStructureCommandSchemaVersion, + type CircuitSnapshot, +} from "./circuit-structure-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; + +export const externalObjectAssignToNewCircuitCommandType = + "external-object.assign-to-new-circuit" as const; +export const externalObjectDeleteCreatedCircuitCommandType = + "external-object.unassign-and-delete-created-circuit" as const; +export const externalObjectNewCircuitCommandSchemaVersion = 1 as const; + +export interface ExternalObjectNewCircuitPayload { + circuit: CircuitSnapshot; + objects: Array<{ + expected: ExternalModelObjectSnapshot; + target: ExternalModelObjectSnapshot; + }>; + confirmedConflictObjectIds: string[]; +} + +export interface ExternalObjectNewCircuitProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalObjectNewCircuitCommandSchemaVersion; + type: + | typeof externalObjectAssignToNewCircuitCommandType + | typeof externalObjectDeleteCreatedCircuitCommandType; +} + +export function createExternalObjectNewCircuitProjectCommand( + type: ExternalObjectNewCircuitProjectCommand["type"], + payload: ExternalObjectNewCircuitPayload +): ExternalObjectNewCircuitProjectCommand { + const row = payload.circuit.deviceRows[0]; + const command: ExternalObjectNewCircuitProjectCommand = { + schemaVersion: externalObjectNewCircuitCommandSchemaVersion, + type, + payload: { + circuit: row + ? { + ...payload.circuit, + deviceRows: [{ + ...row, + manualQuantity: row.manualQuantity ?? row.quantity, + }], + } + : payload.circuit, + objects: payload.objects, + confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds], + }, + }; + assertExternalObjectNewCircuitProjectCommand(command); + return command; +} + +export function invertExternalObjectNewCircuitProjectCommand( + command: ExternalObjectNewCircuitProjectCommand +) { + return createExternalObjectNewCircuitProjectCommand( + command.type === externalObjectAssignToNewCircuitCommandType + ? externalObjectDeleteCreatedCircuitCommandType + : externalObjectAssignToNewCircuitCommandType, + { + circuit: command.payload.circuit, + objects: command.payload.objects.map(({ expected, target }) => ({ + expected: target, + target: expected, + })), + confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds, + } + ); +} + +export function assertExternalObjectNewCircuitProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalObjectNewCircuitProjectCommand { + if ( + command.schemaVersion !== externalObjectNewCircuitCommandSchemaVersion || + (command.type !== externalObjectAssignToNewCircuitCommandType && + command.type !== externalObjectDeleteCreatedCircuitCommandType) || + !isRecord(command.payload) || + !Array.isArray(command.payload.objects) || + command.payload.objects.length === 0 || + !Array.isArray(command.payload.confirmedConflictObjectIds) + ) { + throw new Error("Unsupported external object new-circuit command."); + } + assertCircuitInsertProjectCommand({ + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitInsertCommandType, + payload: { circuit: command.payload.circuit }, + }); + const circuit = command.payload.circuit as CircuitSnapshot; + if ( + circuit.deviceRows.length !== 1 || + circuit.deviceRows[0]!.manualQuantity !== 0 || + circuit.protectionDevice === undefined || + circuit.protectionDevice === null + ) { + throw new Error( + "An external circuit must contain one external-only row and protection device." + ); + } + const rowId = circuit.deviceRows[0]!.id; + const objectIds = new Set(); + for (const transition of command.payload.objects) { + if (!isRecord(transition)) throw new Error("Invalid external object transition."); + const expected = parseObject(transition.expected); + const target = parseObject(transition.target); + if (expected.id !== target.id || objectIds.has(expected.id)) { + throw new Error("New-circuit assignment contains mismatched or duplicate objects."); + } + objectIds.add(expected.id); + const assigning = command.type === externalObjectAssignToNewCircuitCommandType; + if ( + (assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== rowId)) || + (!assigning && (expected.circuitDeviceRowId !== rowId || target.circuitDeviceRowId !== null)) + ) { + throw new Error("External object links do not match the new-circuit action."); + } + assertOnlyAssignmentChanged(expected, target); + } + const confirmedIds = new Set(); + for (const objectId of command.payload.confirmedConflictObjectIds) { + if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) { + throw new Error("New-circuit assignment contains an invalid conflict confirmation."); + } + confirmedIds.add(objectId); + } +} + +function parseObject(value: unknown): ExternalModelObjectSnapshot { + return parseExternalModelStateSnapshot({ + source: null, + importBatches: [], + roomMappings: [], + objects: [value], + }).objects[0]!; +} + +function assertOnlyAssignmentChanged( + expected: ExternalModelObjectSnapshot, + target: ExternalModelObjectSnapshot +) { + const expectedRest = { ...expected, circuitDeviceRowId: null }; + const targetRest = { ...target, circuitDeviceRowId: null }; + if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { + throw new Error("New-circuit assignment may only change the external object row link."); + } +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + ).join(",")}}`; + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/external-object-new-circuit-project-command.store.ts b/src/domain/ports/external-object-new-circuit-project-command.store.ts new file mode 100644 index 0000000..efd13ba --- /dev/null +++ b/src/domain/ports/external-object-new-circuit-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalObjectNewCircuitProjectCommand } from "../models/external-object-new-circuit-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalObjectNewCircuitCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalObjectNewCircuitProjectCommand; +} + +export interface ExternalObjectNewCircuitProjectCommandStore { + execute(input: ExecuteExternalObjectNewCircuitCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalObjectNewCircuitProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index be66428..bee31a3 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -182,6 +182,12 @@ import { externalObjectDeleteCreatedRowCommandType, } from "../models/external-object-new-row-project-command.model.js"; import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js"; +import { + assertExternalObjectNewCircuitProjectCommand, + externalObjectAssignToNewCircuitCommandType, + externalObjectDeleteCreatedCircuitCommandType, +} from "../models/external-object-new-circuit-project-command.model.js"; +import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -220,7 +226,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, - private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore + private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, + private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore ) {} executeUser( @@ -618,6 +625,17 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalObjectAssignToNewCircuitCommandType: + case externalObjectDeleteCreatedCircuitCommandType: { + assertExternalObjectNewCircuitProjectCommand(input.command); + if (!this.externalObjectNewCircuitStore) { + throw new Error("External object new-circuit store is not available."); + } + return this.externalObjectNewCircuitStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 9e63ed6..79a4a9f 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -26,6 +26,7 @@ import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repos import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-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"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -77,6 +78,8 @@ export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = new ExternalObjectNewRowProjectCommandRepository(db); +export const externalObjectNewCircuitProjectCommandStore = + new ExternalObjectNewCircuitProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -103,5 +106,6 @@ export const projectCommandService = new ProjectCommandService( externalCsvConfigurationProjectCommandStore, externalInitialImportProjectCommandStore, externalObjectRowAssignmentProjectCommandStore, - externalObjectNewRowProjectCommandStore + externalObjectNewRowProjectCommandStore, + externalObjectNewCircuitProjectCommandStore ); diff --git a/tests/external-object-row-assignment-project-command.repository.test.ts b/tests/external-object-row-assignment-project-command.repository.test.ts index 4e127f1..da8551e 100644 --- a/tests/external-object-row-assignment-project-command.repository.test.ts +++ b/tests/external-object-row-assignment-project-command.repository.test.ts @@ -6,11 +6,13 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js"; +import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; @@ -24,6 +26,10 @@ import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType, } from "../src/domain/models/external-object-new-row-project-command.model.js"; +import { + createExternalObjectNewCircuitProjectCommand, + externalObjectAssignToNewCircuitCommandType, +} from "../src/domain/models/external-object-new-circuit-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; @@ -376,3 +382,192 @@ describe("external object new-row project command", () => { } }); }); + +function newExternalCircuit(database: AppDatabase) { + const sourceRow = row(database, "row-2"); + return { + id: "circuit-new", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-2F1.3", + displayName: "Steckdose", + sortOrder: 30, + cableType: null, + cableCrossSection: null, + cableLength: null, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: false, + remark: null, + deviceRows: [{ + ...sourceRow, + id: "row-new", + circuitId: "circuit-new", + quantity: 2, + manualQuantity: 0, + }], + protectionDevice: { + circuitId: "circuit-new", + type: "FI_LS" as const, + ratedCurrentA: 16, + fuseUtilizationCategory: null, + tripCharacteristic: "B" as const, + rcdType: "A" as const, + ratedResidualCurrentMa: 30, + }, + }; +} + +describe("external object new-circuit project command", () => { + it("creates circuit, protection, row and links as one undoable revision", () => { + const context = createFixture(null); + try { + const object = objectSnapshot(null); + const circuit = newExternalCircuit(context.db); + const command = createExternalObjectNewCircuitProjectCommand( + externalObjectAssignToNewCircuitCommandType, + { + circuit, + objects: [{ + expected: object, + target: { ...object, circuitDeviceRowId: "row-new" }, + }], + confirmedConflictObjectIds: [], + } + ); + const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal( + context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()! + .equipmentIdentifier, + "-2F1.3" + ); + assert.equal(row(context.db, "row-new").manualQuantity, 0); + assert.equal( + context.db.select().from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type, + "FI_LS" + ); + assert.equal( + context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, + "row-new" + ); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: inserted.inverse, + }), + /only be removed through project history/ + ); + + const undone = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(), + undefined + ); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: inserted.revision.changeSetId, + command: undone.inverse, + }); + assert.equal( + context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()! + .equipmentIdentifier, + "-2F1.3" + ); + } finally { + context.close(); + } + }); + + it("rejects a preplanned BMK that does not belong to the target group", () => { + const context = createFixture(null); + try { + const object = objectSnapshot(null); + const circuit = { + ...newExternalCircuit(context.db), + equipmentIdentifier: "-1F1.3", + }; + assert.throws( + () => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalObjectNewCircuitProjectCommand( + externalObjectAssignToNewCircuitCommandType, + { + circuit, + objects: [{ + expected: object, + target: { ...object, circuitDeviceRowId: "row-new" }, + }], + confirmedConflictObjectIds: [], + } + ), + }), + /equipment identifier does not match target group/ + ); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + } finally { + context.close(); + } + }); + + it("rolls back the complete circuit subtree when history persistence fails", () => { + const context = createFixture(null); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_new_circuit_history + BEFORE INSERT ON project_change_sets + BEGIN SELECT RAISE(ABORT, 'late history failure'); END; + `); + const object = objectSnapshot(null); + assert.throws( + () => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalObjectNewCircuitProjectCommand( + externalObjectAssignToNewCircuitCommandType, + { + circuit: newExternalCircuit(context.db), + objects: [{ + expected: object, + target: { ...object, circuitDeviceRowId: "row-new" }, + }], + confirmedConflictObjectIds: [], + } + ), + }), + /late history failure/ + ); + assert.equal( + context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(), + undefined + ); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + } finally { + context.close(); + } + }); +});