Add external object row assignment command

This commit is contained in:
2026-08-02 18:38:38 +02:00
parent dac19b093c
commit 1a7dd7fe03
10 changed files with 693 additions and 12 deletions
+7 -3
View File
@@ -287,9 +287,13 @@ version introduced by additive migration `0004`; it is distinct from the CSV
configuration JSON schema version. configuration JSON schema version.
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
existing rows from `quantity` and keeps `quantity` as the materialized total. existing rows from `quantity` and keeps `quantity` as the materialized total.
Normal manual row inserts and quantity edits update both values together. Future Normal manual row inserts and quantity edits update both values together.
external-object assignment commands must set the total to `manualQuantity` plus `external-object.update-row-assignment` atomically assigns external objects to
the sum of linked objects' effective quantities in the same transaction. 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 Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version, `external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
@@ -9,8 +9,10 @@
- Named and automatic logical project snapshots can be listed and restored - Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained. created every 25 revisions and only their newest 12 entries are retained.
- The Revit/CSV foundation and confirmed initial import are implemented. Row - The Revit/CSV foundation and confirmed initial import are implemented.
assignment, follow-up import, conflict review and return export are pending. 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. - 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 - The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model. versioning, permissions or controlled synchronization model.
+13 -1
View File
@@ -540,6 +540,17 @@ weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln. 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 `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -587,7 +598,8 @@ ihn für die betroffene Gruppe.
## Noch nicht unterstützt ## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung - 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 - vollständige elektrische Dimensionierung
- Produktionsdeployment - Produktionsdeployment
+11 -4
View File
@@ -202,15 +202,14 @@ bleiben und dürfen spätere Quellstände nicht erneut auswerten.
oder mehrerer Objekte ändern. oder mehrerer Objekte ändern.
- `external-model-object.update-project-device-link`: ProjectDevice-Links - `external-model-object.update-project-device-link`: ProjectDevice-Links
explizit setzen oder lösen, ohne das ProjectDevice zu verändern. explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
- `external-model-object.assign-to-row`: Objekte einer bestehenden Row - `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
zuweisen und deren materialisierte Menge atomar aktualisieren. 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-model-object.assign-to-new-row`: stabile neue Row einfügen,
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren. Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
- `external-model-object.assign-to-new-circuit`: stabilen Circuit samt Schutz, - `external-model-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
Command gespeichert und bei Redo nicht neu berechnet. 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, - `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
weiterhin unveränderte fehlende Objekte löschen. 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 verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen. als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen. 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. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen. testen.
@@ -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<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[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<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[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<string, unknown>;
return `{${Object.keys(record).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
@@ -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<ExternalObjectRowAssignmentPayload> {
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<unknown>
): 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<string>();
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<string, unknown>,
target as unknown as Record<string, unknown>,
"quantity",
"row"
);
}
const objectIds = new Set<string>();
const affectedRowIds = new Set<string>();
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<string, unknown>,
target as unknown as Record<string, unknown>,
"circuitDeviceRowId",
"external object"
);
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId);
}
const confirmedIds = new Set<string>();
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<string, unknown>,
target: Record<string, unknown>,
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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;
};
}
+17 -1
View File
@@ -171,6 +171,11 @@ import {
externalInitialImportCommandType, externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js"; } from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.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 { interface DispatchProjectCommandInput {
projectId: string; projectId: string;
@@ -207,7 +212,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore, private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore
) {} ) {}
executeUser( executeUser(
@@ -584,6 +590,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command); assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({ return this.projectStateRestoreStore.execute({
@@ -24,6 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.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 { 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 circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore = export const circuitDeviceRowProjectCommandStore =
@@ -71,6 +72,8 @@ export const externalCsvConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db); new ExternalCsvConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore = export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db); new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService( export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore, circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore, circuitDeviceRowProjectCommandStore,
@@ -95,5 +98,6 @@ export const projectCommandService = new ProjectCommandService(
circuitProtectionProjectCommandStore, circuitProtectionProjectCommandStore,
projectHistoryStore, projectHistoryStore,
externalCsvConfigurationProjectCommandStore, externalCsvConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore
); );
@@ -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();
}
});
});