Add external object row assignment command

This commit is contained in:
Julian Appel 2026-08-02 18:38:38 +02:00
parent dac19b093c
commit 1a7dd7fe03
10 changed files with 693 additions and 12 deletions

View file

@ -0,0 +1,204 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeviceRowQuantity,
} from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
invertExternalObjectRowAssignmentProjectCommand,
} from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../../domain/ports/external-object-row-assignment-project-command.store.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalObjectRowAssignmentProjectCommandRepository
implements ExternalObjectRowAssignmentProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
assertExternalObjectRowAssignmentProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
this.apply(tx, input.projectId, input.command);
return invertExternalObjectRowAssignmentProjectCommand(input.command);
});
}
private apply(
database: AppDatabase,
projectId: string,
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
) {
const rowTransitions = new Map(
command.payload.rows.map((transition) => [transition.expected.id, transition])
);
const objectTransitions = new Map(
command.payload.objects.map((transition) => [transition.expected.id, transition])
);
const rowIds = [...rowTransitions.keys()];
const objectIds = [...objectTransitions.keys()];
const currentRows = database.select().from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds)).all();
const currentObjects = database.select().from(externalModelObjects)
.where(inArray(externalModelObjects.id, objectIds)).all();
if (currentRows.length !== rowIds.length || currentObjects.length !== objectIds.length) {
throw new Error("External object assignment target no longer exists.");
}
for (const row of currentRows) {
const expected = rowTransitions.get(row.id)!.expected;
if (!same(toCircuitDeviceRowSnapshot(row), expected)) {
throw new Error("Circuit device row changed before external object assignment.");
}
}
for (const object of currentObjects) {
const expected = objectTransitions.get(object.id)!.expected;
if (!same(toExternalObjectSnapshot(object), expected)) {
throw new Error("External object changed before row assignment.");
}
if (object.projectId !== projectId) {
throw new Error("External object belongs to another project.");
}
}
const rowContexts = database
.select({
rowId: circuitDeviceRows.id,
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
category: circuitSections.category,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
rowContexts.length !== rowIds.length ||
rowContexts.some((context) => context.projectId !== projectId)
) {
throw new Error("Circuit device row belongs to another project.");
}
const contextByRowId = new Map(rowContexts.map((context) => [context.rowId, context]));
const allProjectObjects = database.select().from(externalModelObjects)
.where(eq(externalModelObjects.projectId, projectId)).all()
.map(toExternalObjectSnapshot);
const targetObjects = allProjectObjects.map((object) =>
objectTransitions.get(object.id)?.target ?? object
);
const mappings = database.select().from(externalRoomMappings)
.where(eq(externalRoomMappings.projectId, projectId)).all();
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds);
for (const transition of command.payload.objects) {
const object = transition.target;
if (object.circuitDeviceRowId === null) continue;
const row = rowTransitions.get(object.circuitDeviceRowId)?.target;
const context = contextByRowId.get(object.circuitDeviceRowId);
if (!row || !context) throw new Error("External object target row is incomplete.");
if (
object.distributionBoardId === null ||
object.distributionBoardId !== context.distributionBoardId
) {
throw new Error("External object distribution does not match target row.");
}
if (object.planningValues.category !== context.category) {
throw new Error("External object category does not match target circuit group.");
}
const objectRoomId = object.externalRoomMappingId === null
? null
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
if (objectRoomId !== row.roomId) {
throw new Error("External object room does not match target row.");
}
if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) {
throw new Error("External object planning values require explicit conflict confirmation.");
}
}
for (const transition of command.payload.rows) {
const linkedObjects = targetObjects.filter(
(object) => object.circuitDeviceRowId === transition.target.id
);
const selectionMarkers = new Set(
linkedObjects.map((object) => object.acceptedSourceValues.selectionMarker.trim())
);
if (selectionMarkers.size > 1) {
throw new Error("External objects with different selection markers require separate rows.");
}
assertCircuitDeviceRowQuantity({
quantity: transition.target.quantity,
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
externalObjects: linkedObjects.map((object) => ({
effectiveQuantity: object.planningValues.effectiveQuantity,
})),
});
}
for (const transition of command.payload.rows) {
const result = database.update(circuitDeviceRows)
.set({ quantity: transition.target.quantity })
.where(and(
eq(circuitDeviceRows.id, transition.expected.id),
eq(circuitDeviceRows.quantity, transition.expected.quantity),
eq(circuitDeviceRows.manualQuantity, transition.expected.manualQuantity ?? transition.expected.quantity)
)).run();
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
}
for (const transition of command.payload.objects) {
const result = database.update(externalModelObjects)
.set({ circuitDeviceRowId: transition.target.circuitDeviceRowId })
.where(eq(externalModelObjects.id, transition.expected.id)).run();
if (result.changes !== 1) throw new Error("External object changed during assignment.");
}
}
}
function matchesRowPlanningValues(
row: CircuitDeviceRowSnapshot,
object: ExternalModelObjectSnapshot
) {
const planning = object.planningValues;
return (
row.displayName === (planning.displayName ?? row.displayName) &&
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function toExternalObjectSnapshot(
object: typeof externalModelObjects.$inferSelect
): ExternalModelObjectSnapshot {
return { ...object };
}
function same(left: unknown, right: unknown) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -0,0 +1,186 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export const externalObjectRowAssignmentCommandType =
"external-object.update-row-assignment" as const;
export const externalObjectRowAssignmentCommandSchemaVersion = 1 as const;
export interface ExternalObjectRowAssignmentPayload {
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>;
confirmedConflictObjectIds: string[];
}
export interface ExternalObjectRowAssignmentProjectCommand
extends SerializedProjectCommand<ExternalObjectRowAssignmentPayload> {
schemaVersion: typeof externalObjectRowAssignmentCommandSchemaVersion;
type: typeof externalObjectRowAssignmentCommandType;
}
export function createExternalObjectRowAssignmentProjectCommand(
payload: ExternalObjectRowAssignmentPayload
): ExternalObjectRowAssignmentProjectCommand {
const normalizedPayload: ExternalObjectRowAssignmentPayload = {
...payload,
rows: payload.rows.map(({ expected, target }) => ({
expected: normalizeRow(expected),
target: normalizeRow(target),
})),
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
};
const command: ExternalObjectRowAssignmentProjectCommand = {
schemaVersion: externalObjectRowAssignmentCommandSchemaVersion,
type: externalObjectRowAssignmentCommandType,
payload: normalizedPayload,
};
assertExternalObjectRowAssignmentProjectCommand(command);
return command;
}
export function invertExternalObjectRowAssignmentProjectCommand(
command: ExternalObjectRowAssignmentProjectCommand
) {
return createExternalObjectRowAssignmentProjectCommand({
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
confirmedConflictObjectIds: [...command.payload.confirmedConflictObjectIds],
});
}
export function assertExternalObjectRowAssignmentProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalObjectRowAssignmentProjectCommand {
if (
command.schemaVersion !== externalObjectRowAssignmentCommandSchemaVersion ||
command.type !== externalObjectRowAssignmentCommandType ||
!isRecord(command.payload) ||
!Array.isArray(command.payload.rows) ||
!Array.isArray(command.payload.objects) ||
!Array.isArray(command.payload.confirmedConflictObjectIds) ||
command.payload.rows.length === 0 ||
command.payload.objects.length === 0
) {
throw new Error("Unsupported external object row-assignment command.");
}
const rowIds = new Set<string>();
for (const transition of command.payload.rows) {
if (!isRecord(transition)) throw new Error("Invalid row assignment transition.");
const expected = parseRow(transition.expected);
const target = parseRow(transition.target);
if (expected.manualQuantity === undefined || target.manualQuantity === undefined) {
throw new Error("Row assignment requires an explicit manual quantity.");
}
if (expected.id !== target.id || rowIds.has(expected.id)) {
throw new Error("Row assignment contains mismatched or duplicate rows.");
}
rowIds.add(expected.id);
if (expected.manualQuantity !== target.manualQuantity) {
throw new Error("Row assignment must preserve the manual quantity.");
}
assertOnlyFieldChanged(
expected as unknown as Record<string, unknown>,
target as unknown as Record<string, unknown>,
"quantity",
"row"
);
}
const objectIds = new Set<string>();
const affectedRowIds = new Set<string>();
for (const transition of command.payload.objects) {
if (!isRecord(transition)) throw new Error("Invalid object assignment transition.");
const expected = parseObject(transition.expected);
const target = parseObject(transition.target);
if (expected.id !== target.id || objectIds.has(expected.id)) {
throw new Error("Row assignment contains mismatched or duplicate objects.");
}
objectIds.add(expected.id);
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
throw new Error("External object row assignment must change its row link.");
}
assertOnlyFieldChanged(
expected as unknown as Record<string, unknown>,
target as unknown as Record<string, unknown>,
"circuitDeviceRowId",
"external object"
);
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId);
}
const confirmedIds = new Set<string>();
for (const objectId of command.payload.confirmedConflictObjectIds) {
if (
typeof objectId !== "string" ||
!objectIds.has(objectId) ||
confirmedIds.has(objectId)
) {
throw new Error("Row assignment contains an invalid conflict confirmation.");
}
confirmedIds.add(objectId);
}
if (
affectedRowIds.size !== rowIds.size ||
[...affectedRowIds].some((rowId) => !rowIds.has(rowId))
) {
throw new Error("Row assignment must include every and only affected device row.");
}
}
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot {
return { ...row, manualQuantity: row.manualQuantity ?? row.quantity };
}
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
const envelope = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row: value },
};
assertCircuitDeviceRowInsertProjectCommand(envelope);
return envelope.payload.row;
}
function parseObject(value: unknown): ExternalModelObjectSnapshot {
const state = parseExternalModelStateSnapshot({
source: null,
importBatches: [],
roomMappings: [],
objects: [value],
});
return state.objects[0]!;
}
function assertOnlyFieldChanged(
expected: Record<string, unknown>,
target: Record<string, unknown>,
allowedField: string,
label: string
) {
const expectedRest = { ...expected };
const targetRest = { ...target };
delete expectedRest[allowedField];
delete targetRest[allowedField];
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error(`Row assignment may only change the ${label} assignment state.`);
}
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isRecord(value)) {
return `{${Object.keys(value).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -0,0 +1,19 @@
import type { ExternalObjectRowAssignmentProjectCommand } from "../models/external-object-row-assignment-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalObjectRowAssignmentCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalObjectRowAssignmentProjectCommand;
}
export interface ExternalObjectRowAssignmentProjectCommandStore {
execute(input: ExecuteExternalObjectRowAssignmentCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalObjectRowAssignmentProjectCommand;
};
}

View file

@ -171,6 +171,11 @@ import {
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
} from "../models/external-object-row-assignment-project-command.model.js";
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@ -207,7 +212,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore
) {}
executeUser(
@ -584,6 +590,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) {
throw new Error("External object row-assignment store is not available.");
}
return this.externalObjectRowAssignmentStore.execute({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({

View file

@ -24,6 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
@ -71,6 +72,8 @@ export const externalCsvConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@ -95,5 +98,6 @@ export const projectCommandService = new ProjectCommandService(
circuitProtectionProjectCommandStore,
projectHistoryStore,
externalCsvConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore
externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore
);