forked from jappel/leistungsbilanz-ts
Add pure Revit assignment planning with room-wise socket split
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e48042bde3
commit
d4c44001e1
2 changed files with 826 additions and 0 deletions
560
src/shared/revit/assignment-plan.ts
Normal file
560
src/shared/revit/assignment-plan.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
/**
|
||||
* Pure planning of Revit-object assignments for the circuit-list editor
|
||||
* (requirements §11.4–11.7). The assignment dialog builds a local plan with
|
||||
* it, and the API re-checks the submitted plan with the same rules.
|
||||
*
|
||||
* A Revit object is indivisible: it is planned onto exactly one row and so
|
||||
* belongs to exactly one circuit. Objects are grouped into rows by room,
|
||||
* selection marker, device type, category, project-device link and identical
|
||||
* planning values; the effective quantity is not part of the group key, so
|
||||
* single and multiple sockets share one row.
|
||||
*
|
||||
* This file has no imports because Turbopack resolves frontend imports
|
||||
* without the `.js` suffix the API build needs.
|
||||
*/
|
||||
|
||||
export type AssignmentPlanCategory = "lighting" | "single_phase" | "three_phase";
|
||||
|
||||
export interface AssignmentPlanObject {
|
||||
id: string;
|
||||
worksharingId: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
deviceTypeId: string;
|
||||
roomId: string | null;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
linkedProjectDeviceId: string | null;
|
||||
isSocket: boolean;
|
||||
planningValues: {
|
||||
displayName: string | null;
|
||||
category: AssignmentPlanCategory | null;
|
||||
connectionKind: string | null;
|
||||
effectiveQuantity: number;
|
||||
powerPerUnitW: number | null;
|
||||
simultaneityFactor: number | null;
|
||||
cosPhi: number | null;
|
||||
costGroup: string | null;
|
||||
remark: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssignmentPlanRow {
|
||||
id: string;
|
||||
circuitId: string;
|
||||
roomId: string | null;
|
||||
displayName: string;
|
||||
linkedProjectDeviceId: string | null;
|
||||
category: string | null;
|
||||
connectionKind: string | null;
|
||||
/** kW per unit, as stored on CircuitDeviceRow. */
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
costGroup: string | null;
|
||||
remark: string | null;
|
||||
/** Distinct selection markers of the Revit objects already linked. */
|
||||
linkedSelectionMarkers: string[];
|
||||
linkedObjectCount: number;
|
||||
}
|
||||
|
||||
export interface AssignmentPlanCircuit {
|
||||
id: string;
|
||||
sectionId: string;
|
||||
category: AssignmentPlanCategory | null;
|
||||
equipmentIdentifier: string;
|
||||
displayName: string | null;
|
||||
rows: AssignmentPlanRow[];
|
||||
/** Effective quantity of linked socket objects before the plan. */
|
||||
linkedSocketQuantity: number;
|
||||
}
|
||||
|
||||
export interface AssignmentPlanSection {
|
||||
id: string;
|
||||
category: AssignmentPlanCategory | null;
|
||||
}
|
||||
|
||||
export type AssignmentTarget =
|
||||
| { kind: "row"; rowId: string }
|
||||
| { kind: "circuit"; circuitId: string }
|
||||
| { kind: "new-circuit"; key: string };
|
||||
|
||||
export interface AssignmentPlanState {
|
||||
newCircuits: Array<{ key: string; sectionId: string }>;
|
||||
assignments: Array<{ objectId: string; target: AssignmentTarget }>;
|
||||
/** `newRowChoiceKey` values whose group gets a new row instead of a matching one. */
|
||||
newRowChoices: string[];
|
||||
}
|
||||
|
||||
export type AssignmentCircuitRef =
|
||||
| { kind: "existing"; circuitId: string }
|
||||
| { kind: "new"; key: string };
|
||||
|
||||
export type AssignmentConflictField =
|
||||
| "displayName"
|
||||
| "projectDevice"
|
||||
| "connectionKind"
|
||||
| "powerPerUnit"
|
||||
| "simultaneityFactor"
|
||||
| "cosPhi"
|
||||
| "costGroup"
|
||||
| "remark";
|
||||
|
||||
export interface AssignmentConflict {
|
||||
objectId: string;
|
||||
field: AssignmentConflictField;
|
||||
rowValue: string | number | null;
|
||||
objectValue: string | number | null;
|
||||
}
|
||||
|
||||
export interface ResolvedRowAssignment {
|
||||
key: string;
|
||||
circuit: AssignmentCircuitRef;
|
||||
row: { kind: "existing"; rowId: string } | { kind: "new"; groupKey: string };
|
||||
/** For a group placed by circuit drop: the matching row it could join. */
|
||||
matchingRowId: string | null;
|
||||
objectIds: string[];
|
||||
quantity: number;
|
||||
socketQuantity: number;
|
||||
conflicts: AssignmentConflict[];
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedCircuitPlan {
|
||||
circuit: AssignmentCircuitRef;
|
||||
refKey: string;
|
||||
sectionId: string;
|
||||
socketQuantityBefore: number;
|
||||
socketQuantityAfter: number;
|
||||
overLimit: boolean;
|
||||
rows: ResolvedRowAssignment[];
|
||||
}
|
||||
|
||||
export interface ResolvedAssignmentPlan {
|
||||
circuits: ResolvedCircuitPlan[];
|
||||
issues: string[];
|
||||
conflictObjectIds: string[];
|
||||
overLimitRefKeys: string[];
|
||||
objectCount: number;
|
||||
newCircuitCount: number;
|
||||
}
|
||||
|
||||
/** Request rows the API accepts for `external-object.apply-assignment-plan`. */
|
||||
export type AssignmentPlanRequestRow =
|
||||
| { kind: "existing-row"; rowId: string; objectIds: string[] }
|
||||
| { kind: "new-row"; circuitId: string; objectIds: string[] }
|
||||
| { kind: "new-row-in-new-circuit"; newCircuitKey: string; objectIds: string[] };
|
||||
|
||||
export const assignmentConflictFieldLabels: Record<AssignmentConflictField, string> = {
|
||||
displayName: "Bezeichnung",
|
||||
projectDevice: "Projektgerät",
|
||||
connectionKind: "Anschlussart",
|
||||
powerPerUnit: "Leistung je Einheit",
|
||||
simultaneityFactor: "GZF",
|
||||
cosPhi: "cos φ",
|
||||
costGroup: "Kostengruppe",
|
||||
remark: "Bemerkung",
|
||||
};
|
||||
|
||||
export function circuitRefKey(ref: AssignmentCircuitRef): string {
|
||||
return ref.kind === "existing" ? ref.circuitId : `new:${ref.key}`;
|
||||
}
|
||||
|
||||
export function objectGroupKey(object: AssignmentPlanObject): string {
|
||||
const planning = object.planningValues;
|
||||
return JSON.stringify([
|
||||
object.roomId,
|
||||
object.selectionMarker.trim(),
|
||||
object.deviceTypeId,
|
||||
planning.category,
|
||||
object.linkedProjectDeviceId,
|
||||
planning.displayName,
|
||||
planning.connectionKind,
|
||||
planning.powerPerUnitW,
|
||||
planning.simultaneityFactor,
|
||||
planning.cosPhi,
|
||||
planning.costGroup,
|
||||
planning.remark,
|
||||
]);
|
||||
}
|
||||
|
||||
export function newRowChoiceKey(ref: AssignmentCircuitRef, groupKey: string) {
|
||||
return `${circuitRefKey(ref)}|${groupKey}`;
|
||||
}
|
||||
|
||||
export function socketQuantity(objects: readonly AssignmentPlanObject[]) {
|
||||
return objects.reduce(
|
||||
(sum, object) => sum + (object.isSocket ? object.planningValues.effectiveQuantity : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function effectiveQuantity(objects: readonly AssignmentPlanObject[]) {
|
||||
return objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
|
||||
}
|
||||
|
||||
/** Planning-value differences between an existing row and an object. */
|
||||
export function findRowConflicts(
|
||||
row: AssignmentPlanRow,
|
||||
object: AssignmentPlanObject
|
||||
): AssignmentConflict[] {
|
||||
const planning = object.planningValues;
|
||||
const conflicts: AssignmentConflict[] = [];
|
||||
const add = (
|
||||
field: AssignmentConflictField,
|
||||
rowValue: string | number | null,
|
||||
objectValue: string | number | null
|
||||
) => conflicts.push({ objectId: object.id, field, rowValue, objectValue });
|
||||
if (planning.displayName !== null && row.displayName !== planning.displayName) {
|
||||
add("displayName", row.displayName, planning.displayName);
|
||||
}
|
||||
if (row.linkedProjectDeviceId !== object.linkedProjectDeviceId) {
|
||||
add("projectDevice", row.linkedProjectDeviceId, object.linkedProjectDeviceId);
|
||||
}
|
||||
if (row.connectionKind !== planning.connectionKind) {
|
||||
add("connectionKind", row.connectionKind, planning.connectionKind);
|
||||
}
|
||||
if (planning.powerPerUnitW !== null && row.powerPerUnit !== planning.powerPerUnitW / 1000) {
|
||||
add("powerPerUnit", row.powerPerUnit * 1000, planning.powerPerUnitW);
|
||||
}
|
||||
if (row.simultaneityFactor !== (planning.simultaneityFactor ?? 1)) {
|
||||
add("simultaneityFactor", row.simultaneityFactor, planning.simultaneityFactor ?? 1);
|
||||
}
|
||||
if (row.cosPhi !== planning.cosPhi) add("cosPhi", row.cosPhi, planning.cosPhi);
|
||||
if (row.costGroup !== planning.costGroup) add("costGroup", row.costGroup, planning.costGroup);
|
||||
if (row.remark !== planning.remark) add("remark", row.remark, planning.remark);
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking reasons why objects cannot join a row: category of the target
|
||||
* group, room and a single selection marker per row (server rules).
|
||||
*/
|
||||
export function findRowIssues(
|
||||
row: AssignmentPlanRow,
|
||||
circuitCategory: AssignmentPlanCategory | null,
|
||||
objects: readonly AssignmentPlanObject[]
|
||||
): string[] {
|
||||
const issues = findCategoryIssues(circuitCategory, objects);
|
||||
if (objects.some((object) => object.roomId !== row.roomId)) {
|
||||
issues.push("Raum der Revit-Objekte passt nicht zur Gerätezeile.");
|
||||
}
|
||||
const markers = new Set([
|
||||
...row.linkedSelectionMarkers.map((marker) => marker.trim()),
|
||||
...objects.map((object) => object.selectionMarker.trim()),
|
||||
]);
|
||||
if (markers.size > 1) {
|
||||
issues.push("Unterschiedliche Auswahlkenner benötigen getrennte Gerätezeilen.");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function findCategoryIssues(
|
||||
circuitCategory: AssignmentPlanCategory | null,
|
||||
objects: readonly AssignmentPlanObject[]
|
||||
): string[] {
|
||||
if (circuitCategory === null) {
|
||||
return ["Die Stromkreisgruppe besitzt keine Kategorie."];
|
||||
}
|
||||
return objects.some((object) => object.planningValues.category !== circuitCategory)
|
||||
? ["Kategorie der Revit-Objekte passt nicht zur Stromkreisgruppe."]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matching row a group joins when dropped on its circuit: only rows that
|
||||
* already carry Revit links, with the same room, category, project device
|
||||
* and selection marker and without planning-value conflicts. Manual-only rows
|
||||
* are never joined implicitly to avoid counting devices twice.
|
||||
*/
|
||||
export function findMatchingRow(
|
||||
circuit: AssignmentPlanCircuit,
|
||||
objects: readonly AssignmentPlanObject[]
|
||||
): AssignmentPlanRow | null {
|
||||
return (
|
||||
circuit.rows.find(
|
||||
(row) =>
|
||||
row.linkedObjectCount > 0 &&
|
||||
findRowIssues(row, circuit.category, objects).length === 0 &&
|
||||
objects.every((object) => findRowConflicts(row, object).length === 0)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssignmentPlan(input: {
|
||||
objects: readonly AssignmentPlanObject[];
|
||||
circuits: readonly AssignmentPlanCircuit[];
|
||||
sections: readonly AssignmentPlanSection[];
|
||||
plan: AssignmentPlanState;
|
||||
maxSocketsPerCircuit: number | null;
|
||||
}): ResolvedAssignmentPlan {
|
||||
const objectById = new Map(input.objects.map((object) => [object.id, object]));
|
||||
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
|
||||
const rowById = new Map(
|
||||
input.circuits.flatMap((circuit) => circuit.rows.map((row) => [row.id, { row, circuit }] as const))
|
||||
);
|
||||
const sectionById = new Map(input.sections.map((section) => [section.id, section]));
|
||||
const newCircuitByKey = new Map(input.plan.newCircuits.map((entry) => [entry.key, entry]));
|
||||
const newRowChoices = new Set(input.plan.newRowChoices);
|
||||
const issues: string[] = [];
|
||||
|
||||
interface Bucket {
|
||||
ref: AssignmentCircuitRef;
|
||||
sectionId: string;
|
||||
category: AssignmentPlanCategory | null;
|
||||
existing: AssignmentPlanCircuit | null;
|
||||
rowTargets: Map<string, AssignmentPlanObject[]>;
|
||||
circuitTargets: AssignmentPlanObject[];
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const bucketFor = (ref: AssignmentCircuitRef): Bucket | null => {
|
||||
const key = circuitRefKey(ref);
|
||||
const known = buckets.get(key);
|
||||
if (known) return known;
|
||||
let bucket: Bucket;
|
||||
if (ref.kind === "existing") {
|
||||
const circuit = circuitById.get(ref.circuitId);
|
||||
if (!circuit) return null;
|
||||
bucket = {
|
||||
ref, sectionId: circuit.sectionId, category: circuit.category, existing: circuit,
|
||||
rowTargets: new Map(), circuitTargets: [],
|
||||
};
|
||||
} else {
|
||||
const entry = newCircuitByKey.get(ref.key);
|
||||
const section = entry ? sectionById.get(entry.sectionId) : undefined;
|
||||
if (!entry || !section) return null;
|
||||
bucket = {
|
||||
ref, sectionId: section.id, category: section.category, existing: null,
|
||||
rowTargets: new Map(), circuitTargets: [],
|
||||
};
|
||||
}
|
||||
buckets.set(key, bucket);
|
||||
return bucket;
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const assignment of input.plan.assignments) {
|
||||
const object = objectById.get(assignment.objectId);
|
||||
if (!object) {
|
||||
issues.push("Ein geplantes Revit-Objekt ist nicht mehr zuordenbar.");
|
||||
continue;
|
||||
}
|
||||
if (seen.has(object.id)) {
|
||||
issues.push(`Revit-Objekt ${object.worksharingId} ist mehrfach geplant.`);
|
||||
continue;
|
||||
}
|
||||
seen.add(object.id);
|
||||
const target = assignment.target;
|
||||
if (target.kind === "row") {
|
||||
const location = rowById.get(target.rowId);
|
||||
const bucket = location
|
||||
? bucketFor({ kind: "existing", circuitId: location.circuit.id })
|
||||
: null;
|
||||
if (!bucket) {
|
||||
issues.push("Eine Zielzeile existiert nicht mehr.");
|
||||
continue;
|
||||
}
|
||||
const entries = bucket.rowTargets.get(target.rowId) ?? [];
|
||||
entries.push(object);
|
||||
bucket.rowTargets.set(target.rowId, entries);
|
||||
} else {
|
||||
const bucket = bucketFor(
|
||||
target.kind === "circuit"
|
||||
? { kind: "existing", circuitId: target.circuitId }
|
||||
: { kind: "new", key: target.key }
|
||||
);
|
||||
if (!bucket) {
|
||||
issues.push("Ein Zielstromkreis existiert nicht mehr.");
|
||||
continue;
|
||||
}
|
||||
bucket.circuitTargets.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
const circuits: ResolvedCircuitPlan[] = [];
|
||||
for (const bucket of buckets.values()) {
|
||||
const rowAssignments: ResolvedRowAssignment[] = [];
|
||||
const refKey = circuitRefKey(bucket.ref);
|
||||
const joinedRows = new Map<string, AssignmentPlanObject[]>(bucket.rowTargets);
|
||||
const newRowGroups = new Map<string, AssignmentPlanObject[]>();
|
||||
const matchingByGroup = new Map<string, string | null>();
|
||||
|
||||
const groups = new Map<string, AssignmentPlanObject[]>();
|
||||
for (const object of bucket.circuitTargets) {
|
||||
const key = objectGroupKey(object);
|
||||
const entries = groups.get(key) ?? [];
|
||||
entries.push(object);
|
||||
groups.set(key, entries);
|
||||
}
|
||||
for (const [groupKey, objects] of groups) {
|
||||
const matching = bucket.existing ? findMatchingRow(bucket.existing, objects) : null;
|
||||
matchingByGroup.set(groupKey, matching?.id ?? null);
|
||||
if (matching && !newRowChoices.has(newRowChoiceKey(bucket.ref, groupKey))) {
|
||||
joinedRows.set(matching.id, [...(joinedRows.get(matching.id) ?? []), ...objects]);
|
||||
} else {
|
||||
newRowGroups.set(groupKey, objects);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [rowId, objects] of joinedRows) {
|
||||
const row = rowById.get(rowId)!.row;
|
||||
rowAssignments.push({
|
||||
key: `${refKey}|row:${rowId}`,
|
||||
circuit: bucket.ref,
|
||||
row: { kind: "existing", rowId },
|
||||
matchingRowId: null,
|
||||
objectIds: objects.map((object) => object.id),
|
||||
quantity: effectiveQuantity(objects),
|
||||
socketQuantity: socketQuantity(objects),
|
||||
conflicts: objects.flatMap((object) => findRowConflicts(row, object)),
|
||||
issues: findRowIssues(row, bucket.category, objects),
|
||||
});
|
||||
}
|
||||
for (const [groupKey, objects] of newRowGroups) {
|
||||
rowAssignments.push({
|
||||
key: `${refKey}|new:${groupKey}`,
|
||||
circuit: bucket.ref,
|
||||
row: { kind: "new", groupKey },
|
||||
matchingRowId: matchingByGroup.get(groupKey) ?? null,
|
||||
objectIds: objects.map((object) => object.id),
|
||||
quantity: effectiveQuantity(objects),
|
||||
socketQuantity: socketQuantity(objects),
|
||||
conflicts: [],
|
||||
issues: findCategoryIssues(bucket.category, objects),
|
||||
});
|
||||
}
|
||||
|
||||
const before = bucket.existing?.linkedSocketQuantity ?? 0;
|
||||
const after = before + rowAssignments.reduce((sum, row) => sum + row.socketQuantity, 0);
|
||||
const addsSockets = after > before;
|
||||
circuits.push({
|
||||
circuit: bucket.ref,
|
||||
refKey,
|
||||
sectionId: bucket.sectionId,
|
||||
socketQuantityBefore: before,
|
||||
socketQuantityAfter: after,
|
||||
overLimit:
|
||||
addsSockets &&
|
||||
input.maxSocketsPerCircuit !== null &&
|
||||
after > input.maxSocketsPerCircuit,
|
||||
rows: rowAssignments,
|
||||
});
|
||||
}
|
||||
|
||||
const usedNewKeys = new Set(
|
||||
circuits.flatMap((circuit) => (circuit.circuit.kind === "new" ? [circuit.circuit.key] : []))
|
||||
);
|
||||
return {
|
||||
circuits,
|
||||
issues: [...issues, ...circuits.flatMap((circuit) => circuit.rows.flatMap((row) => row.issues))],
|
||||
conflictObjectIds: [
|
||||
...new Set(
|
||||
circuits.flatMap((circuit) =>
|
||||
circuit.rows.flatMap((row) => row.conflicts.map((conflict) => conflict.objectId))
|
||||
)
|
||||
),
|
||||
],
|
||||
overLimitRefKeys: circuits.filter((circuit) => circuit.overLimit).map((circuit) => circuit.refKey),
|
||||
objectCount: seen.size,
|
||||
newCircuitCount: input.plan.newCircuits.filter((entry) => usedNewKeys.has(entry.key)).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a resolved plan into the API request rows. */
|
||||
export function toAssignmentPlanRequestRows(
|
||||
plan: ResolvedAssignmentPlan
|
||||
): AssignmentPlanRequestRow[] {
|
||||
return plan.circuits.flatMap((circuit) =>
|
||||
circuit.rows.map((row): AssignmentPlanRequestRow => {
|
||||
if (row.row.kind === "existing") {
|
||||
return { kind: "existing-row", rowId: row.row.rowId, objectIds: row.objectIds };
|
||||
}
|
||||
return circuit.circuit.kind === "existing"
|
||||
? { kind: "new-row", circuitId: circuit.circuit.circuitId, objectIds: row.objectIds }
|
||||
: { kind: "new-row-in-new-circuit", newCircuitKey: circuit.circuit.key, objectIds: row.objectIds };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export interface RoomwiseSplitResult {
|
||||
/** Object ids per new circuit, in planning order. */
|
||||
circuits: string[][];
|
||||
/** Single objects larger than the limit; each got its own circuit. */
|
||||
oversizedObjectIds: string[];
|
||||
/** Selected objects that are not sockets and stay unplanned. */
|
||||
skippedObjectIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributes socket objects room by room onto new circuits without ever
|
||||
* splitting an object (requirements §11.6):
|
||||
* 1. rooms by room number (natural order), objects without room last,
|
||||
* objects of a room by Worksharing-ID;
|
||||
* 2. a room that fits the remaining capacity joins the current circuit;
|
||||
* 3. otherwise a room that fits an empty circuit starts a new one;
|
||||
* 4. otherwise the room is filled object by object onto fresh circuits.
|
||||
*/
|
||||
export function splitSocketsByRoom(
|
||||
objects: readonly AssignmentPlanObject[],
|
||||
maxSocketsPerCircuit: number
|
||||
): RoomwiseSplitResult {
|
||||
if (!Number.isInteger(maxSocketsPerCircuit) || maxSocketsPerCircuit < 1) {
|
||||
throw new Error("Der Höchstwert muss eine positive ganze Zahl sein.");
|
||||
}
|
||||
const sockets = objects.filter((object) => object.isSocket);
|
||||
const skippedObjectIds = objects.filter((object) => !object.isSocket).map((object) => object.id);
|
||||
const rooms = new Map<string, AssignmentPlanObject[]>();
|
||||
for (const object of sockets) {
|
||||
const key = object.roomId ?? (object.roomNumber ? `source:${object.roomNumber}` : "");
|
||||
const entries = rooms.get(key) ?? [];
|
||||
entries.push(object);
|
||||
rooms.set(key, entries);
|
||||
}
|
||||
const orderedRooms = [...rooms.entries()]
|
||||
.sort(([leftKey, left], [rightKey, right]) => {
|
||||
if (leftKey === "" || rightKey === "") return leftKey === "" ? (rightKey === "" ? 0 : 1) : -1;
|
||||
return compareNatural(left[0]!.roomNumber, right[0]!.roomNumber) ||
|
||||
compareNatural(left[0]!.roomName, right[0]!.roomName) ||
|
||||
compareNatural(leftKey, rightKey);
|
||||
})
|
||||
.map(([, entries]) =>
|
||||
[...entries].sort((left, right) => compareNatural(left.worksharingId, right.worksharingId))
|
||||
);
|
||||
|
||||
const circuits: string[][] = [];
|
||||
const oversizedObjectIds: string[] = [];
|
||||
let current: AssignmentPlanObject[] = [];
|
||||
let currentQuantity = 0;
|
||||
const close = () => {
|
||||
if (current.length > 0) circuits.push(current.map((object) => object.id));
|
||||
current = [];
|
||||
currentQuantity = 0;
|
||||
};
|
||||
for (const room of orderedRooms) {
|
||||
const roomQuantity = socketQuantity(room);
|
||||
if (current.length > 0 && currentQuantity + roomQuantity <= maxSocketsPerCircuit) {
|
||||
current.push(...room);
|
||||
currentQuantity += roomQuantity;
|
||||
continue;
|
||||
}
|
||||
close();
|
||||
if (roomQuantity <= maxSocketsPerCircuit) {
|
||||
current = [...room];
|
||||
currentQuantity = roomQuantity;
|
||||
continue;
|
||||
}
|
||||
for (const object of room) {
|
||||
const quantity = object.planningValues.effectiveQuantity;
|
||||
if (current.length > 0 && currentQuantity + quantity > maxSocketsPerCircuit) close();
|
||||
if (quantity > maxSocketsPerCircuit) oversizedObjectIds.push(object.id);
|
||||
current.push(object);
|
||||
currentQuantity += quantity;
|
||||
}
|
||||
}
|
||||
close();
|
||||
return { circuits, oversizedObjectIds, skippedObjectIds };
|
||||
}
|
||||
|
||||
function compareNatural(left: string, right: string) {
|
||||
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
266
tests/revit-assignment-plan.test.ts
Normal file
266
tests/revit-assignment-plan.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
circuitRefKey,
|
||||
findRowConflicts,
|
||||
newRowChoiceKey,
|
||||
objectGroupKey,
|
||||
resolveAssignmentPlan,
|
||||
splitSocketsByRoom,
|
||||
toAssignmentPlanRequestRows,
|
||||
type AssignmentPlanCircuit,
|
||||
type AssignmentPlanObject,
|
||||
type AssignmentPlanRow,
|
||||
} from "../src/shared/revit/assignment-plan.js";
|
||||
|
||||
function socket(
|
||||
id: string,
|
||||
overrides: Partial<AssignmentPlanObject> & { quantity?: number } = {}
|
||||
): AssignmentPlanObject {
|
||||
const { quantity = 2, ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
worksharingId: `ws-${id}`,
|
||||
familyAndType: "Steckdose: Doppel",
|
||||
selectionMarker: "SD",
|
||||
deviceTypeId: "type-socket",
|
||||
roomId: "room-1",
|
||||
roomNumber: "1.01",
|
||||
roomName: "Büro",
|
||||
linkedProjectDeviceId: null,
|
||||
isSocket: true,
|
||||
planningValues: {
|
||||
displayName: "Steckdose",
|
||||
category: "single_phase",
|
||||
connectionKind: null,
|
||||
effectiveQuantity: quantity,
|
||||
powerPerUnitW: 200,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
costGroup: "440",
|
||||
remark: null,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function row(overrides: Partial<AssignmentPlanRow> = {}): AssignmentPlanRow {
|
||||
return {
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
roomId: "room-1",
|
||||
displayName: "Steckdose",
|
||||
linkedProjectDeviceId: null,
|
||||
category: "single_phase",
|
||||
connectionKind: null,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
costGroup: "440",
|
||||
remark: null,
|
||||
linkedSelectionMarkers: ["SD"],
|
||||
linkedObjectCount: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function circuit(overrides: Partial<AssignmentPlanCircuit> = {}): AssignmentPlanCircuit {
|
||||
return {
|
||||
id: "circuit-1",
|
||||
sectionId: "section-2",
|
||||
category: "single_phase",
|
||||
equipmentIdentifier: "-2F1",
|
||||
displayName: "Steckdose",
|
||||
rows: [row()],
|
||||
linkedSocketQuantity: 4,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "section-1", category: "lighting" as const },
|
||||
{ id: "section-2", category: "single_phase" as const },
|
||||
];
|
||||
|
||||
describe("Revit assignment plan", () => {
|
||||
it("splits 8 double sockets with limit 8 into two circuits of 4 objects", () => {
|
||||
const objects = Array.from({ length: 8 }, (_, index) => socket(`s${index + 1}`));
|
||||
const result = splitSocketsByRoom(objects, 8);
|
||||
assert.deepEqual(result.circuits.map((ids) => ids.length), [4, 4]);
|
||||
assert.deepEqual(result.oversizedObjectIds, []);
|
||||
assert.deepEqual(result.circuits.flat().sort(), objects.map((object) => object.id).sort());
|
||||
});
|
||||
|
||||
it("keeps rooms together and only splits a room when it exceeds the limit", () => {
|
||||
const objects = [
|
||||
...[1, 2].map((index) => socket(`a${index}`, { roomId: "room-a", roomNumber: "1.02" })),
|
||||
...[1, 2, 3].map((index) => socket(`b${index}`, { roomId: "room-b", roomNumber: "1.10" })),
|
||||
...[1, 2].map((index) => socket(`c${index}`, { roomId: "room-c", roomNumber: "1.3" })),
|
||||
...[1, 2, 3, 4, 5].map((index) => socket(`d${index}`, { roomId: "room-d", roomNumber: "2.01" })),
|
||||
];
|
||||
// Natural order 1.02 (4) · 1.3 (4) · 1.10 (6) · 2.01 (10)
|
||||
const result = splitSocketsByRoom(objects, 8);
|
||||
assert.deepEqual(result.circuits, [
|
||||
["a1", "a2", "c1", "c2"],
|
||||
["b1", "b2", "b3"],
|
||||
["d1", "d2", "d3", "d4"],
|
||||
["d5"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives an oversized object its own circuit and skips non-sockets", () => {
|
||||
const result = splitSocketsByRoom(
|
||||
[
|
||||
socket("big", { quantity: 10 }),
|
||||
socket("small", { roomId: "room-2", roomNumber: "1.02" }),
|
||||
socket("light", { isSocket: false }),
|
||||
],
|
||||
8
|
||||
);
|
||||
assert.deepEqual(result.circuits, [["big"], ["small"]]);
|
||||
assert.deepEqual(result.oversizedObjectIds, ["big"]);
|
||||
assert.deepEqual(result.skippedObjectIds, ["light"]);
|
||||
assert.throws(() => splitSocketsByRoom([], 0), /positive ganze Zahl/);
|
||||
});
|
||||
|
||||
it("groups single and double sockets of a room into one row and separates rooms", () => {
|
||||
const single = socket("single", { quantity: 1 });
|
||||
assert.equal(objectGroupKey(single), objectGroupKey(socket("double")));
|
||||
const plan = resolveAssignmentPlan({
|
||||
objects: [single, socket("double"), socket("other-room", { roomId: "room-2" })],
|
||||
circuits: [],
|
||||
sections,
|
||||
plan: {
|
||||
newCircuits: [{ key: "n1", sectionId: "section-2" }],
|
||||
assignments: ["single", "double", "other-room"].map((objectId) => ({
|
||||
objectId,
|
||||
target: { kind: "new-circuit" as const, key: "n1" },
|
||||
})),
|
||||
newRowChoices: [],
|
||||
},
|
||||
maxSocketsPerCircuit: 8,
|
||||
});
|
||||
assert.equal(plan.circuits.length, 1);
|
||||
assert.deepEqual(plan.circuits[0]!.rows.map((entry) => entry.quantity), [3, 2]);
|
||||
assert.equal(plan.circuits[0]!.socketQuantityAfter, 5);
|
||||
assert.equal(plan.newCircuitCount, 1);
|
||||
assert.deepEqual(plan.issues, []);
|
||||
assert.deepEqual(toAssignmentPlanRequestRows(plan).map((entry) => entry.kind), [
|
||||
"new-row-in-new-circuit",
|
||||
"new-row-in-new-circuit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins a matching linked row on circuit drop unless a new row is chosen", () => {
|
||||
const input = {
|
||||
objects: [socket("s1")],
|
||||
circuits: [circuit()],
|
||||
sections,
|
||||
maxSocketsPerCircuit: 8,
|
||||
};
|
||||
const joined = resolveAssignmentPlan({
|
||||
...input,
|
||||
plan: {
|
||||
newCircuits: [],
|
||||
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
|
||||
newRowChoices: [],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(joined.circuits[0]!.rows[0]!.row, { kind: "existing", rowId: "row-1" });
|
||||
assert.equal(joined.circuits[0]!.socketQuantityAfter, 6);
|
||||
|
||||
const separate = resolveAssignmentPlan({
|
||||
...input,
|
||||
plan: {
|
||||
newCircuits: [],
|
||||
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
|
||||
newRowChoices: [
|
||||
newRowChoiceKey({ kind: "existing", circuitId: "circuit-1" }, objectGroupKey(socket("s1"))),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(separate.circuits[0]!.rows[0]!.row.kind, "new");
|
||||
assert.equal(separate.circuits[0]!.rows[0]!.matchingRowId, "row-1");
|
||||
|
||||
const manualOnly = resolveAssignmentPlan({
|
||||
...input,
|
||||
circuits: [circuit({ rows: [row({ linkedObjectCount: 0, linkedSelectionMarkers: [] })] })],
|
||||
plan: {
|
||||
newCircuits: [],
|
||||
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
|
||||
newRowChoices: [],
|
||||
},
|
||||
});
|
||||
assert.equal(manualOnly.circuits[0]!.rows[0]!.row.kind, "new");
|
||||
});
|
||||
|
||||
it("lists planning conflicts and blocking issues for an explicit row drop", () => {
|
||||
const differing = socket("s1", {
|
||||
planningValues: { ...socket("x").planningValues, powerPerUnitW: 250, simultaneityFactor: null },
|
||||
});
|
||||
assert.deepEqual(
|
||||
findRowConflicts(row({ simultaneityFactor: 0.5 }), differing).map((entry) => entry.field),
|
||||
["powerPerUnit", "simultaneityFactor"]
|
||||
);
|
||||
const plan = resolveAssignmentPlan({
|
||||
objects: [differing, socket("s2", { roomId: "room-2", selectionMarker: "X" })],
|
||||
circuits: [circuit()],
|
||||
sections,
|
||||
plan: {
|
||||
newCircuits: [],
|
||||
assignments: [
|
||||
{ objectId: "s1", target: { kind: "row", rowId: "row-1" } },
|
||||
{ objectId: "s2", target: { kind: "row", rowId: "row-1" } },
|
||||
],
|
||||
newRowChoices: [],
|
||||
},
|
||||
maxSocketsPerCircuit: null,
|
||||
});
|
||||
assert.deepEqual(plan.conflictObjectIds, ["s1"]);
|
||||
assert.ok(plan.issues.some((issue) => /Raum/.test(issue)));
|
||||
assert.ok(plan.issues.some((issue) => /Auswahlkenner/.test(issue)));
|
||||
assert.equal(plan.circuits[0]!.overLimit, false);
|
||||
});
|
||||
|
||||
it("marks only circuits that gain sockets above the limit and rejects wrong categories", () => {
|
||||
const plan = resolveAssignmentPlan({
|
||||
objects: [socket("s1"), socket("s2"), socket("s3", { quantity: 4 })],
|
||||
circuits: [circuit()],
|
||||
sections,
|
||||
plan: {
|
||||
newCircuits: [{ key: "light", sectionId: "section-1" }],
|
||||
assignments: [
|
||||
{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } },
|
||||
{ objectId: "s2", target: { kind: "circuit", circuitId: "circuit-1" } },
|
||||
{ objectId: "s3", target: { kind: "new-circuit", key: "light" } },
|
||||
],
|
||||
newRowChoices: [],
|
||||
},
|
||||
maxSocketsPerCircuit: 6,
|
||||
});
|
||||
assert.deepEqual(plan.overLimitRefKeys, ["circuit-1"]);
|
||||
assert.equal(plan.circuits[0]!.socketQuantityAfter, 8);
|
||||
assert.equal(circuitRefKey(plan.circuits[1]!.circuit), "new:light");
|
||||
assert.ok(plan.issues.some((issue) => /Kategorie/.test(issue)));
|
||||
});
|
||||
|
||||
it("reports duplicate and unknown objects", () => {
|
||||
const plan = resolveAssignmentPlan({
|
||||
objects: [socket("s1")],
|
||||
circuits: [],
|
||||
sections,
|
||||
plan: {
|
||||
newCircuits: [{ key: "n1", sectionId: "section-2" }],
|
||||
assignments: [
|
||||
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
|
||||
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
|
||||
{ objectId: "gone", target: { kind: "new-circuit", key: "n1" } },
|
||||
],
|
||||
newRowChoices: [],
|
||||
},
|
||||
maxSocketsPerCircuit: 8,
|
||||
});
|
||||
assert.equal(plan.objectCount, 1);
|
||||
assert.equal(plan.issues.length, 2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue