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

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