forked from jappel/leistungsbilanz-ts
Add atomic Revit assignment-plan command and route
- external-object.apply-assignment-plan creates planned circuits (BMK, default protection), external-only rows and quantity changes with all object links in one revision; the history-only revert removes only unchanged created structure. - POST /external-model/objects/assignment-plan validates strictly with zod, re-checks rows with the shared planning rules and requires explicit confirmation of planning conflicts and socket-limit overruns. - The compact object list carries the socket limit and socket device types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d4c44001e1
commit
fd45a1caeb
15 changed files with 1598 additions and 15 deletions
|
|
@ -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<ExternalObjectAssignmentPlanProjectCommandStore["execute"]>[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<string, CircuitContext>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<ExternalObjectAssignmentPlanPayload> {
|
||||
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<unknown>
|
||||
): 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<string>();
|
||||
const registerRow = (rowId: string) => {
|
||||
if (rowIds.has(rowId)) throw new Error("Assignment plan contains duplicate device rows.");
|
||||
rowIds.add(rowId);
|
||||
};
|
||||
const circuitIds = new Set<string>();
|
||||
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<string>();
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
|
||||
const expected = parseObject(transition.expected);
|
||||
const target = parseObject(transition.target);
|
||||
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||
throw new Error("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<string>();
|
||||
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<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string>,
|
||||
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]!;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Awaited<ReturnType<typeof buildProjectCircuitTrees>>>;
|
||||
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<string, ExternalModelObjectSnapshot[]>();
|
||||
for (const object of state.objects) {
|
||||
if (object.circuitDeviceRowId === null) continue;
|
||||
linkedByRowId.set(object.circuitDeviceRowId, [
|
||||
...(linkedByRowId.get(object.circuitDeviceRowId) ?? []),
|
||||
object,
|
||||
]);
|
||||
}
|
||||
const circuitById = new Map<string, { section: Section; circuit: TreeCircuit }>();
|
||||
const rowById = new Map<string, { section: Section; circuit: TreeCircuit; row: TreeCircuit["deviceRows"][number] }>();
|
||||
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<string>();
|
||||
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<string, { sectionId: string; section: Section }>();
|
||||
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<string, number>();
|
||||
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<string, Array<{ row: CircuitDeviceRowSnapshot }>>();
|
||||
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<string, number>();
|
||||
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<string, string[]>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof externalModelStateRepository.getByProject>["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot {
|
||||
export function buildExternalRow(objects: ReturnType<typeof externalModelStateRepository.getByProject>["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<ReturnType<typeof buildProjectCircuitTrees>>, r
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function toRowSnapshot(row: NonNullable<ReturnType<typeof findRow>>["row"], circuitId: string): CircuitDeviceRowSnapshot {
|
||||
export function toRowSnapshot(row: NonNullable<ReturnType<typeof findRow>>["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<string, unknown> { 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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
@ -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<CircuitDeviceRowSnapshot> & { 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<string, string> = {
|
||||
"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();
|
||||
}
|
||||
});
|
||||
});
|
||||
206
tests/external-object-assignment-plan-route.test.ts
Normal file
206
tests/external-object-assignment-plan-route.test.ts
Normal file
|
|
@ -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<string, string>;
|
||||
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue