Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80dbc73230
commit
37a70b6b36
37 changed files with 8537 additions and 4 deletions
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||
// The editor keeps the complete document as local draft and saves it as a
|
||||
// whole (PUT), so every edit is a function draft → new draft.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateGroupSnapshot,
|
||||
EstimateRowSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
|
||||
export type MoveDirection = -1 | 1;
|
||||
|
||||
/** Maximum text lengths accepted by the server. */
|
||||
export const estimateTextLimits = {
|
||||
balanceName: 200,
|
||||
workStage: 50,
|
||||
costGroup: 20,
|
||||
groupTitle: 200,
|
||||
groupEditor: 100,
|
||||
supply: 50,
|
||||
circuitRef: 50,
|
||||
rowName: 200,
|
||||
remark: 500,
|
||||
} as const;
|
||||
|
||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||
|
||||
export function createEstimateRow(
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
sortOrder,
|
||||
supply: null,
|
||||
circuitRef: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateGroup(
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateGroupSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
costGroup: input.costGroup,
|
||||
title: input.title,
|
||||
editor: input.editor ?? null,
|
||||
sortOrder,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
|
||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||
export function normalizeEstimateSortOrder(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||
...row,
|
||||
sortOrder: rowIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||
return items.map((item, index) =>
|
||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||
);
|
||||
}
|
||||
|
||||
function mapGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
let changed = false;
|
||||
const groups = balance.groups.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
const next = update(group);
|
||||
changed = next !== group;
|
||||
return next;
|
||||
});
|
||||
// An unknown group or a no-op keeps the identical document.
|
||||
return changed ? { ...balance, groups } : balance;
|
||||
}
|
||||
|
||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||
export function addEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
options: { afterRowId?: string; createId?: () => string } = {}
|
||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||
let rowId: string | null = null;
|
||||
const next = mapGroup(balance, groupId, (group) => {
|
||||
const row = createEstimateRow(0, options.createId);
|
||||
rowId = row.id;
|
||||
const afterIndex = options.afterRowId
|
||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||
: -1;
|
||||
const rows = [...group.rows];
|
||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||
return { ...group, rows: renumber(rows) };
|
||||
});
|
||||
return { balance: next, rowId };
|
||||
}
|
||||
|
||||
export function updateEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
patch: EstimateRowPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function moveEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => {
|
||||
const rows = moveItem(
|
||||
group.rows,
|
||||
group.rows.findIndex((row) => row.id === rowId),
|
||||
direction
|
||||
);
|
||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a group and returns its id. */
|
||||
export function addEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
createId: () => string = createClientId
|
||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||
return {
|
||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||
groupId: group.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
patch: EstimateGroupPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||
}
|
||||
|
||||
export function removeEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||
return groups.length === balance.groups.length
|
||||
? balance
|
||||
: { ...balance, groups: renumber(groups) };
|
||||
}
|
||||
|
||||
export function moveEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = moveItem(
|
||||
balance.groups,
|
||||
balance.groups.findIndex((group) => group.id === groupId),
|
||||
direction
|
||||
);
|
||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||
}
|
||||
|
||||
function requiredText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact document the server accepts: strings trimmed, empty optional
|
||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||
* type are copied, so no UI state can leak into the request.
|
||||
*/
|
||||
export function toSaveableEstimateBalance(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
return {
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: requiredText(normalized.name),
|
||||
workStage: optionalText(normalized.workStage),
|
||||
dateIso: normalized.dateIso || null,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: requiredText(group.costGroup),
|
||||
title: requiredText(group.title),
|
||||
editor: optionalText(group.editor),
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: optionalText(row.supply),
|
||||
circuitRef: optionalText(row.circuitRef),
|
||||
name: requiredText(row.name),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: optionalText(row.remark),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable comparison key of the saveable document (dirty detection). */
|
||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||
}
|
||||
|
||||
/**
|
||||
* German problems that would make the server reject the document. The
|
||||
* editor never saves while this list is not empty.
|
||||
*/
|
||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||
const problems: string[] = [];
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
if (!saveable.name) {
|
||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||
}
|
||||
for (const group of saveable.groups) {
|
||||
if (!group.title) {
|
||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function createCopyName(name: string) {
|
||||
const copyName = `${name.trim()} (Kopie)`;
|
||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue