Add external object new circuit command

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