diff --git a/src/db/repositories/external-object-assignment-plan-project-command.repository.ts b/src/db/repositories/external-object-assignment-plan-project-command.repository.ts new file mode 100644 index 0000000..d34c4c0 --- /dev/null +++ b/src/db/repositories/external-object-assignment-plan-project-command.repository.ts @@ -0,0 +1,434 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; +import { + assertExternalObjectAssignmentPlanProjectCommand, + createExternalObjectAssignmentPlanProjectCommand, + externalObjectApplyAssignmentPlanCommandType, + externalObjectRevertAssignmentPlanCommandType, + invertExternalObjectAssignmentPlanProjectCommand, + type ExternalObjectAssignmentPlanProjectCommand, +} from "../../domain/models/external-object-assignment-plan-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 { ExternalObjectAssignmentPlanProjectCommandStore } from "../../domain/ports/external-object-assignment-plan-project-command.store.js"; +import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js"; +import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js"; +import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js"; +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.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 { syncSingleDeviceCircuitDisplayNames } from "./circuit-display-name-sync.persistence.js"; +import { + assertCircuitDeviceRowReferencesInProject, + toCircuitDeviceRowInsertValues, + toCircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure.persistence.js"; +import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; +import { + applyExternalObjectLinks, + assertExternalObjectsCompatibleWithRow, + loadExpectedExternalObjectTransitions, + snapshotsEqual, + toExternalObjectSnapshot, +} from "./external-object-assignment.persistence.js"; +import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; +import { resolveCircuitVoltage, updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; + +interface CircuitContext { + circuitListId: string; + distributionBoardId: string; + category: CircuitGroupCategory; +} + +/** + * Applies or reverts one confirmed assignment plan atomically: new circuits + * with rows and protection, new rows in existing circuits, row quantities and + * all object links. Revert is history-only and removes created structure only + * while it is unchanged. + */ +export class ExternalObjectAssignmentPlanProjectCommandRepository + implements ExternalObjectAssignmentPlanProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalObjectAssignmentPlanProjectCommand(input.command); + if ( + input.source === "user" && + input.command.type === externalObjectRevertAssignmentPlanCommandType + ) { + throw new Error("Applied assignment plans may only be reverted through project history."); + } + return executeProjectCommandTransactionWithAppliedForward(this.database, input, (tx) => { + const command = input.command; + const touchedCircuitIds = [ + ...new Set([ + ...command.payload.newRows.map((row) => row.circuitId), + ...command.payload.rows.map(({ target }) => target.circuitId), + ]), + ]; + if (command.type === externalObjectApplyAssignmentPlanCommandType) { + this.apply(tx, input.projectId, command, input.source); + } else { + this.revert(tx, input.projectId, command); + } + const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames( + tx, + input.source, + touchedCircuitIds, + command.payload.circuitDisplayNames + ); + const forward = createExternalObjectAssignmentPlanProjectCommand(command.type, { + ...command.payload, + circuitDisplayNames, + }); + return { + forward, + inverse: invertExternalObjectAssignmentPlanProjectCommand(forward), + }; + }); + } + + private apply( + database: AppDatabase, + projectId: string, + command: ExternalObjectAssignmentPlanProjectCommand, + source: ProjectRevisionSource + ) { + const { newCircuits, newRows, rows } = command.payload; + const contexts = new Map(); + + for (const circuit of newCircuits) { + const section = this.loadSectionContext(database, projectId, circuit); + const parsed = parseGroupedEquipmentIdentifier(circuit.equipmentIdentifier); + if ( + !parsed || + parsed.kind !== "circuit" || + parsed.category !== section.category || + parsed.groupNumber !== section.groupNumber + ) { + throw new Error("External circuit equipment identifier does not match target group."); + } + if (database.select({ id: circuits.id }).from(circuits) + .where(eq(circuits.id, circuit.id)).get()) { + throw new Error("External circuit id already exists."); + } + for (const row of circuit.deviceRows) { + this.assertNewRow(database, projectId, row, section.category); + } + const voltage = resolveCircuitVoltage( + database, + projectId, + circuit.sectionId, + circuit.deviceRows.map((row) => row.phaseType) + ); + if (circuit.voltage !== voltage) { + throw new Error("External circuit voltage must match the project phase voltage."); + } + contexts.set(circuit.id, section); + } + for (const row of newRows) { + const context = this.loadCircuitContext(database, projectId, row.circuitId); + this.assertNewRow(database, projectId, row, context.category); + contexts.set(row.circuitId, context); + } + const existingRowIds = rows.map(({ expected }) => expected.id); + const currentRows = existingRowIds.length === 0 + ? [] + : database.select().from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, existingRowIds)).all(); + if (currentRows.length !== existingRowIds.length) { + throw new Error("External object assignment target no longer exists."); + } + for (const { expected } of rows) { + const current = currentRows.find((row) => row.id === expected.id)!; + if (!snapshotsEqual(toCircuitDeviceRowSnapshot(current), expected)) { + throw new Error("Circuit device row changed before external object assignment."); + } + if (!contexts.has(expected.circuitId)) { + contexts.set( + expected.circuitId, + this.loadCircuitContext(database, projectId, expected.circuitId) + ); + } + } + loadExpectedExternalObjectTransitions( + database, + projectId, + command.payload.objects, + "External object changed before plan assignment." + ); + + for (const circuit of newCircuits) { + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + circuit.equipmentIdentifier + ); + database.insert(circuits).values(toCircuitValues(circuit)).run(); + for (const row of circuit.deviceRows) { + database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run(); + } + database.insert(circuitProtectionDevices).values(circuit.protectionDevice!).run(); + } + for (const row of newRows) { + database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run(); + } + for (const { expected, target } of rows) { + this.updateRowQuantity(database, expected, target); + } + applyExternalObjectLinks( + database, + command.payload.objects, + "External object changed during plan assignment." + ); + for (const circuitId of new Set(newRows.map((row) => row.circuitId))) { + this.updateCircuitState(database, projectId, circuitId); + } + + // Undo/redo restores a state the user already accepted; only a new user + // plan needs the explicit planning-value conflict confirmation. + const confirmed = new Set( + source === "user" + ? command.payload.confirmedConflictObjectIds + : command.payload.objects.map(({ target }) => target.id) + ); + const targetRows = [ + ...newCircuits.flatMap((circuit) => circuit.deviceRows), + ...newRows, + ...rows.map(({ target }) => target), + ]; + const linkedObjects = database.select().from(externalModelObjects) + .where(inArray(externalModelObjects.circuitDeviceRowId, targetRows.map((row) => row.id))) + .all() + .map(toExternalObjectSnapshot); + for (const row of targetRows) { + const context = contexts.get(row.circuitId)!; + const rowObjects = linkedObjects.filter((object) => object.circuitDeviceRowId === row.id); + assertExternalObjectsCompatibleWithRow({ + database, + projectId, + row, + distributionBoardId: context.distributionBoardId, + category: context.category, + assignedObjects: command.payload.objects + .map(({ target }) => target) + .filter((object) => object.circuitDeviceRowId === row.id), + allLinkedObjects: rowObjects, + confirmedConflictObjectIds: confirmed, + }); + assertCircuitDeviceRowQuantity({ + quantity: row.quantity, + manualQuantity: row.manualQuantity ?? row.quantity, + externalObjects: rowObjects.map((object) => ({ + effectiveQuantity: object.planningValues.effectiveQuantity, + })), + }); + } + } + + private revert( + database: AppDatabase, + projectId: string, + command: ExternalObjectAssignmentPlanProjectCommand + ) { + const { newCircuits, newRows, rows } = command.payload; + for (const expected of newCircuits) { + this.loadSectionContext(database, projectId, expected); + const current = this.loadCircuitSnapshot(database, expected.id); + if (!current || !snapshotsEqual(current, expected)) { + throw new Error("Created external circuit changed before history removal."); + } + } + for (const expected of newRows) { + this.loadCircuitContext(database, projectId, expected.circuitId); + const current = database.select().from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, expected.id)).get(); + if (!current || !snapshotsEqual(toCircuitDeviceRowSnapshot(current), expected)) { + throw new Error("Created external device row changed before history removal."); + } + } + const createdRowIds = [ + ...newCircuits.flatMap((circuit) => circuit.deviceRows.map((row) => row.id)), + ...newRows.map((row) => row.id), + ]; + const plannedObjectIds = new Set(command.payload.objects.map(({ expected }) => expected.id)); + const createdLinks = createdRowIds.length === 0 + ? [] + : database.select({ id: externalModelObjects.id }).from(externalModelObjects) + .where(inArray(externalModelObjects.circuitDeviceRowId, createdRowIds)).all(); + if (createdLinks.some(({ id }) => !plannedObjectIds.has(id))) { + throw new Error("Created external rows have different object links."); + } + loadExpectedExternalObjectTransitions( + database, + projectId, + command.payload.objects, + "External object changed before plan removal." + ); + for (const { expected } of rows) { + const current = database.select().from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, expected.id)).get(); + if (!current || !snapshotsEqual(toCircuitDeviceRowSnapshot(current), expected)) { + throw new Error("Circuit device row changed before plan removal."); + } + } + + applyExternalObjectLinks( + database, + command.payload.objects, + "External object changed during plan removal." + ); + for (const { expected, target } of rows) { + const linked = database.select().from(externalModelObjects) + .where(eq(externalModelObjects.circuitDeviceRowId, target.id)).all(); + assertCircuitDeviceRowQuantity({ + quantity: target.quantity, + manualQuantity: target.manualQuantity ?? target.quantity, + externalObjects: linked.map((object) => ({ + effectiveQuantity: object.planningValues.effectiveQuantity, + })), + }); + this.updateRowQuantity(database, expected, target); + } + for (const row of newRows) { + const deleted = database.delete(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, row.id)).run(); + if (deleted.changes !== 1) { + throw new Error("Created external device row changed during history removal."); + } + } + for (const circuitId of new Set(newRows.map((row) => row.circuitId))) { + this.updateCircuitState(database, projectId, circuitId); + } + assertCircuitsAreNotReferencedAsFeeders( + database, + projectId, + newCircuits.map((circuit) => circuit.id) + ); + for (const circuit of newCircuits) { + const deleted = database.delete(circuits).where(and( + eq(circuits.id, circuit.id), + eq(circuits.circuitListId, circuit.circuitListId) + )).run(); + if (deleted.changes !== 1) { + throw new Error("Created external circuit changed during history removal."); + } + } + } + + private assertNewRow( + database: AppDatabase, + projectId: string, + row: CircuitDeviceRowSnapshot, + category: CircuitGroupCategory + ) { + if (!isElectricalPhaseType(row.phaseType) || row.category !== category) { + throw new Error("External device row does not match target circuit group."); + } + assertCircuitDeviceRowReferencesInProject(database, projectId, row); + if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, row.id)).get()) { + throw new Error("External device-row id already exists."); + } + } + + private updateRowQuantity( + database: AppDatabase, + expected: CircuitDeviceRowSnapshot, + target: CircuitDeviceRowSnapshot + ) { + const result = database.update(circuitDeviceRows) + .set({ quantity: target.quantity }) + .where(and( + eq(circuitDeviceRows.id, expected.id), + eq(circuitDeviceRows.quantity, expected.quantity), + eq(circuitDeviceRows.manualQuantity, expected.manualQuantity ?? expected.quantity) + )).run(); + if (result.changes !== 1) throw new Error("Circuit device row changed during assignment."); + } + + private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) { + const remaining = database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)).limit(1).get(); + const result = database.update(circuits).set({ isReserve: remaining ? 0 : 1 }) + .where(eq(circuits.id, circuitId)).run(); + if (result.changes !== 1) throw new Error("Target circuit changed during row assignment."); + updateDerivedCircuitVoltage(database, projectId, circuitId); + } + + private loadSectionContext(database: AppDatabase, projectId: string, circuit: CircuitSnapshot) { + 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, circuit.sectionId), + eq(circuitSections.circuitListId, circuit.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 { + circuitListId: circuit.circuitListId, + distributionBoardId: context.distributionBoardId, + category: context.category, + groupNumber: context.groupNumber, + }; + } + + private loadCircuitContext(database: AppDatabase, projectId: string, circuitId: string): CircuitContext { + const context = database.select({ + projectId: circuitLists.projectId, + circuitListId: circuitLists.id, + distributionBoardId: circuitLists.distributionBoardId, + category: circuitSections.category, + }).from(circuits) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId)) + .where(eq(circuits.id, circuitId)).get(); + if (!context || context.projectId !== projectId || context.category === null) { + throw new Error("Target circuit does not belong to the project."); + } + return { + circuitListId: context.circuitListId, + distributionBoardId: context.distributionBoardId, + category: context.category, + }; + } + + 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 }; +} diff --git a/src/domain/models/external-object-assignment-plan-project-command.model.ts b/src/domain/models/external-object-assignment-plan-project-command.model.ts new file mode 100644 index 0000000..5fb9015 --- /dev/null +++ b/src/domain/models/external-object-assignment-plan-project-command.model.ts @@ -0,0 +1,250 @@ +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 { + assertCircuitDisplayNameTransitions, + invertCircuitDisplayNameTransitions, + withCircuitDisplayNames, + type CircuitDisplayNameTransition, +} from "./circuit-display-name-sync.model.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"; + +/** + * One confirmed plan of the Revit assignment dialog (requirements §11.7): + * new circuits with their external-only rows and default protection, new + * external-only rows in existing circuits, quantity changes of existing rows + * and every object link. The revert type is history-only and removes only + * created structure that is still unchanged. + */ +export const externalObjectApplyAssignmentPlanCommandType = + "external-object.apply-assignment-plan" as const; +export const externalObjectRevertAssignmentPlanCommandType = + "external-object.revert-assignment-plan" as const; +export const externalObjectAssignmentPlanCommandSchemaVersion = 1 as const; + +export interface ExternalObjectLinkTransitionSnapshot { + expected: ExternalModelObjectSnapshot; + target: ExternalModelObjectSnapshot; +} + +export interface ExternalObjectAssignmentPlanPayload { + newCircuits: CircuitSnapshot[]; + newRows: CircuitDeviceRowSnapshot[]; + rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>; + objects: ExternalObjectLinkTransitionSnapshot[]; + confirmedConflictObjectIds: string[]; + circuitDisplayNames?: CircuitDisplayNameTransition[]; +} + +export interface ExternalObjectAssignmentPlanProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalObjectAssignmentPlanCommandSchemaVersion; + type: + | typeof externalObjectApplyAssignmentPlanCommandType + | typeof externalObjectRevertAssignmentPlanCommandType; +} + +export function createExternalObjectAssignmentPlanProjectCommand( + type: ExternalObjectAssignmentPlanProjectCommand["type"], + payload: ExternalObjectAssignmentPlanPayload +): ExternalObjectAssignmentPlanProjectCommand { + const command: ExternalObjectAssignmentPlanProjectCommand = { + schemaVersion: externalObjectAssignmentPlanCommandSchemaVersion, + type, + payload: withCircuitDisplayNames( + { + newCircuits: payload.newCircuits, + newRows: payload.newRows, + rows: payload.rows, + objects: payload.objects, + confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds], + }, + payload.circuitDisplayNames + ), + }; + assertExternalObjectAssignmentPlanProjectCommand(command); + return command; +} + +export function invertExternalObjectAssignmentPlanProjectCommand( + command: ExternalObjectAssignmentPlanProjectCommand +) { + return createExternalObjectAssignmentPlanProjectCommand( + command.type === externalObjectApplyAssignmentPlanCommandType + ? externalObjectRevertAssignmentPlanCommandType + : externalObjectApplyAssignmentPlanCommandType, + { + newCircuits: command.payload.newCircuits, + newRows: command.payload.newRows, + 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, + circuitDisplayNames: invertCircuitDisplayNameTransitions( + command.payload.circuitDisplayNames ?? [] + ), + } + ); +} + +export function assertExternalObjectAssignmentPlanProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalObjectAssignmentPlanProjectCommand { + if ( + command.schemaVersion !== externalObjectAssignmentPlanCommandSchemaVersion || + (command.type !== externalObjectApplyAssignmentPlanCommandType && + command.type !== externalObjectRevertAssignmentPlanCommandType) || + !isRecord(command.payload) || + !Array.isArray(command.payload.newCircuits) || + !Array.isArray(command.payload.newRows) || + !Array.isArray(command.payload.rows) || + !Array.isArray(command.payload.objects) || + command.payload.objects.length === 0 || + !Array.isArray(command.payload.confirmedConflictObjectIds) + ) { + throw new Error("Unsupported external object assignment-plan command."); + } + const allowedKeys = new Set([ + "newCircuits", "newRows", "rows", "objects", "confirmedConflictObjectIds", "circuitDisplayNames", + ]); + if (Object.keys(command.payload).some((key) => !allowedKeys.has(key))) { + throw new Error("External object assignment-plan command contains unknown fields."); + } + + const rowIds = new Set(); + const registerRow = (rowId: string) => { + if (rowIds.has(rowId)) throw new Error("Assignment plan contains duplicate device rows."); + rowIds.add(rowId); + }; + const circuitIds = new Set(); + for (const value of command.payload.newCircuits) { + const circuit = parseCircuit(value); + if (circuitIds.has(circuit.id)) { + throw new Error("Assignment plan contains duplicate circuits."); + } + circuitIds.add(circuit.id); + if ( + circuit.deviceRows.length === 0 || + circuit.deviceRows.some((row) => row.manualQuantity !== 0 || row.circuitId !== circuit.id) || + circuit.protectionDevice === undefined || + circuit.protectionDevice === null + ) { + throw new Error( + "A planned external circuit needs external-only rows and a protection device." + ); + } + for (const row of circuit.deviceRows) registerRow(row.id); + } + for (const value of command.payload.newRows) { + const row = parseRow(value); + if (row.manualQuantity !== 0 || circuitIds.has(row.circuitId)) { + throw new Error("A planned external row must start with zero manual quantity."); + } + registerRow(row.id); + } + for (const transition of command.payload.rows) { + if (!isRecord(transition)) throw new Error("Invalid device-row transition."); + const expected = parseRow(transition.expected); + const target = parseRow(transition.target); + if ( + expected.id !== target.id || + canonicalJson({ ...expected, quantity: 0 }) !== canonicalJson({ ...target, quantity: 0 }) + ) { + throw new Error("Assignment plan may only change existing row quantities."); + } + registerRow(expected.id); + } + + const assigning = command.type === externalObjectApplyAssignmentPlanCommandType; + const objectIds = new Set(); + 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("Assignment plan contains mismatched or duplicate objects."); + } + objectIds.add(expected.id); + const linkedRowId = assigning ? target.circuitDeviceRowId : expected.circuitDeviceRowId; + const unlinkedRowId = assigning ? expected.circuitDeviceRowId : target.circuitDeviceRowId; + if (unlinkedRowId !== null || linkedRowId === null || !rowIds.has(linkedRowId)) { + throw new Error("External object links do not match the assignment plan."); + } + const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null }; + const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null }; + if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { + throw new Error("Assignment plan may only change the external object row link."); + } + } + const confirmedIds = new Set(); + for (const objectId of command.payload.confirmedConflictObjectIds) { + if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) { + throw new Error("Assignment plan contains an invalid conflict confirmation."); + } + confirmedIds.add(objectId); + } + assertCircuitDisplayNameTransitions(command.payload.circuitDisplayNames); +} + +function parseCircuit(value: unknown): CircuitSnapshot { + const envelope = { + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitInsertCommandType, + payload: { circuit: value }, + }; + assertCircuitInsertProjectCommand(envelope); + return envelope.payload.circuit as CircuitSnapshot; +} + +function parseRow(value: unknown): CircuitDeviceRowSnapshot { + const envelope = { + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row: value }, + }; + assertCircuitDeviceRowInsertProjectCommand(envelope); + const row = envelope.payload.row; + if (row.manualQuantity === undefined) { + throw new Error("Assignment plan rows require a manual quantity."); + } + return row; +} + +function parseObject(value: unknown): ExternalModelObjectSnapshot { + return parseExternalModelStateSnapshot({ + source: null, + importBatches: [], + roomMappings: [], + objects: [value], + }).objects[0]!; +} + +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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/external-object-assignment-plan-project-command.store.ts b/src/domain/ports/external-object-assignment-plan-project-command.store.ts new file mode 100644 index 0000000..6e81dbe --- /dev/null +++ b/src/domain/ports/external-object-assignment-plan-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalObjectAssignmentPlanProjectCommand } from "../models/external-object-assignment-plan-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalObjectAssignmentPlanCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalObjectAssignmentPlanProjectCommand; +} + +export interface ExternalObjectAssignmentPlanProjectCommandStore { + execute(input: ExecuteExternalObjectAssignmentPlanCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalObjectAssignmentPlanProjectCommand; + }; +} diff --git a/src/domain/services/circuit-numbering.service.ts b/src/domain/services/circuit-numbering.service.ts index 5802d55..f204781 100644 --- a/src/domain/services/circuit-numbering.service.ts +++ b/src/domain/services/circuit-numbering.service.ts @@ -26,6 +26,25 @@ function parseSuffix(equipmentIdentifier: string, prefix: string): number | null return Number(suffix); } +/** + * Next identifiers of one group: highest existing suffix + 1, + 2, ... Gaps + * are never filled. Used once at planning time; Redo never recalculates. + */ +export function nextCircuitIdentifiers( + prefix: string, + existingIdentifiers: Iterable, + count: number +): string[] { + let maxSuffix = 0; + for (const identifier of existingIdentifiers) { + const suffix = parseSuffix(identifier, prefix); + if (suffix !== null && suffix > maxSuffix) { + maxSuffix = suffix; + } + } + return Array.from({ length: count }, (_, index) => `${prefix}${maxSuffix + index + 1}`); +} + export class CircuitNumberingService { private readonly sectionRepository: CircuitNumberingSectionReader; private readonly circuitRepository: CircuitNumberingCircuitReader; @@ -42,14 +61,10 @@ export class CircuitNumberingService { } const circuits = await this.circuitRepository.listBySection(sectionId); - let maxSuffix = 0; - for (const circuit of circuits) { - const suffix = parseSuffix(circuit.equipmentIdentifier, section.prefix); - if (suffix !== null && suffix > maxSuffix) { - maxSuffix = suffix; - } - } - - return `${section.prefix}${maxSuffix + 1}`; + return nextCircuitIdentifiers( + section.prefix, + circuits.map((circuit) => circuit.equipmentIdentifier), + 1 + )[0]!; } } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 25fc776..1ad5154 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -205,6 +205,12 @@ import { externalObjectDeleteCreatedCircuitCommandType, } from "../models/external-object-new-circuit-project-command.model.js"; import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js"; +import { + assertExternalObjectAssignmentPlanProjectCommand, + externalObjectApplyAssignmentPlanCommandType, + externalObjectRevertAssignmentPlanCommandType, +} from "../models/external-object-assignment-plan-project-command.model.js"; +import type { ExternalObjectAssignmentPlanProjectCommandStore } from "../ports/external-object-assignment-plan-project-command.store.js"; import { assertEstimateBalanceDeleteProjectCommand, assertEstimateBalanceInsertProjectCommand, @@ -263,7 +269,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore, private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore, private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore, - private readonly circuitListBulkUpdateStore?: CircuitListBulkUpdateProjectCommandStore + private readonly circuitListBulkUpdateStore?: CircuitListBulkUpdateProjectCommandStore, + private readonly externalObjectAssignmentPlanStore?: ExternalObjectAssignmentPlanProjectCommandStore ) {} executeUser( @@ -719,6 +726,17 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalObjectApplyAssignmentPlanCommandType: + case externalObjectRevertAssignmentPlanCommandType: { + assertExternalObjectAssignmentPlanProjectCommand(input.command); + if (!this.externalObjectAssignmentPlanStore) { + throw new Error("External object assignment-plan store is not available."); + } + return this.externalObjectAssignmentPlanStore.execute({ + ...input, + command: input.command, + }).revision; + } case estimateBalanceInsertCommandType: case estimateBalanceDeleteCommandType: case estimateBalanceUpdateCommandType: { diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 8cd677f..e9aa313 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -151,6 +151,7 @@ export interface ExternalProjectObjectDto { } export interface ExternalProjectObjectsDto { currentRevision: number; sourceName: string | null; + maxSocketsPerCircuit: number | null; socketDeviceTypeIds: string[]; distributionBoards: Array<{ id: string; name: string }>; projectDevices: Array<{ id: string; name: string; displayName: string }>; objects: ExternalProjectObjectDto[]; diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index e6d73c0..ded3499 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -76,6 +76,8 @@ const commandTypeLabels: Record = { "external-object.unassign-and-delete-created-row": "Externes Objekt aus erzeugter Gerätezeile gelöst", "external-object.update-row-assignment": "Externe Objektzuordnung geändert", + "external-object.apply-assignment-plan": "Revit-Objekte zugeordnet", + "external-object.revert-assignment-plan": "Revit-Zuordnung zurückgenommen", "estimate-balance.insert": "Grobe Leistungsbilanz angelegt", "estimate-balance.update": "Grobe Leistungsbilanz bearbeitet", "estimate-balance.delete": "Grobe Leistungsbilanz entfernt", diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index ef108b6..88f1403 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -31,6 +31,7 @@ import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/re 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"; +import { ExternalObjectAssignmentPlanProjectCommandRepository } from "../../db/repositories/external-object-assignment-plan-project-command.repository.js"; import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); @@ -93,6 +94,8 @@ export const externalObjectNewRowProjectCommandStore = new ExternalObjectNewRowProjectCommandRepository(db); export const externalObjectNewCircuitProjectCommandStore = new ExternalObjectNewCircuitProjectCommandRepository(db); +export const externalObjectAssignmentPlanProjectCommandStore = + new ExternalObjectAssignmentPlanProjectCommandRepository(db); export const estimateBalanceProjectCommandStore = new EstimateBalanceProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( @@ -127,5 +130,6 @@ export const projectCommandService = new ProjectCommandService( externalObjectPlanningProjectCommandStore, externalFollowUpImportProjectCommandStore, externalObjectDeleteMissingProjectCommandStore, - circuitListBulkUpdateProjectCommandStore + circuitListBulkUpdateProjectCommandStore, + externalObjectAssignmentPlanProjectCommandStore ); diff --git a/src/server/controllers/external-object-assignment-plan.controller.ts b/src/server/controllers/external-object-assignment-plan.controller.ts new file mode 100644 index 0000000..1d8647e --- /dev/null +++ b/src/server/controllers/external-object-assignment-plan.controller.ts @@ -0,0 +1,320 @@ +import type { Request, Response } from "express"; +import { randomUUID } from "node:crypto"; +import { + createExternalObjectAssignmentPlanProjectCommand, + externalObjectApplyAssignmentPlanCommandType, +} from "../../domain/models/external-object-assignment-plan-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 { nextCircuitIdentifiers } from "../../domain/services/circuit-numbering.service.js"; +import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults.js"; +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { + findCategoryIssues, + findRowConflicts, + findRowIssues, + objectGroupKey, + socketQuantity, + type AssignmentPlanCategory, + type AssignmentPlanObject, + type AssignmentPlanRow, +} from "../../shared/revit/assignment-plan.js"; +import { resolveSocketDeviceTypeIds } from "../../shared/revit/socket-device-types.js"; +import { applyExternalObjectAssignmentPlanSchema } from "../../shared/validation/external-object-assignment-plan.schemas.js"; +import { + externalModelConfigurationRepository, + externalModelStateRepository, + projectRepository, +} from "../composition/application-repositories.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { buildProjectCircuitTrees } from "./circuit-tree.controller.js"; +import { buildExternalRow, scalarText, toRowSnapshot } from "./external-objects.controller.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +type ProjectTrees = NonNullable>>; +type Tree = ProjectTrees["trees"][number]; +type Section = Tree["sections"][number]; +type TreeCircuit = Section["circuits"][number]; + +/** + * Applies one confirmed plan of the Revit assignment dialog as a single + * project command. The plan is re-checked with the shared planning rules: + * objects must be present, unassigned and planned for this distribution + * board; rows need compatible objects; planning-value conflicts and circuits + * above the project's socket limit need explicit confirmation. + */ +export async function applyExternalObjectAssignmentPlan(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + const parsed = applyExternalObjectAssignmentPlanSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const input = parsed.data; + try { + const project = await projectRepository.findById(projectId); + if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." }); + const trees = await buildProjectCircuitTrees(projectId); + const tree = trees?.trees.find((entry) => entry.circuitListId === input.circuitListId); + if (!tree) return res.status(404).json({ error: "Stromkreisliste nicht gefunden." }); + + const { state } = externalModelStateRepository.getByProject(projectId); + const configuration = + externalModelConfigurationRepository.getByProject(projectId).configuration?.configuration ?? null; + const socketTypeIds = resolveSocketDeviceTypeIds(configuration); + const roomIdByMappingId = new Map(state.roomMappings.map((mapping) => [mapping.id, mapping.roomId])); + const objectById = new Map(state.objects.map((object) => [object.id, object])); + const toPlanObject = (object: ExternalModelObjectSnapshot): AssignmentPlanObject => ({ + id: object.id, + worksharingId: object.worksharingId, + familyAndType: object.acceptedSourceValues.familyAndType, + selectionMarker: scalarText(object.acceptedSourceValues.selectionMarker), + deviceTypeId: object.deviceTypeId, + roomId: object.externalRoomMappingId === null + ? null + : roomIdByMappingId.get(object.externalRoomMappingId) ?? null, + roomNumber: scalarText(object.acceptedSourceValues.roomNumber), + roomName: scalarText(object.acceptedSourceValues.roomName), + linkedProjectDeviceId: object.linkedProjectDeviceId, + isSocket: socketTypeIds.has(object.deviceTypeId), + planningValues: object.planningValues, + }); + + const linkedByRowId = new Map(); + for (const object of state.objects) { + if (object.circuitDeviceRowId === null) continue; + linkedByRowId.set(object.circuitDeviceRowId, [ + ...(linkedByRowId.get(object.circuitDeviceRowId) ?? []), + object, + ]); + } + const circuitById = new Map(); + const rowById = new Map(); + for (const section of tree.sections) { + for (const circuit of section.circuits) { + circuitById.set(circuit.id, { section, circuit }); + for (const row of circuit.deviceRows) rowById.set(row.id, { section, circuit, row }); + } + } + const sectionById = new Map(tree.sections.map((section) => [section.id, section])); + const toPlanRow = (circuitId: string, row: TreeCircuit["deviceRows"][number]): AssignmentPlanRow => { + const linked = linkedByRowId.get(row.id) ?? []; + return { + id: row.id, + circuitId, + roomId: row.roomId ?? null, + displayName: row.displayName, + linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, + category: row.category ?? null, + connectionKind: row.connectionKind ?? null, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi ?? null, + costGroup: row.costGroup ?? null, + remark: row.remark ?? null, + linkedSelectionMarkers: [...new Set(linked.map((object) => scalarText(object.acceptedSourceValues.selectionMarker).trim()))], + linkedObjectCount: linked.length, + }; + }; + + // Objects: present, unassigned, planned for this board, used once. + const usedObjectIds = new Set(); + const planObjects = (objectIds: readonly string[]) => objectIds.map((id) => { + const object = objectById.get(id); + if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden."); + if (object.presenceStatus !== "present") throw new Error("Fehlende Revit-Objekte können nicht zugeordnet werden."); + if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits zugeordnet."); + if (object.distributionBoardId !== tree.distributionBoardId) { + throw new Error("Ein Revit-Objekt gehört zu einem anderen Verteiler."); + } + if (usedObjectIds.has(id)) throw new Error("Ein Revit-Objekt ist mehrfach geplant."); + usedObjectIds.add(id); + return object; + }); + + const newCircuitByKey = new Map(); + for (const entry of input.newCircuits) { + const section = sectionById.get(entry.sectionId); + if (!section?.category) throw new Error("Stromkreisgruppe wurde nicht gefunden."); + if (newCircuitByKey.has(entry.key)) throw new Error("Ein neuer Stromkreis ist doppelt geplant."); + newCircuitByKey.set(entry.key, { sectionId: entry.sectionId, section }); + } + + const confirmedConflicts = new Set(input.confirmedConflictObjectIds); + const socketsAddedByRef = new Map(); + const addSockets = (refKey: string, objects: ExternalModelObjectSnapshot[]) => + socketsAddedByRef.set(refKey, (socketsAddedByRef.get(refKey) ?? 0) + socketQuantity(objects.map(toPlanObject))); + const requireSameGroup = (objects: ExternalModelObjectSnapshot[]) => { + if (new Set(objects.map((object) => objectGroupKey(toPlanObject(object)))).size !== 1) { + throw new Error("Eine neue Gerätezeile darf nur gleichartige Revit-Objekte desselben Raums enthalten."); + } + }; + const throwIssues = (issues: string[]) => { + if (issues.length > 0) throw new Error(issues[0]); + }; + + const rowTransitions: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> = []; + const newRows: CircuitDeviceRowSnapshot[] = []; + const newCircuitRows = new Map>(); + const objectTransitions: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }> = []; + const link = (objects: ExternalModelObjectSnapshot[], rowId: string) => { + for (const expected of objects) { + objectTransitions.push({ + expected, + target: { ...expected, distributionBoardId: tree.distributionBoardId, circuitDeviceRowId: rowId }, + }); + } + }; + const nextRowSortOrder = new Map(); + const takeRowSortOrder = (circuitId: string, existing: readonly { sortOrder: number }[]) => { + const next = (nextRowSortOrder.get(circuitId) ?? Math.max(0, ...existing.map((row) => row.sortOrder))) + 10; + nextRowSortOrder.set(circuitId, next); + return next; + }; + + for (const requestRow of input.rows) { + const objects = planObjects(requestRow.objectIds); + if (requestRow.kind === "existing-row") { + const location = rowById.get(requestRow.rowId); + if (!location) throw new Error("Gerätezeile wurde nicht gefunden."); + const planRow = toPlanRow(location.circuit.id, location.row); + throwIssues(findRowIssues(planRow, location.section.category as AssignmentPlanCategory, objects.map(toPlanObject))); + for (const object of objects) { + if (findRowConflicts(planRow, toPlanObject(object)).length > 0 && !confirmedConflicts.has(object.id)) { + throw new Error("Abweichende Planungswerte müssen ausdrücklich bestätigt werden."); + } + } + const expected = toRowSnapshot(location.row, location.circuit.id); + rowTransitions.push({ + expected, + target: { + ...expected, + quantity: expected.quantity + objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0), + }, + }); + link(objects, expected.id); + addSockets(location.circuit.id, objects); + } else if (requestRow.kind === "new-row") { + const location = circuitById.get(requestRow.circuitId); + if (!location) throw new Error("Stromkreis wurde nicht gefunden."); + requireSameGroup(objects); + throwIssues(findCategoryIssues(location.section.category as AssignmentPlanCategory, objects.map(toPlanObject))); + const row = buildExternalRow( + objects, + toPlanObject(objects[0]!).roomId, + location.circuit.id, + takeRowSortOrder(location.circuit.id, location.circuit.deviceRows) + ); + newRows.push(row); + link(objects, row.id); + addSockets(location.circuit.id, objects); + } else { + const planned = newCircuitByKey.get(requestRow.newCircuitKey); + if (!planned) throw new Error("Ein neuer Stromkreis ist nicht geplant."); + requireSameGroup(objects); + throwIssues(findCategoryIssues(planned.section.category as AssignmentPlanCategory, objects.map(toPlanObject))); + const rows = newCircuitRows.get(requestRow.newCircuitKey) ?? []; + const row = buildExternalRow( + objects, + toPlanObject(objects[0]!).roomId, + "", + (rows.length + 1) * 10 + ); + rows.push({ row }); + newCircuitRows.set(requestRow.newCircuitKey, rows); + link(objects, row.id); + addSockets(`new:${requestRow.newCircuitKey}`, objects); + } + } + + if ([...newCircuitByKey.keys()].some((key) => !newCircuitRows.has(key))) { + throw new Error("Ein geplanter neuer Stromkreis enthält keine Revit-Objekte."); + } + if (new Set(rowTransitions.map(({ expected }) => expected.id)).size !== rowTransitions.length) { + throw new Error("Eine Gerätezeile ist mehrfach geplant."); + } + + // Socket limit: only circuits that gain sockets are checked. + const limit = project.maxSocketsPerCircuit; + const confirmedOverLimit = new Set(input.confirmedOverLimitCircuitKeys); + if (limit !== null) { + for (const [refKey, added] of socketsAddedByRef) { + if (added === 0) continue; + const existing = circuitById.get(refKey); + const before = existing + ? socketQuantity(existing.circuit.deviceRows.flatMap((row) => linkedByRowId.get(row.id) ?? []).map(toPlanObject)) + : 0; + if (before + added > limit && !confirmedOverLimit.has(refKey)) { + throw new Error(`Der Höchstwert von ${limit} Steckdosen je Stromkreis wird überschritten und muss bestätigt werden.`); + } + } + } + + // New circuits: BMKs are planned once per group, highest number + 1, + 2 ... + const keysBySection = new Map(); + for (const [key, planned] of newCircuitByKey) { + keysBySection.set(planned.sectionId, [...(keysBySection.get(planned.sectionId) ?? []), key]); + } + const newCircuits: CircuitSnapshot[] = []; + for (const [sectionId, keys] of keysBySection) { + const section = sectionById.get(sectionId)!; + const identifiers = nextCircuitIdentifiers( + section.prefix, + section.circuits.map((circuit) => circuit.equipmentIdentifier), + keys.length + ); + const maxSortOrder = Math.max(0, ...section.circuits.map((circuit) => circuit.sortOrder)); + keys.forEach((key, index) => { + const circuitId = randomUUID(); + const rows = newCircuitRows.get(key)!.map(({ row }) => ({ ...row, circuitId })); + const category = section.category!; + const protection = createDefaultCircuitProtection(category); + const names = [...new Set(rows.map((row) => row.displayName))]; + newCircuits.push({ + id: circuitId, + circuitListId: tree.circuitListId, + sectionId, + equipmentIdentifier: identifiers[index]!, + // One external row is shown compactly and carries the circuit name. + displayName: names.length === 1 ? names[0]! : rows[0]!.displayName, + sortOrder: maxSortOrder + (index + 1) * 10, + cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null, + terminalDesignation: null, + voltage: category === "three_phase" ? tree.threePhaseVoltageV : tree.singlePhaseVoltageV, + controlRequirement: null, status: null, isReserve: false, remark: null, + deviceRows: rows, + protectionDevice: { + circuitId, + type: protection.type, + ratedCurrentA: protection.ratedCurrentA, + fuseUtilizationCategory: "fuseUtilizationCategory" in protection ? protection.fuseUtilizationCategory : null, + tripCharacteristic: "tripCharacteristic" in protection ? protection.tripCharacteristic : null, + rcdType: "rcdType" in protection ? protection.rcdType : null, + ratedResidualCurrentMa: "ratedResidualCurrentMa" in protection ? protection.ratedResidualCurrentMa : null, + }, + }); + }); + } + + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: input.expectedRevision, + description: `${objectTransitions.length} Revit-Objekte zugeordnet`, + command: createExternalObjectAssignmentPlanProjectCommand( + externalObjectApplyAssignmentPlanCommandType, + { + newCircuits, + newRows, + rows: rowTransitions, + objects: objectTransitions, + confirmedConflictObjectIds: input.confirmedConflictObjectIds.filter((id) => usedObjectIds.has(id)), + } + ), + }); + return res.json({ + ...result, + newCircuitIds: newCircuits.map((circuit) => circuit.id), + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts index ab8ca50..37adbbe 100644 --- a/src/server/controllers/external-objects.controller.ts +++ b/src/server/controllers/external-objects.controller.ts @@ -5,7 +5,8 @@ import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/mo import { createExternalObjectRowAssignmentProjectCommand } 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 { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; -import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; +import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; +import { resolveSocketDeviceTypeIds } from "../../shared/revit/socket-device-types.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; import { buildProjectCircuitTrees } from "./circuit-tree.controller.js"; @@ -20,8 +21,11 @@ export async function listExternalObjects(req: Request, res: Response) { if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." }); const state = externalModelStateRepository.getByProject(projectId).state; const mappings = new Map(state.roomMappings.map((mapping) => [mapping.id, mapping])); + const configuration = externalModelConfigurationRepository.getByProject(projectId).configuration?.configuration ?? null; return res.json({ currentRevision: project.currentRevision, + maxSocketsPerCircuit: project.maxSocketsPerCircuit, + socketDeviceTypeIds: [...resolveSocketDeviceTypeIds(configuration)], sourceName: state.source?.name ?? null, distributionBoards: boards.map(({ id, name }) => ({ id, name })), projectDevices: devices.map(({ id, name, displayName }) => ({ id, name, displayName })), @@ -263,7 +267,7 @@ async function loadAssignmentContext(projectId: string, objectIds: string[]) { return { objects, roomId: [...roomIds][0]!, treeResult: await buildProjectCircuitTrees(projectId) }; } -function buildExternalRow(objects: ReturnType["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot { +export function buildExternalRow(objects: ReturnType["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot { const first = objects[0]!; const planning = first.planningValues; const quantity = objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0); @@ -303,12 +307,12 @@ function findRow(result: Awaited>, r } return null; } -function toRowSnapshot(row: NonNullable>["row"], circuitId: string): CircuitDeviceRowSnapshot { +export function toRowSnapshot(row: NonNullable>["row"], circuitId: string): CircuitDeviceRowSnapshot { return { id: row.id, circuitId, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null }; } const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[]; -function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); } +export function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function hasOwn(value: object, key: PropertyKey) { return Object.prototype.hasOwnProperty.call(value, key); } function nullableId(value: unknown) { if (value === null || value === "") return null; if (typeof value !== "string") throw new Error("Ungültige Zuordnungs-ID."); return value; } diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 1d91670..49b671f 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -63,6 +63,7 @@ import { getExternalInitialImportStatus, planExternalInitialImport, } from "../controllers/external-initial-import.controller.js"; +import { applyExternalObjectAssignmentPlan } from "../controllers/external-object-assignment-plan.controller.js"; import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js"; import { @@ -106,6 +107,7 @@ projectRouter.post("/:projectId/external-model/objects/assign-row", assignExtern projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow); projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit); projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow); +projectRouter.post("/:projectId/external-model/objects/assignment-plan", applyExternalObjectAssignmentPlan); projectRouter.post("/:projectId/external-model/return-workbook", createExternalReturnWorkbook); projectRouter.get("/:projectId/external-model/export-batches", listExternalExportBatches); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); diff --git a/src/shared/validation/external-object-assignment-plan.schemas.ts b/src/shared/validation/external-object-assignment-plan.schemas.ts new file mode 100644 index 0000000..56cd31e --- /dev/null +++ b/src/shared/validation/external-object-assignment-plan.schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; + +const idSchema = z.string().min(1).max(200); +const objectIdsSchema = z.array(idSchema).min(1).max(10000); +const newCircuitKeySchema = z.string().min(1).max(100); + +/** Request of the Revit assignment dialog (requirements §11.7). */ +export const applyExternalObjectAssignmentPlanSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + circuitListId: idSchema, + newCircuits: z + .array(z.object({ key: newCircuitKeySchema, sectionId: idSchema }).strict()) + .max(500), + rows: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("existing-row"), + rowId: idSchema, + objectIds: objectIdsSchema, + }).strict(), + z.object({ + kind: z.literal("new-row"), + circuitId: idSchema, + objectIds: objectIdsSchema, + }).strict(), + z.object({ + kind: z.literal("new-row-in-new-circuit"), + newCircuitKey: newCircuitKeySchema, + objectIds: objectIdsSchema, + }).strict(), + ]) + ) + .min(1) + .max(5000), + confirmedConflictObjectIds: z.array(idSchema).max(10000), + confirmedOverLimitCircuitKeys: z.array(z.string().min(1).max(200)).max(5000), + }) + .strict(); + +export type ApplyExternalObjectAssignmentPlanInput = z.infer< + typeof applyExternalObjectAssignmentPlanSchema +>; diff --git a/tests/external-object-assignment-plan-project-command.repository.test.ts b/tests/external-object-assignment-plan-project-command.repository.test.ts new file mode 100644 index 0000000..5a4a9c5 --- /dev/null +++ b/tests/external-object-assignment-plan-project-command.repository.test.ts @@ -0,0 +1,262 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js"; +import { ExternalObjectAssignmentPlanProjectCommandRepository } from "../src/db/repositories/external-object-assignment-plan-project-command.repository.js"; +import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; +import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; +import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js"; +import { + createExternalObjectAssignmentPlanProjectCommand, + externalObjectApplyAssignmentPlanCommandType, + externalObjectRevertAssignmentPlanCommandType, +} from "../src/domain/models/external-object-assignment-plan-project-command.model.js"; +import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; + +function createFixture() { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + const database = context.db; + database.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + database.insert(floors).values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10 }).run(); + for (const [id, number] of [["room-1", "101"], ["room-2", "102"]] as const) { + database.insert(rooms).values({ id, projectId: "project-1", floorId: "floor-1", roomNumber: number, roomName: "Büro" }).run(); + } + database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); + database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); + database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); + database.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", displayName: "Steckdose", sortOrder: 10, voltage: 230 }).run(); + database.insert(circuits).values({ id: "circuit-2", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.2", displayName: "Reserve", sortOrder: 20, voltage: 230, isReserve: 1 }).run(); + database.insert(circuitDeviceRows).values(rowValues({ id: "row-1", circuitId: "circuit-1", quantity: 2 })).run(); + database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run(); + database.insert(externalImportBatches).values({ + id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", + importedAtIso: "2026-10-30T10:00:00.000Z", fileName: "revit.xlsx", sha256: "a".repeat(64), objectCount: 4, + }).run(); + for (const [id, roomId, number] of [["mapping-1", "room-1", "101"], ["mapping-2", "room-2", "102"]] as const) { + database.insert(externalRoomMappings).values({ + id, projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: `number:${number}`, + sourceFloorName: "EG", sourceRoomNumber: number, sourceRoomName: "Büro", roomId, defaultDistributionBoardId: "board-1", + }).run(); + } + database.insert(externalModelObjects).values([ + objectSnapshot("object-0", "mapping-1", "row-1"), + objectSnapshot("object-a1", "mapping-1", null), + objectSnapshot("object-a2", "mapping-1", null), + objectSnapshot("object-b1", "mapping-2", null), + ]).run(); + return context; +} + +function rowValues(overrides: Partial & { id: string; circuitId: string }): CircuitDeviceRowSnapshot { + return { + linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose: Doppel", displayName: "Steckdose", + phaseType: "single_phase", connectionKind: "socket", costGroup: null, category: "single_phase", level: null, + roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", quantity: 2, manualQuantity: 0, + powerPerUnit: 0.12, simultaneityFactor: 1, cosPhi: null, remark: null, overriddenFields: null, + ...overrides, + }; +} + +const worksharingIds: Record = { + "object-0": "00000000-0000-4000-8000-000000000000-00000001", + "object-a1": "00000000-0000-4000-8000-000000000001-00000001", + "object-a2": "00000000-0000-4000-8000-000000000002-00000001", + "object-b1": "00000000-0000-4000-8000-000000000003-00000001", +}; + +function objectSnapshot(id: string, mappingId: string, circuitDeviceRowId: string | null): ExternalModelObjectSnapshot { + const roomNumber = mappingId === "mapping-1" ? "101" : "102"; + return { + id, projectId: "project-1", sourceId: "source-1", worksharingId: worksharingIds[id]!, ifcGuid: null, + deviceTypeId: "device-type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 3, roomNumber, roomName: "Büro", familyAndType: "Steckdose: Doppel", selectionMarker: "SD", + socketCount: "2", sourceRemark: null, circuitCombined: null, boardName: null, circuitIdentifier: null, + designation: null, electricalPowerW: null, voltageV: null, currentA: null, + }, + planningValues: { + displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, + powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null, + }, + overriddenFields: [], externalRoomMappingId: mappingId, distributionBoardId: "board-1", + linkedProjectDeviceId: null, circuitDeviceRowId, presenceStatus: "present", + }; +} + +function link(id: string, mappingId: string, rowId: string) { + const expected = objectSnapshot(id, mappingId, null); + return { expected, target: { ...expected, circuitDeviceRowId: rowId } }; +} + +function planCommand(database: AppDatabase) { + const existing = toCircuitDeviceRowSnapshot( + database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()! + ); + const newCircuit: CircuitSnapshot = { + 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: [rowValues({ id: "row-new-b", circuitId: "circuit-new", roomId: "room-2", roomNumberSnapshot: "102" })], + protectionDevice: { + circuitId: "circuit-new", type: "FI_LS", ratedCurrentA: 16, fuseUtilizationCategory: null, + tripCharacteristic: "B", rcdType: "A", ratedResidualCurrentMa: 30, + }, + }; + return createExternalObjectAssignmentPlanProjectCommand(externalObjectApplyAssignmentPlanCommandType, { + newCircuits: [newCircuit], + newRows: [rowValues({ id: "row-new-a", circuitId: "circuit-2" })], + rows: [{ expected: existing, target: { ...existing, quantity: 4 } }], + objects: [ + link("object-a1", "mapping-1", "row-1"), + link("object-a2", "mapping-1", "row-new-a"), + link("object-b1", "mapping-2", "row-new-b"), + ], + confirmedConflictObjectIds: [], + }); +} + +function readState(database: AppDatabase) { + const circuitRows = database.select().from(circuits).all() + .map(({ id, displayName, isReserve, equipmentIdentifier }) => ({ id, displayName, isReserve, equipmentIdentifier })) + .sort((left, right) => left.id.localeCompare(right.id)); + const rows = database.select().from(circuitDeviceRows).all() + .map(({ id, circuitId, quantity }) => ({ id, circuitId, quantity })) + .sort((left, right) => left.id.localeCompare(right.id)); + const links = Object.fromEntries( + database.select().from(externalModelObjects).all().map((object) => [object.id, object.circuitDeviceRowId]) + ); + const protections = database.select().from(circuitProtectionDevices).all().map((entry) => entry.circuitId); + const revision = database.select().from(projects).get()!.currentRevision; + return { circuitRows, rows, links, protections, revision }; +} + +describe("external object assignment-plan project command", () => { + it("applies a plan atomically and restores it exactly through undo and redo", () => { + const context = createFixture(); + try { + const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); + const before = readState(context.db); + const applied = repository.execute({ + projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db), + }); + const after = readState(context.db); + assert.deepEqual(after.links, { + "object-0": "row-1", "object-a1": "row-1", "object-a2": "row-new-a", "object-b1": "row-new-b", + }); + assert.deepEqual(after.rows.find((entry) => entry.id === "row-1"), { id: "row-1", circuitId: "circuit-1", quantity: 4 }); + assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-2"), { + id: "circuit-2", displayName: "Steckdose", isReserve: 0, equipmentIdentifier: "-2F1.2", + }); + assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-new")?.equipmentIdentifier, "-2F1.3"); + assert.deepEqual(after.protections, ["circuit-new"]); + assert.equal(applied.inverse.type, externalObjectRevertAssignmentPlanCommandType); + assert.deepEqual(applied.inverse.payload.circuitDisplayNames, [ + { circuitId: "circuit-2", expected: "Steckdose", target: "Reserve" }, + ]); + + const undone = repository.execute({ + projectId: "project-1", expectedRevision: 1, source: "undo", + historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse, + }); + assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...before, revision: 0 }); + + repository.execute({ + projectId: "project-1", expectedRevision: 2, source: "redo", + historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse, + }); + assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...after, revision: 0 }); + } finally { + context.close(); + } + }); + + it("rolls back completely when an object changed and rejects a user revert", () => { + const context = createFixture(); + try { + const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); + const command = planCommand(context.db); + context.db.update(externalModelObjects) + .set({ circuitDeviceRowId: "row-1" }) + .where(eq(externalModelObjects.id, "object-b1")).run(); + const before = readState(context.db); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command }), + /changed before plan assignment/ + ); + assert.deepEqual(readState(context.db), before); + + const revert = createExternalObjectAssignmentPlanProjectCommand( + externalObjectRevertAssignmentPlanCommandType, + { ...command.payload, objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })) } + ); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: revert }), + /only be reverted through project history/ + ); + } finally { + context.close(); + } + }); + + it("refuses undo after a created row was changed", () => { + const context = createFixture(); + try { + const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); + const applied = repository.execute({ + projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db), + }); + context.db.update(circuitDeviceRows).set({ remark: "geändert" }) + .where(eq(circuitDeviceRows.id, "row-new-a")).run(); + const before = readState(context.db); + assert.throws( + () => repository.execute({ + projectId: "project-1", expectedRevision: 1, source: "undo", + historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse, + }), + /changed before history removal/ + ); + assert.deepEqual(readState(context.db), before); + } finally { + context.close(); + } + }); + + it("requires explicit confirmation of planning-value conflicts on existing rows", () => { + const context = createFixture(); + try { + context.db.update(circuitDeviceRows).set({ simultaneityFactor: 0.5 }) + .where(eq(circuitDeviceRows.id, "row-1")).run(); + const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db) }), + /explicit conflict confirmation/ + ); + const confirmed = createExternalObjectAssignmentPlanProjectCommand( + externalObjectApplyAssignmentPlanCommandType, + { ...planCommand(context.db).payload, confirmedConflictObjectIds: ["object-a1"] } + ); + assert.doesNotThrow(() => + repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: confirmed }) + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/external-object-assignment-plan-route.test.ts b/tests/external-object-assignment-plan-route.test.ts new file mode 100644 index 0000000..eecc1a7 --- /dev/null +++ b/tests/external-object-assignment-plan-route.test.ts @@ -0,0 +1,206 @@ +/** + * HTTP boundary of the Revit assignment plan route against createApp() with + * an in-memory database: authentication, strict input and foreign objects. + */ +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import config, { WRITE_HEADER, signIn } from "./security/security.config.ts"; + +type Headers = Record; + +describe("Revit assignment plan route", () => { + let server: import("node:http").Server; + let baseUrl = ""; + let user: Headers = {}; + + async function call(method: string, path: string, headers: Headers, body?: unknown) { + return fetch(baseUrl + path, { + method, + headers: { ...headers, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } + + async function createProjectWithBoard(name: string) { + const project = (await (await call("POST", "/api/projects", user, { name })).json()) as { id: string }; + const created = await call("POST", `/api/projects/${project.id}/distribution-boards`, user, { + expectedRevision: 0, name: "UV-EG", floorId: null, supplyType: "AV", + }); + assert.equal(created.status, 201, await created.clone().text()); + const [circuitList] = (await (await call("GET", `/api/projects/${project.id}/circuit-lists`, user)).json()) as Array<{ id: string }>; + return { projectId: project.id, circuitListId: circuitList!.id }; + } + + function plan(circuitListId: string, objectId = "unknown-object") { + return { + expectedRevision: 1, + circuitListId, + newCircuits: [], + rows: [{ kind: "new-row", circuitId: "unknown-circuit", objectIds: [objectId] }], + confirmedConflictObjectIds: [], + confirmedOverLimitCircuitKeys: [], + }; + } + + before(async () => { + const app = await config.createApp(); + server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + user = await signIn(baseUrl, "planer-a"); + }); + + after(() => new Promise((resolve) => server.close(() => resolve()))); + + async function seedSockets(projectId: string, boardId: string, count: number) { + const { db } = await import("../src/db/client.js"); + const { externalModelConfigurations } = await import("../src/db/schema/external-model-configurations.js"); + const { externalModelSources } = await import("../src/db/schema/external-model-sources.js"); + const { externalImportBatches } = await import("../src/db/schema/external-import-batches.js"); + const { externalModelObjects } = await import("../src/db/schema/external-model-objects.js"); + const { createDefaultExternalModelConfiguration } = await import( + "../src/external-model/configuration/external-model-configuration.js" + ); + const deviceTypeId = "10000000-0000-4000-8000-000000000001"; + db.insert(externalModelConfigurations).values({ + id: `config-${projectId}`, + projectId, + configuration: { + ...createDefaultExternalModelConfiguration(), + deviceTypes: [{ + id: deviceTypeId, name: "Steckdose", category: "single_phase", isSocket: true, + defaults: { powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null }, + }], + familyTypeRules: [], + }, + }).run(); + db.insert(externalModelSources).values({ id: `source-${projectId}`, projectId, name: "Revit", sourceType: "revit_xlsx" }).run(); + db.insert(externalImportBatches).values({ + id: `batch-${projectId}`, projectId, sourceId: `source-${projectId}`, importKind: "initial", + importedAtIso: "2026-10-30T10:00:00.000Z", fileName: "revit.xlsx", sha256: "b".repeat(64), objectCount: count, + }).run(); + const ids = Array.from({ length: count }, (_, index) => `${projectId}-socket-${index + 1}`); + db.insert(externalModelObjects).values(ids.map((id, index) => ({ + id, projectId, sourceId: `source-${projectId}`, + worksharingId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}-00000001`, + ifcGuid: null, deviceTypeId, lastSeenImportBatchId: `batch-${projectId}`, lastAcceptedImportBatchId: `batch-${projectId}`, + acceptedSourceValues: { + rowNumber: index + 3, roomNumber: null, roomName: null, familyAndType: "Steckdose: Doppel", selectionMarker: "SD", + socketCount: "2", sourceRemark: null, circuitCombined: null, boardName: null, circuitIdentifier: null, + designation: null, electricalPowerW: null, voltageV: null, currentA: null, + }, + planningValues: { + displayName: "Steckdose", category: "single_phase" as const, connectionKind: null, effectiveQuantity: 2, + powerPerUnitW: 200, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null, + }, + overriddenFields: [], externalRoomMappingId: null, distributionBoardId: boardId, + linkedProjectDeviceId: null, circuitDeviceRowId: null, presenceStatus: "present" as const, + }))).run(); + return ids; + } + + it("applies a split plan with the socket limit, one undo step and stable BMKs", async () => { + const { projectId, circuitListId } = await createProjectWithBoard("Steckdosen"); + const tree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as { + distributionBoardId: string; + sections: Array<{ id: string; category?: string; prefix: string; circuits: Array<{ id: string; equipmentIdentifier: string; deviceRows: Array<{ quantity: number; manualQuantity: number }> }> }>; + }; + const section = tree.sections.find((entry) => entry.category === "single_phase")!; + const objectIds = await seedSockets(projectId, tree.distributionBoardId, 6); + const settings = await call("PUT", `/api/projects/${projectId}`, user, { + expectedRevision: 1, name: "Steckdosen", internalProjectNumber: null, externalProjectNumber: null, + buildingOwner: null, description: null, isPublicBuilding: false, singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, enabledDistributionBoardSupplyTypes: ["AV", "SV", "EV", "USV", "MSR", "SiBe"], + maxSocketsPerCircuit: 8, + }); + assert.equal(settings.status, 200, await settings.clone().text()); + + const path = `/api/projects/${projectId}/external-model/objects/assignment-plan`; + const request = { + expectedRevision: 2, + circuitListId, + newCircuits: [{ key: "a", sectionId: section.id }, { key: "b", sectionId: section.id }], + rows: [ + { kind: "new-row-in-new-circuit", newCircuitKey: "a", objectIds: objectIds.slice(0, 4) }, + { kind: "new-row-in-new-circuit", newCircuitKey: "b", objectIds: objectIds.slice(4, 6) }, + ], + confirmedConflictObjectIds: [], + confirmedOverLimitCircuitKeys: [], + }; + const overLimit = await call("POST", path, user, { + ...request, + rows: [{ kind: "new-row-in-new-circuit", newCircuitKey: "a", objectIds: objectIds.slice(0, 5) }], + newCircuits: [{ key: "a", sectionId: section.id }], + }); + assert.equal(overLimit.status, 400); + assert.match(await overLimit.text(), /Höchstwert von 8 Steckdosen/); + + const applied = await call("POST", path, user, request); + assert.equal(applied.status, 200, await applied.clone().text()); + const afterTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree; + const created = afterTree.sections.find((entry) => entry.id === section.id)!.circuits; + assert.deepEqual(created.map((circuit) => circuit.equipmentIdentifier), [`${section.prefix}1`, `${section.prefix}2`]); + assert.deepEqual(created.map((circuit) => circuit.deviceRows.map((row) => [row.quantity, row.manualQuantity])), [[[8, 0]], [[4, 0]]]); + + const objects = (await (await call("GET", `/api/projects/${projectId}/external-model/objects`, user)).json()) as { + maxSocketsPerCircuit: number | null; socketDeviceTypeIds: string[]; objects: Array<{ circuitDeviceRowId: string | null }>; + }; + assert.equal(objects.maxSocketsPerCircuit, 8); + assert.equal(objects.socketDeviceTypeIds.length, 1); + assert.ok(objects.objects.every((object) => object.circuitDeviceRowId !== null)); + + const undone = await call("POST", `/api/projects/${projectId}/history/undo`, user, { expectedRevision: 3 }); + assert.equal(undone.status, 200, await undone.clone().text()); + const undoneTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree; + assert.equal(undoneTree.sections.find((entry) => entry.id === section.id)!.circuits.length, 0); + const redone = await call("POST", `/api/projects/${projectId}/history/redo`, user, { expectedRevision: 4 }); + assert.equal(redone.status, 200, await redone.clone().text()); + const redoneTree = (await (await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`, user)).json()) as typeof tree; + assert.deepEqual( + redoneTree.sections.find((entry) => entry.id === section.id)!.circuits.map((circuit) => circuit.id), + created.map((circuit) => circuit.id) + ); + }); + + it("rejects requests without a session", async () => { + const { projectId, circuitListId } = await createProjectWithBoard("Ohne Login"); + const response = await call( + "POST", + `/api/projects/${projectId}/external-model/objects/assignment-plan`, + WRITE_HEADER, + plan(circuitListId) + ); + assert.equal(response.status, 401); + }); + + it("rejects invalid input and unknown fields", async () => { + const { projectId, circuitListId } = await createProjectWithBoard("Ungültig"); + const path = `/api/projects/${projectId}/external-model/objects/assignment-plan`; + assert.equal((await call("POST", path, user, {})).status, 400); + assert.equal((await call("POST", path, user, { ...plan(circuitListId), extra: true })).status, 400); + assert.equal( + (await call("POST", path, user, { ...plan(circuitListId), rows: [{ kind: "row", rowId: "x", objectIds: ["a"] }] })).status, + 400 + ); + assert.equal((await call("POST", path, user, { ...plan(circuitListId), rows: [] })).status, 400); + }); + + it("rejects a circuit list of another project and unknown objects", async () => { + const own = await createProjectWithBoard("Eigenes Projekt"); + const foreign = await createProjectWithBoard("Fremdes Projekt"); + const path = `/api/projects/${own.projectId}/external-model/objects/assignment-plan`; + const foreignList = await call("POST", path, user, plan(foreign.circuitListId)); + assert.equal(foreignList.status, 404); + const unknownObject = await call("POST", path, user, plan(own.circuitListId)); + assert.equal(unknownObject.status, 400); + assert.match(await unknownObject.text(), /Revit-Objekt wurde nicht gefunden/); + const missingProject = await call( + "POST", + "/api/projects/unknown-project/external-model/objects/assignment-plan", + user, + plan(own.circuitListId) + ); + assert.equal(missingProject.status, 404); + }); +}); diff --git a/tests/security/security.config.ts b/tests/security/security.config.ts index 329fa7d..a87027f 100644 --- a/tests/security/security.config.ts +++ b/tests/security/security.config.ts @@ -125,6 +125,7 @@ const config = { { method: "PUT", path: "/api/projects/{id}", body: {} }, { method: "PUT", path: "/api/projects/{id}", body: "{kaputt" }, { method: "POST", path: "/api/projects/{id}/distribution-boards/import", body: {} }, + { method: "POST", path: "/api/projects/{id}/external-model/objects/assignment-plan", body: {} }, { method: "POST", path: "/api/projects/{id}/distribution-boards/import",