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