Add external object new circuit command

This commit is contained in:
Julian Appel 2026-08-02 18:55:57 +02:00
parent 347b3717c3
commit a69b7b603f
13 changed files with 880 additions and 228 deletions

View file

@ -1,4 +1,4 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectNewRowProjectCommand,
@ -7,17 +7,14 @@ import {
invertExternalObjectNewRowProjectCommand,
type ExternalObjectNewRowProjectCommand,
} from "../../domain/models/external-object-new-row-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
@ -25,6 +22,12 @@ import {
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
assertExternalObjectsCompatibleWithRow,
loadExpectedExternalObjectTransitions,
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
export class ExternalObjectNewRowProjectCommandRepository
implements ExternalObjectNewRowProjectCommandStore
@ -68,14 +71,16 @@ export class ExternalObjectNewRowProjectCommandRepository
throw new Error("External device-row id already exists.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
this.assertTargetCompatibility(
assertExternalObjectsCompatibleWithRow({
database,
projectId,
row,
context.distributionBoardId,
context.category,
objects.map((object) => object.target),
new Set(command.payload.confirmedConflictObjectIds)
);
distributionBoardId: context.distributionBoardId,
category: context.category,
assignedObjects: objects.map(({ target }) => target),
allLinkedObjects: objects.map(({ target }) => target),
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
});
assertCircuitDeviceRowQuantity({
quantity: row.quantity,
manualQuantity: 0,
@ -86,7 +91,11 @@ export class ExternalObjectNewRowProjectCommandRepository
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
this.updateCircuitState(database, projectId, row.circuitId);
this.applyObjectLinks(database, objects);
applyExternalObjectLinks(
database,
objects,
"External object changed during new-row assignment."
);
}
private remove(
@ -98,7 +107,7 @@ export class ExternalObjectNewRowProjectCommandRepository
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
const currentRow = database.select().from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
if (!currentRow || !same(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
if (!currentRow || !snapshotsEqual(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
throw new Error("Created external device row changed before history removal.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
@ -111,7 +120,11 @@ export class ExternalObjectNewRowProjectCommandRepository
) {
throw new Error("Created external device row has different object links.");
}
this.applyObjectLinks(database, objects);
applyExternalObjectLinks(
database,
objects,
"External object changed during new-row assignment."
);
const deleted = database.delete(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
if (deleted.changes !== 1) {
@ -144,76 +157,12 @@ export class ExternalObjectNewRowProjectCommandRepository
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const transitions = new Map(
command.payload.objects.map((transition) => [transition.expected.id, transition])
return loadExpectedExternalObjectTransitions(
database,
projectId,
command.payload.objects,
"External object changed before new-row assignment."
);
const current = database.select().from(externalModelObjects)
.where(inArray(externalModelObjects.id, [...transitions.keys()])).all();
if (current.length !== transitions.size) {
throw new Error("One or more external objects no longer exist.");
}
for (const object of current) {
const expected = transitions.get(object.id)!.expected;
if (object.projectId !== projectId || !same(toExternalObjectSnapshot(object), expected)) {
throw new Error("External object changed before new-row assignment.");
}
}
return command.payload.objects;
}
private assertTargetCompatibility(
database: AppDatabase,
row: CircuitDeviceRowSnapshot,
distributionBoardId: string,
category: string,
objects: ExternalModelObjectSnapshot[],
confirmedConflicts: Set<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) {
@ -225,42 +174,3 @@ export class ExternalObjectNewRowProjectCommandRepository
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
function matchesRowPlanningValues(
row: CircuitDeviceRowSnapshot,
object: ExternalModelObjectSnapshot
) {
const planning = object.planningValues;
return (
row.displayName === (planning.displayName ?? row.displayName) &&
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function toExternalObjectSnapshot(
object: typeof externalModelObjects.$inferSelect
): ExternalModelObjectSnapshot {
return { ...object };
}
function same(left: unknown, right: unknown) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}