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

@ -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);
}

View file

@ -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 };
}

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);
}

View file

@ -6,18 +6,22 @@ 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";
import {
applyExternalObjectLinks,
assertExternalObjectsCompatibleWithRow,
loadExpectedExternalObjectTransitions,
snapshotsEqual,
toExternalObjectSnapshot,
} from "./external-object-assignment.persistence.js";
export class ExternalObjectRowAssignmentProjectCommandRepository
implements ExternalObjectRowAssignmentProjectCommandStore
@ -44,29 +48,23 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
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) {
if (currentRows.length !== rowIds.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)) {
if (!snapshotsEqual(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.");
}
}
loadExpectedExternalObjectTransitions(
database,
projectId,
command.payload.objects,
"External object changed before row assignment."
);
const rowContexts = database
.select({
@ -83,7 +81,9 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
.all();
if (
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.");
}
@ -95,47 +95,26 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
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.");
}
const assignedObjects = command.payload.objects
.map(({ target }) => target)
.filter((object) => object.circuitDeviceRowId === transition.target.id);
const context = contextByRowId.get(transition.target.id)!;
assertExternalObjectsCompatibleWithRow({
database,
projectId,
row: transition.target,
distributionBoardId: context.distributionBoardId,
category: context.category!,
assignedObjects,
allLinkedObjects: linkedObjects,
confirmedConflictObjectIds: confirmedConflicts,
});
assertCircuitDeviceRowQuantity({
quantity: transition.target.quantity,
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
@ -155,50 +134,10 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
)).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.");
}
applyExternalObjectLinks(
database,
command.payload.objects,
"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);
}

View file

@ -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);
}

View file

@ -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;
};
}

View file

@ -182,6 +182,12 @@ import {
externalObjectDeleteCreatedRowCommandType,
} from "../models/external-object-new-row-project-command.model.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 {
projectId: string;
@ -220,7 +226,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
) {}
executeUser(
@ -618,6 +625,17 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).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: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({

View file

@ -26,6 +26,7 @@ import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repos
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 { 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 circuitDeviceRowProjectCommandStore =
@ -77,6 +78,8 @@ export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
new ExternalObjectNewRowProjectCommandRepository(db);
export const externalObjectNewCircuitProjectCommandStore =
new ExternalObjectNewCircuitProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@ -103,5 +106,6 @@ export const projectCommandService = new ProjectCommandService(
externalCsvConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore
externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore
);