Add external object row assignment command
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user