forked from jappel/leistungsbilanz-ts
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>
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
|
||
// Numbers are entered and shown in German notation. As in the circuit list
|
||
// editor, only "," is accepted as decimal separator: a "." is ambiguous
|
||
// (decimal point or thousands separator) and is rejected instead of guessed.
|
||
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
|
||
|
||
export type DecimalParseResult =
|
||
| { ok: true; value: number }
|
||
| { ok: false; error: string };
|
||
|
||
export interface DecimalRange {
|
||
min?: number;
|
||
/** Excludes `min` itself (e.g. cos φ > 0). */
|
||
minExclusive?: boolean;
|
||
max?: number;
|
||
}
|
||
|
||
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
|
||
|
||
function formatBound(value: number) {
|
||
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
|
||
}
|
||
|
||
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) {
|
||
return { ok: false, error: "Bitte einen Wert eingeben." };
|
||
}
|
||
if (trimmed.includes(".")) {
|
||
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
|
||
}
|
||
if (!DECIMAL_PATTERN.test(trimmed)) {
|
||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||
}
|
||
const value = Number(trimmed.replace(",", "."));
|
||
if (!Number.isFinite(value)) {
|
||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||
}
|
||
if (range.min !== undefined) {
|
||
if (range.minExclusive ? value <= range.min : value < range.min) {
|
||
return {
|
||
ok: false,
|
||
error: range.minExclusive
|
||
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
|
||
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
|
||
};
|
||
}
|
||
}
|
||
if (range.max !== undefined && value > range.max) {
|
||
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
|
||
}
|
||
return { ok: true, value };
|
||
}
|
||
|
||
/** Value ranges accepted by the server for the numeric document fields. */
|
||
export const estimateNumberRanges = {
|
||
quantity: { min: 0 },
|
||
powerPerUnitW: { min: 0 },
|
||
simultaneityFactor: { min: 0, max: 1 },
|
||
cosPhi: { min: 0, minExclusive: true, max: 1 },
|
||
reservePercent: { min: 0, max: 100 },
|
||
} satisfies Record<string, DecimalRange>;
|
||
|
||
/**
|
||
* Text of a numeric input field: German comma, no thousands separator, so the
|
||
* shown text can be parsed again unchanged.
|
||
*/
|
||
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
|
||
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
|
||
}
|
||
|
||
/**
|
||
* Read-only display of a calculated value with thousands separator ("1.200",
|
||
* "30,24"). The value is rounded half away from zero first, like Excel does
|
||
* (40,175 → 40,18 instead of the binary-rounded 40,17).
|
||
*/
|
||
export function formatGermanNumber(
|
||
value: number,
|
||
minimumFractionDigits: number,
|
||
maximumFractionDigits = minimumFractionDigits
|
||
): string {
|
||
const rounded = roundEstimateValue(value, maximumFractionDigits);
|
||
// "+ 0" turns -0 into 0 and avoids "-0,00".
|
||
return (rounded + 0).toLocaleString("de-DE", {
|
||
minimumFractionDigits,
|
||
maximumFractionDigits,
|
||
});
|
||
}
|
||
|
||
export function formatKw(value: number) {
|
||
return formatGermanNumber(value, 2);
|
||
}
|
||
|
||
export function formatAmpere(value: number) {
|
||
return formatGermanNumber(value, 2);
|
||
}
|
||
|
||
export function formatSignedKw(value: number) {
|
||
const text = formatKw(value);
|
||
return value > 0 && text !== "0,00" ? `+${text}` : text;
|
||
}
|
||
|
||
export function formatDeltaPercent(value: number | null) {
|
||
if (value === null) {
|
||
return "–";
|
||
}
|
||
const text = formatGermanNumber(value, 1);
|
||
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
|
||
}
|
||
|
||
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
|
||
export function formatIsoDateGerman(dateIso: string | null): string {
|
||
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
|
||
}
|
||
|
||
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
|
||
export function getTodayIsoDate(now: Date = new Date()): string {
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const day = String(now.getDate()).padStart(2, "0");
|
||
return `${now.getFullYear()}-${month}-${day}`;
|
||
}
|