forked from jappel/leistungsbilanz-ts
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
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1).max(100);
|
||||
const trimmed = (max: number) =>
|
||||
z.string().max(max).refine((value) => value === value.trim(), {
|
||||
message: "must be trimmed",
|
||||
});
|
||||
const nullableText = (max: number) =>
|
||||
trimmed(max)
|
||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
||||
.nullable();
|
||||
const finite = z.number().finite();
|
||||
|
||||
export const estimateRowSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
sortOrder: z.number().int().min(0),
|
||||
supply: nullableText(50),
|
||||
circuitRef: nullableText(50),
|
||||
name: trimmed(200),
|
||||
quantity: finite.min(0),
|
||||
unit: z.enum(["piece", "area"]),
|
||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
||||
powerPerUnitW: finite.min(0),
|
||||
simultaneityFactor: finite.min(0).max(1),
|
||||
cosPhi: finite.gt(0).max(1),
|
||||
safetySupply: z.boolean(),
|
||||
remark: nullableText(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateGroupSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
costGroup: trimmed(20),
|
||||
title: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "title must not be empty",
|
||||
}),
|
||||
editor: nullableText(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateBalanceSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "name must not be empty",
|
||||
}),
|
||||
workStage: nullableText(50),
|
||||
dateIso: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
||||
message: "invalid date",
|
||||
})
|
||||
.nullable(),
|
||||
reserveAvPercent: finite.min(0).max(100),
|
||||
reserveSvPercent: finite.min(0).max(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((balance, context) => {
|
||||
const ids = new Set<string>();
|
||||
for (const group of balance.groups) {
|
||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
||||
if (ids.has(id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `duplicate id ${id}`,
|
||||
path: ["groups"],
|
||||
});
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface EstimateBalanceInsertProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceInsertCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceDeleteProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceUpdateProjectCommand
|
||||
extends SerializedProjectCommand<{
|
||||
expected: EstimateBalanceSnapshot;
|
||||
target: EstimateBalanceSnapshot;
|
||||
}> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceUpdateCommandType;
|
||||
}
|
||||
|
||||
export type EstimateBalanceProjectCommand =
|
||||
| EstimateBalanceInsertProjectCommand
|
||||
| EstimateBalanceDeleteProjectCommand
|
||||
| EstimateBalanceUpdateProjectCommand;
|
||||
|
||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
||||
return normalizeEstimateBalanceSnapshot(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEstimateBalanceSnapshot(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: balance.groups.map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable JSON for exact equality checks of two snapshots. */
|
||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||
return JSON.stringify({
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: normalized.name,
|
||||
workStage: normalized.workStage,
|
||||
dateIso: normalized.dateIso,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: row.supply,
|
||||
circuitRef: row.circuitRef,
|
||||
name: row.name,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: row.remark,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameEstimateBalance(
|
||||
left: EstimateBalanceSnapshot,
|
||||
right: EstimateBalanceSnapshot
|
||||
): boolean {
|
||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
||||
}
|
||||
|
||||
export function createEstimateBalanceSnapshot(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
dateIso: string | null;
|
||||
sortOrder: number;
|
||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
||||
createId?: () => string;
|
||||
}): EstimateBalanceSnapshot {
|
||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
||||
let groups: EstimateGroupSnapshot[] = [];
|
||||
let reserveAvPercent = 10;
|
||||
let reserveSvPercent = 0;
|
||||
if (input.template === "kg400") {
|
||||
groups = estimateDefaultGroups.map((group, index) => ({
|
||||
id: createId(),
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: [],
|
||||
}));
|
||||
} else if (input.template !== "empty") {
|
||||
const source = input.template.copyFrom;
|
||||
reserveAvPercent = source.reserveAvPercent;
|
||||
reserveSvPercent = source.reserveSvPercent;
|
||||
groups = source.groups.map((group) => ({
|
||||
...group,
|
||||
id: createId(),
|
||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||
}));
|
||||
}
|
||||
return parseEstimateBalanceSnapshot({
|
||||
id: createId(),
|
||||
projectId: input.projectId,
|
||||
name: input.name.trim(),
|
||||
workStage: input.workStage?.trim() || null,
|
||||
dateIso: input.dateIso || null,
|
||||
reserveAvPercent,
|
||||
reserveSvPercent,
|
||||
sortOrder: input.sortOrder,
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEstimateBalanceInsertProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceInsertProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceInsertCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceDeleteProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceDeleteProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceDeleteCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceUpdateProjectCommand(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
): EstimateBalanceUpdateProjectCommand {
|
||||
const payload = {
|
||||
expected: parseEstimateBalanceSnapshot(expected),
|
||||
target: parseEstimateBalanceSnapshot(target),
|
||||
};
|
||||
assertMatchingUpdate(payload.expected, payload.target);
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceUpdateCommandType,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatchingUpdate(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
) {
|
||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
||||
throw new Error("Estimate-balance update must keep id and project.");
|
||||
}
|
||||
if (sameEstimateBalance(expected, target)) {
|
||||
throw new Error("Estimate-balance update must change the balance.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandShape(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type: string,
|
||||
keys: string[]
|
||||
): Record<string, unknown> {
|
||||
const payload = command.payload;
|
||||
if (
|
||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
Array.isArray(payload) ||
|
||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
||||
) {
|
||||
throw new Error("Unsupported estimate-balance command.");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
||||
"expected",
|
||||
"target",
|
||||
]);
|
||||
assertMatchingUpdate(
|
||||
parseEstimateBalanceSnapshot(payload.expected),
|
||||
parseEstimateBalanceSnapshot(payload.target)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue