From 1a7dd7fe0350d1f125fc1ac6f0ce7ec8854ce8e7 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 18:38:38 +0200 Subject: [PATCH] Add external object row assignment command --- AGENTS.md | 10 +- docs/circuit-list-editor-known-limitations.md | 6 +- docs/current-architecture.md | 14 +- .../spec/revit-csv-phase-14-audit-and-plan.md | 15 +- ...w-assignment-project-command.repository.ts | 204 ++++++++++++++++ ...ct-row-assignment-project-command.model.ts | 186 ++++++++++++++ ...ct-row-assignment-project-command.store.ts | 19 ++ .../services/project-command.service.ts | 18 +- .../composition/project-command-stores.ts | 6 +- ...ignment-project-command.repository.test.ts | 227 ++++++++++++++++++ 10 files changed, 693 insertions(+), 12 deletions(-) create mode 100644 src/db/repositories/external-object-row-assignment-project-command.repository.ts create mode 100644 src/domain/models/external-object-row-assignment-project-command.model.ts create mode 100644 src/domain/ports/external-object-row-assignment-project-command.store.ts create mode 100644 tests/external-object-row-assignment-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 054b9c9..4b13c4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,9 +287,13 @@ version introduced by additive migration `0004`; it is distinct from the CSV configuration JSON schema version. Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills existing rows from `quantity` and keeps `quantity` as the materialized total. -Normal manual row inserts and quantity edits update both values together. Future -external-object assignment commands must set the total to `manualQuantity` plus -the sum of linked objects' effective quantities in the same transaction. +Normal manual row inserts and quantity edits update both values together. +`external-object.update-row-assignment` atomically assigns external objects to +existing rows, moves them between existing rows or detaches them. It preserves +the manual quantity, validates the materialized total against every target +link, rejects project/board/category/room/selection-marker mixing and requires +explicitly listed confirmations for other planning-value conflicts. Its exact +inverse supports persistent Undo/Redo, and detach never deletes the row. 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 52949b1..0c0ec21 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,8 +9,10 @@ - Named and automatic logical project snapshots can be listed and restored 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. Row - assignment, follow-up import, conflict review and return export are pending. +- The Revit/CSV foundation and confirmed initial import are implemented. + Existing-row assignment, move and detach have an atomic persistent command; + their editor UI, new-row/new-circuit paths, 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 f55baa7..86b11f4 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -540,6 +540,17 @@ weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln. +`external-object.update-row-assignment` ist diese atomare Schreibgrenze für +bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt +sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden +die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit, +Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft. +Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung; +unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht +still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem +unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die +Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach +Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen. `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. @@ -587,7 +598,8 @@ ihn für die betroffene Gruppe. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- Folgeimport, Editor-Zuordnung und strukturtreuer Revit-Rückexport +- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und + strukturtreuer Revit-Rückexport - vollständige elektrische Dimensionierung - Produktionsdeployment 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 7fb4f88..f193d29 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -202,15 +202,14 @@ bleiben und dürfen spätere Quellstände nicht erneut auswerten. oder mehrerer Objekte ändern. - `external-model-object.update-project-device-link`: ProjectDevice-Links explizit setzen oder lösen, ohne das ProjectDevice zu verändern. -- `external-model-object.assign-to-row`: Objekte einer bestehenden Row - zuweisen und deren materialisierte Menge atomar aktualisieren. +- `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, Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren. - `external-model-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.unassign-from-row`: einzelne Objektlinks lösen und - die Menge exakt aktualisieren. - `external-model-object.delete-missing`: nur ausdrücklich bestätigte, weiterhin unveränderte fehlende Objekte löschen. @@ -319,6 +318,14 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert als manuellen Anteil plus Summe der unteilbaren externen Objektmengen. 2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen. + **Teilweise erledigt:** `external-object.update-row-assignment` deckt + Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und + Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum, + Selektionskennzeichen und Planungswerte, verlangt für bestätigbare + Planungsabweichungen eine explizite Objektliste und aktualisiert Links und + Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist + dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen + Mengenanteil bestehen. Neue Row und neuer Circuit folgen separat. 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-row-assignment-project-command.repository.ts b/src/db/repositories/external-object-row-assignment-project-command.repository.ts new file mode 100644 index 0000000..dcf33b1 --- /dev/null +++ b/src/db/repositories/external-object-row-assignment-project-command.repository.ts @@ -0,0 +1,204 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { + assertCircuitDeviceRowQuantity, +} from "../../domain/calculations/circuit-device-row-quantity.js"; +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"; + +export class ExternalObjectRowAssignmentProjectCommandRepository + implements ExternalObjectRowAssignmentProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalObjectRowAssignmentProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + this.apply(tx, input.projectId, input.command); + return invertExternalObjectRowAssignmentProjectCommand(input.command); + }); + } + + private apply( + database: AppDatabase, + projectId: string, + command: Parameters[0]["command"] + ) { + const rowTransitions = new Map( + command.payload.rows.map((transition) => [transition.expected.id, transition]) + ); + const objectTransitions = new Map( + 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) { + 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)) { + 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."); + } + } + + const rowContexts = database + .select({ + rowId: circuitDeviceRows.id, + projectId: circuitLists.projectId, + distributionBoardId: circuitLists.distributionBoardId, + category: circuitSections.category, + }) + .from(circuitDeviceRows) + .innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId)) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if ( + rowContexts.length !== rowIds.length || + rowContexts.some((context) => context.projectId !== projectId) + ) { + throw new Error("Circuit device row belongs to another project."); + } + const contextByRowId = new Map(rowContexts.map((context) => [context.rowId, context])); + + const allProjectObjects = database.select().from(externalModelObjects) + .where(eq(externalModelObjects.projectId, projectId)).all() + .map(toExternalObjectSnapshot); + 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."); + } + assertCircuitDeviceRowQuantity({ + quantity: transition.target.quantity, + manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, + externalObjects: linkedObjects.map((object) => ({ + effectiveQuantity: object.planningValues.effectiveQuantity, + })), + }); + } + + for (const transition of command.payload.rows) { + const result = database.update(circuitDeviceRows) + .set({ quantity: transition.target.quantity }) + .where(and( + eq(circuitDeviceRows.id, transition.expected.id), + eq(circuitDeviceRows.quantity, transition.expected.quantity), + eq(circuitDeviceRows.manualQuantity, transition.expected.manualQuantity ?? transition.expected.quantity) + )).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."); + } + } +} + +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-row-assignment-project-command.model.ts b/src/domain/models/external-object-row-assignment-project-command.model.ts new file mode 100644 index 0000000..a45fe31 --- /dev/null +++ b/src/domain/models/external-object-row-assignment-project-command.model.ts @@ -0,0 +1,186 @@ +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowInsertCommandType, + circuitDeviceRowStructureCommandSchemaVersion, + type CircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; + +export const externalObjectRowAssignmentCommandType = + "external-object.update-row-assignment" as const; +export const externalObjectRowAssignmentCommandSchemaVersion = 1 as const; + +export interface ExternalObjectRowAssignmentPayload { + rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>; + objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>; + confirmedConflictObjectIds: string[]; +} + +export interface ExternalObjectRowAssignmentProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalObjectRowAssignmentCommandSchemaVersion; + type: typeof externalObjectRowAssignmentCommandType; +} + +export function createExternalObjectRowAssignmentProjectCommand( + payload: ExternalObjectRowAssignmentPayload +): ExternalObjectRowAssignmentProjectCommand { + const normalizedPayload: ExternalObjectRowAssignmentPayload = { + ...payload, + rows: payload.rows.map(({ expected, target }) => ({ + expected: normalizeRow(expected), + target: normalizeRow(target), + })), + confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds], + }; + const command: ExternalObjectRowAssignmentProjectCommand = { + schemaVersion: externalObjectRowAssignmentCommandSchemaVersion, + type: externalObjectRowAssignmentCommandType, + payload: normalizedPayload, + }; + assertExternalObjectRowAssignmentProjectCommand(command); + return command; +} + +export function invertExternalObjectRowAssignmentProjectCommand( + command: ExternalObjectRowAssignmentProjectCommand +) { + return createExternalObjectRowAssignmentProjectCommand({ + rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })), + objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })), + confirmedConflictObjectIds: [...command.payload.confirmedConflictObjectIds], + }); +} + +export function assertExternalObjectRowAssignmentProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalObjectRowAssignmentProjectCommand { + if ( + command.schemaVersion !== externalObjectRowAssignmentCommandSchemaVersion || + command.type !== externalObjectRowAssignmentCommandType || + !isRecord(command.payload) || + !Array.isArray(command.payload.rows) || + !Array.isArray(command.payload.objects) || + !Array.isArray(command.payload.confirmedConflictObjectIds) || + command.payload.rows.length === 0 || + command.payload.objects.length === 0 + ) { + throw new Error("Unsupported external object row-assignment command."); + } + const rowIds = new Set(); + for (const transition of command.payload.rows) { + if (!isRecord(transition)) throw new Error("Invalid row assignment transition."); + const expected = parseRow(transition.expected); + const target = parseRow(transition.target); + if (expected.manualQuantity === undefined || target.manualQuantity === undefined) { + throw new Error("Row assignment requires an explicit manual quantity."); + } + if (expected.id !== target.id || rowIds.has(expected.id)) { + throw new Error("Row assignment contains mismatched or duplicate rows."); + } + rowIds.add(expected.id); + if (expected.manualQuantity !== target.manualQuantity) { + throw new Error("Row assignment must preserve the manual quantity."); + } + assertOnlyFieldChanged( + expected as unknown as Record, + target as unknown as Record, + "quantity", + "row" + ); + } + const objectIds = new Set(); + const affectedRowIds = new Set(); + for (const transition of command.payload.objects) { + if (!isRecord(transition)) throw new Error("Invalid object assignment transition."); + const expected = parseObject(transition.expected); + const target = parseObject(transition.target); + if (expected.id !== target.id || objectIds.has(expected.id)) { + throw new Error("Row assignment contains mismatched or duplicate objects."); + } + objectIds.add(expected.id); + if (expected.circuitDeviceRowId === target.circuitDeviceRowId) { + throw new Error("External object row assignment must change its row link."); + } + assertOnlyFieldChanged( + expected as unknown as Record, + target as unknown as Record, + "circuitDeviceRowId", + "external object" + ); + if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId); + if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId); + } + const confirmedIds = new Set(); + for (const objectId of command.payload.confirmedConflictObjectIds) { + if ( + typeof objectId !== "string" || + !objectIds.has(objectId) || + confirmedIds.has(objectId) + ) { + throw new Error("Row assignment contains an invalid conflict confirmation."); + } + confirmedIds.add(objectId); + } + if ( + affectedRowIds.size !== rowIds.size || + [...affectedRowIds].some((rowId) => !rowIds.has(rowId)) + ) { + throw new Error("Row assignment must include every and only affected device row."); + } +} + +function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { + return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; +} + +function parseRow(value: unknown): CircuitDeviceRowSnapshot { + const envelope = { + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row: value }, + }; + assertCircuitDeviceRowInsertProjectCommand(envelope); + return envelope.payload.row; +} + +function parseObject(value: unknown): ExternalModelObjectSnapshot { + const state = parseExternalModelStateSnapshot({ + source: null, + importBatches: [], + roomMappings: [], + objects: [value], + }); + return state.objects[0]!; +} + +function assertOnlyFieldChanged( + expected: Record, + target: Record, + allowedField: string, + label: string +) { + const expectedRest = { ...expected }; + const targetRest = { ...target }; + delete expectedRest[allowedField]; + delete targetRest[allowedField]; + if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { + throw new Error(`Row assignment may only change the ${label} assignment state.`); + } +} + +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-row-assignment-project-command.store.ts b/src/domain/ports/external-object-row-assignment-project-command.store.ts new file mode 100644 index 0000000..98f2fca --- /dev/null +++ b/src/domain/ports/external-object-row-assignment-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalObjectRowAssignmentProjectCommand } from "../models/external-object-row-assignment-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalObjectRowAssignmentCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalObjectRowAssignmentProjectCommand; +} + +export interface ExternalObjectRowAssignmentProjectCommandStore { + execute(input: ExecuteExternalObjectRowAssignmentCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalObjectRowAssignmentProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index ef6a85a..2a90470 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -171,6 +171,11 @@ import { externalInitialImportCommandType, } from "../models/external-initial-import-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js"; +import { + assertExternalObjectRowAssignmentProjectCommand, + externalObjectRowAssignmentCommandType, +} from "../models/external-object-row-assignment-project-command.model.js"; +import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -207,7 +212,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly historyStore: ProjectHistoryStore, private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, - private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore + private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, + private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore ) {} executeUser( @@ -584,6 +590,16 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalObjectRowAssignmentCommandType: { + assertExternalObjectRowAssignmentProjectCommand(input.command); + if (!this.externalObjectRowAssignmentStore) { + throw new Error("External object row-assignment store is not available."); + } + return this.externalObjectRowAssignmentStore.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 129ea1c..9b6d539 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -24,6 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -71,6 +72,8 @@ export const externalCsvConfigurationProjectCommandStore = new ExternalCsvConfigurationProjectCommandRepository(db); export const externalInitialImportProjectCommandStore = new ExternalInitialImportProjectCommandRepository(db); +export const externalObjectRowAssignmentProjectCommandStore = + new ExternalObjectRowAssignmentProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -95,5 +98,6 @@ export const projectCommandService = new ProjectCommandService( circuitProtectionProjectCommandStore, projectHistoryStore, externalCsvConfigurationProjectCommandStore, - externalInitialImportProjectCommandStore + externalInitialImportProjectCommandStore, + externalObjectRowAssignmentProjectCommandStore ); diff --git a/tests/external-object-row-assignment-project-command.repository.test.ts b/tests/external-object-row-assignment-project-command.repository.test.ts new file mode 100644 index 0000000..5585204 --- /dev/null +++ b/tests/external-object-row-assignment-project-command.repository.test.ts @@ -0,0 +1,227 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +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 { 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 { 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"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; +import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; +import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; +import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; + +function createFixture() { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + const database = context.db; + database.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + database.insert(floors).values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10 }).run(); + database.insert(rooms).values({ id: "room-1", projectId: "project-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }).run(); + database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); + database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); + database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); + for (const index of [1, 2]) { + database.insert(circuits).values({ id: `circuit-${index}`, circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: `-2F1.${index}`, sortOrder: index * 10, voltage: 230 }).run(); + database.insert(circuitDeviceRows).values({ + id: `row-${index}`, + circuitId: `circuit-${index}`, + sortOrder: 10, + name: "socket", + displayName: "Steckdose", + phaseType: "single_phase", + connectionKind: "socket", + category: "single_phase", + roomId: "room-1", + roomNumberSnapshot: "101", + roomNameSnapshot: "Büro", + quantity: index === 1 ? 3 : 0, + manualQuantity: index === 1 ? 1 : 0, + powerPerUnit: 0.12, + simultaneityFactor: 1, + }).run(); + } + database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run(); + database.insert(externalImportBatches).values({ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T16:00:00.000Z", + fileName: "revit.csv", + sha256: "a".repeat(64), + appliedProjectRevision: 0, + configurationVersion: 1, + configurationSnapshot: externalCsvTestConfiguration, + originalBytes: Buffer.from("test"), + document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + }).run(); + database.insert(externalRoomMappings).values({ + id: "mapping-1", + projectId: "project-1", + sourceId: "source-1", + normalizedSourceRoomKey: "number:101", + sourceFloorName: "EG", + sourceRoomNumber: "101", + sourceRoomName: "Büro", + roomId: "room-1", + defaultDistributionBoardId: "board-1", + }).run(); + database.insert(externalModelObjects).values(objectSnapshot("row-1")).run(); + return context; +} + +function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot { + return { + id: "object-1", + projectId: "project-1", + sourceId: "source-1", + ifcGuid: "ifc-1", + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 2, + roomNumber: "101", + roomName: "Büro", + familyAndType: "Steckdose: Standard", + selectionMarker: "Steckdose", + circuitIdentifier: "-2F1.1", + power: "120", + quantity: "2", + additionalSourceValues: {}, + }, + planningValues: { + displayName: "Steckdose", + internalDeviceType: "socket", + category: "single_phase", + connectionKind: "socket", + effectiveQuantity: 2, + powerPerUnitW: 120, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + overriddenFields: [], + externalRoomMappingId: "mapping-1", + distributionBoardId: "board-1", + linkedProjectDeviceId: null, + circuitDeviceRowId, + presenceStatus: "present", + }; +} + +function row(database: AppDatabase, id: string) { + return toCircuitDeviceRowSnapshot( + database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, id)).get()! + ); +} + +function moveCommand(database: AppDatabase, confirmedConflictObjectIds: string[] = []) { + const row1 = row(database, "row-1"); + const row2 = row(database, "row-2"); + const object = objectSnapshot("row-1"); + return createExternalObjectRowAssignmentProjectCommand({ + rows: [ + { expected: row1, target: { ...row1, quantity: 1 } }, + { expected: row2, target: { ...row2, quantity: 2 } }, + ], + objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-2" } }], + confirmedConflictObjectIds, + }); +} + +describe("external object row assignment project command", () => { + it("moves an object atomically and preserves exact quantities through undo and redo", () => { + const context = createFixture(); + try { + const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); + const moved = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }); + assert.equal(row(context.db, "row-1").quantity, 1); + assert.equal(row(context.db, "row-2").quantity, 2); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); + + const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: moved.revision.changeSetId, command: moved.inverse }); + assert.equal(row(context.db, "row-1").quantity, 3); + assert.equal(row(context.db, "row-2").quantity, 0); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); + + repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: moved.revision.changeSetId, command: undone.inverse }); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); + } finally { + context.close(); + } + }); + + it("detaches the last object but keeps the now-manual row", () => { + const context = createFixture(); + try { + const currentRow = row(context.db, "row-1"); + const object = objectSnapshot("row-1"); + const command = createExternalObjectRowAssignmentProjectCommand({ + rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }], + objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }], + confirmedConflictObjectIds: [], + }); + new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal(row(context.db, "row-1").quantity, 1); + assert.equal(row(context.db, "row-1").manualQuantity, 1); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); + } finally { + context.close(); + } + }); + + it("requires explicit confirmation for differing planning values", () => { + const context = createFixture(); + try { + context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-2")).run(); + const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), + /explicit conflict confirmation/ + ); + repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db, ["object-1"]) }); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); + } finally { + context.close(); + } + }); + + it("rolls back row quantities and links when history persistence fails", () => { + const context = createFixture(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_assignment_history + BEFORE INSERT ON project_change_sets + BEGIN SELECT RAISE(ABORT, 'late history failure'); END; + `); + const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), + /late history failure/ + ); + assert.equal(row(context.db, "row-1").quantity, 3); + assert.equal(row(context.db, "row-2").quantity, 0); + assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); + } finally { + context.close(); + } + }); +});