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>
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
import {
|
|
createDatabaseContext,
|
|
type DatabaseContext,
|
|
} from "../src/db/database-context.js";
|
|
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
|
|
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
|
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|
import { circuits } from "../src/db/schema/circuits.js";
|
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
|
import { estimateRows } from "../src/db/schema/estimate-balances.js";
|
|
import { projects } from "../src/db/schema/projects.js";
|
|
import {
|
|
createEstimateBalanceDeleteProjectCommand,
|
|
createEstimateBalanceInsertProjectCommand,
|
|
createEstimateBalanceSnapshot,
|
|
createEstimateBalanceUpdateProjectCommand,
|
|
parseEstimateBalanceSnapshot,
|
|
type EstimateBalanceProjectCommand,
|
|
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
|
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
|
|
import {
|
|
calculateEstimateBalance,
|
|
calculateEstimateRow,
|
|
compareEstimateWithDetailed,
|
|
estimateDefaultGroups,
|
|
matchEstimateCostGroup,
|
|
roundEstimateValue,
|
|
type EstimateBalanceSnapshot,
|
|
type EstimateRowSnapshot,
|
|
} from "../src/shared/estimate-balance/estimate-balance.js";
|
|
|
|
const round2 = (value: number) => roundEstimateValue(value, 2);
|
|
let nextId = 0;
|
|
const id = () => `id-${++nextId}`;
|
|
|
|
function row(
|
|
name: string,
|
|
quantity: number,
|
|
powerPerUnitW: number,
|
|
simultaneityFactor: number,
|
|
cosPhi: number,
|
|
extra: Partial<EstimateRowSnapshot> = {}
|
|
): EstimateRowSnapshot {
|
|
return {
|
|
id: id(),
|
|
sortOrder: 0,
|
|
supply: "ELT",
|
|
circuitRef: null,
|
|
name,
|
|
quantity,
|
|
unit: "piece",
|
|
connection: null,
|
|
powerPerUnitW,
|
|
simultaneityFactor,
|
|
cosPhi,
|
|
safetySupply: false,
|
|
remark: null,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
|
|
function templateExample(): EstimateBalanceSnapshot {
|
|
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
|
|
"410": [
|
|
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
|
|
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
|
|
],
|
|
"420": [
|
|
row("Fernwärmestation", 1, 1000, 0.8, 1),
|
|
row("Pumpen", 4, 200, 0.8, 0.9),
|
|
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
|
|
],
|
|
"430": [
|
|
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
|
|
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
|
|
],
|
|
"434": [
|
|
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
|
|
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
|
|
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
|
|
],
|
|
"440": [
|
|
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
|
|
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
|
|
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
|
|
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
|
|
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
|
|
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
|
|
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
|
|
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
|
|
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
|
|
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
|
|
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
|
|
],
|
|
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
|
|
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
|
|
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
|
|
"480": [],
|
|
"": [row("Allgemein", 1, 2000, 1, 0.95)],
|
|
};
|
|
return {
|
|
id: "balance-1",
|
|
projectId: "project-1",
|
|
name: "Entwurfsplanung",
|
|
workStage: "LPH 3",
|
|
dateIso: "2023-06-30",
|
|
reserveAvPercent: 10,
|
|
reserveSvPercent: 0,
|
|
sortOrder: 0,
|
|
groups: estimateDefaultGroups.map((group, index) => ({
|
|
id: `group-${group.costGroup || "allg"}`,
|
|
costGroup: group.costGroup,
|
|
title: group.title,
|
|
editor: null,
|
|
sortOrder: index,
|
|
rows: rowsByGroup[group.costGroup] ?? [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
function createTestDatabase(): DatabaseContext {
|
|
const context = createDatabaseContext(":memory:");
|
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
context.db
|
|
.insert(projects)
|
|
.values([
|
|
{ id: "project-1", name: "Test project" },
|
|
{ id: "project-2", name: "Foreign project" },
|
|
])
|
|
.run();
|
|
return context;
|
|
}
|
|
|
|
describe("estimate balance calculation", () => {
|
|
it("reproduces the office template totals", () => {
|
|
const result = calculateEstimateBalance(templateExample(), 400);
|
|
const groupTotals = result.groups.map((group) => [
|
|
round2(group.av.kw),
|
|
round2(group.av.kva),
|
|
]);
|
|
assert.deepEqual(groupTotals, [
|
|
[35.52, 35.52],
|
|
[3.04, 3.11],
|
|
[6.4, 7.11],
|
|
[3.2, 3.2],
|
|
[40.18, 43.31],
|
|
[12, 12.63],
|
|
[11.2, 11.79],
|
|
[29.7, 29.7],
|
|
[0, 0],
|
|
[2, 2.11],
|
|
]);
|
|
assert.equal(round2(result.total.av.kw), 143.24);
|
|
assert.equal(round2(result.total.av.kva), 148.48);
|
|
assert.equal(round2(result.reserve.av.kw), 14.32);
|
|
assert.equal(round2(result.reserve.av.kva), 14.85);
|
|
assert.equal(round2(result.withReserve.av.kw), 157.56);
|
|
assert.equal(round2(result.withReserve.av.kva), 163.33);
|
|
assert.equal(round2(result.maxCurrentA.av), 235.75);
|
|
assert.equal(result.total.sv.kw, 0);
|
|
assert.equal(result.maxCurrentA.sv, 0);
|
|
});
|
|
|
|
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
|
|
const area = calculateEstimateRow({
|
|
quantity: 250,
|
|
powerPerUnitW: 12,
|
|
simultaneityFactor: 0.8,
|
|
cosPhi: 0.95,
|
|
safetySupply: true,
|
|
});
|
|
assert.equal(round2(area.av.kw), 2.4);
|
|
assert.equal(round2(area.av.kva), 2.53);
|
|
assert.deepEqual(area.sv, area.av);
|
|
const balance = templateExample();
|
|
balance.reserveSvPercent = 20;
|
|
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
|
|
const result = calculateEstimateBalance(balance, 400);
|
|
assert.equal(round2(result.total.sv.kw), 1.6);
|
|
assert.equal(round2(result.withReserve.sv.kw), 1.92);
|
|
assert.equal(round2(result.total.av.kw), 143.24);
|
|
});
|
|
|
|
it("rounds displayed values like the spreadsheet", () => {
|
|
assert.equal(roundEstimateValue(40.175), 40.18);
|
|
assert.equal(roundEstimateValue(143.235), 143.24);
|
|
assert.equal(roundEstimateValue(-1.825), -1.83);
|
|
assert.equal(roundEstimateValue(0.711111), 0.71);
|
|
assert.equal(roundEstimateValue(235.749, 1), 235.7);
|
|
});
|
|
|
|
it("matches detailed cost groups to the most specific estimate group", () => {
|
|
const codes = ["410", "430", "434", "440", "480", ""];
|
|
assert.equal(matchEstimateCostGroup("444", codes), "440");
|
|
assert.equal(matchEstimateCostGroup("434", codes), "434");
|
|
assert.equal(matchEstimateCostGroup("431", codes), "430");
|
|
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
|
|
assert.equal(matchEstimateCostGroup("4", codes), null);
|
|
assert.equal(matchEstimateCostGroup("", codes), null);
|
|
assert.equal(matchEstimateCostGroup(null, codes), null);
|
|
assert.equal(matchEstimateCostGroup("450", codes), null);
|
|
});
|
|
|
|
it("compares the estimate with detailed device rows per cost group", () => {
|
|
const comparison = compareEstimateWithDetailed(templateExample(), [
|
|
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
|
|
{ costGroup: "445", kw: 12, cosPhi: null },
|
|
{ costGroup: "412", kw: 40, cosPhi: 1 },
|
|
{ costGroup: null, kw: 5, cosPhi: 1 },
|
|
{ costGroup: "399", kw: 1, cosPhi: 1 },
|
|
]);
|
|
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
|
|
assert.equal(round2(kg440.detailed.kw), 42);
|
|
assert.equal(round2(kg440.detailed.kva), 45.33);
|
|
assert.equal(kg440.detailedRowCount, 2);
|
|
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
|
|
assert.equal(round2(kg440.deltaPercent!), 4.54);
|
|
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
|
|
assert.equal(round2(kg410.deltaKw), 4.48);
|
|
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
|
|
assert.equal(kg480.deltaPercent, null);
|
|
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
|
|
assert.equal(round2(comparison.total.detailed.kw), 88);
|
|
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
|
|
});
|
|
});
|
|
|
|
describe("estimate balance snapshots and commands", () => {
|
|
it("validates snapshots strictly", () => {
|
|
const valid = templateExample();
|
|
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
|
|
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
|
|
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
|
|
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
|
|
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
|
|
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
|
|
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
|
|
(balance) => (balance.groups[0]!.title = ""),
|
|
(balance) => (balance.dateIso = "30.06.2023"),
|
|
(balance) => (balance.reserveAvPercent = 101),
|
|
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
|
|
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
|
|
];
|
|
for (const mutate of invalid) {
|
|
const balance = structuredClone(valid);
|
|
mutate(balance);
|
|
assert.throws(() => parseEstimateBalanceSnapshot(balance));
|
|
}
|
|
assert.equal(
|
|
createEstimateBalanceRequestSchema.safeParse({
|
|
expectedRevision: 0,
|
|
name: "Entwurf",
|
|
template: "copy",
|
|
}).success,
|
|
false
|
|
);
|
|
});
|
|
|
|
it("creates the KG 400 template and copies a Stand with new ids", () => {
|
|
const created = createEstimateBalanceSnapshot({
|
|
projectId: "project-1",
|
|
name: " Vorplanung ",
|
|
workStage: "LPH 2",
|
|
dateIso: "2026-09-26",
|
|
sortOrder: 0,
|
|
template: "kg400",
|
|
});
|
|
assert.equal(created.name, "Vorplanung");
|
|
assert.deepEqual(
|
|
created.groups.map((group) => group.costGroup),
|
|
estimateDefaultGroups.map((group) => group.costGroup)
|
|
);
|
|
assert.equal(created.reserveAvPercent, 10);
|
|
const source = templateExample();
|
|
const copy = createEstimateBalanceSnapshot({
|
|
projectId: "project-1",
|
|
name: "Entwurfsplanung Kopie",
|
|
workStage: null,
|
|
dateIso: null,
|
|
sortOrder: 1,
|
|
template: { copyFrom: source },
|
|
});
|
|
assert.notEqual(copy.id, source.id);
|
|
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
|
|
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
|
|
assert.equal(
|
|
round2(calculateEstimateBalance(copy, 400).total.av.kw),
|
|
143.24
|
|
);
|
|
});
|
|
|
|
it("inserts, updates and deletes balances with persisted undo and redo", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
const store = new EstimateBalanceProjectCommandRepository(context.db);
|
|
const reader = new EstimateBalanceRepository(context.db);
|
|
const history = new ProjectHistoryRepository(context.db);
|
|
let revision = 0;
|
|
const run = (
|
|
command: EstimateBalanceProjectCommand,
|
|
source: "user" | "undo" | "redo" = "user"
|
|
) => {
|
|
const step = source === "user" ? null : history.getNextCommand("project-1", source);
|
|
const result = store.execute({
|
|
projectId: "project-1",
|
|
expectedRevision: revision,
|
|
source,
|
|
historyTargetChangeSetId: step?.changeSetId,
|
|
command,
|
|
});
|
|
revision += 1;
|
|
return result;
|
|
};
|
|
|
|
const balance = templateExample();
|
|
const insert = createEstimateBalanceInsertProjectCommand(balance);
|
|
const inserted = run(insert);
|
|
const stored = reader.findById("project-1", balance.id);
|
|
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
|
|
assert.equal(reader.listByProject("project-2").length, 0);
|
|
|
|
const target = structuredClone(stored!);
|
|
target.groups[0]!.rows.splice(1, 1);
|
|
target.groups[0]!.rows[0]!.quantity = 10;
|
|
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
|
|
target.groups[8]!.editor = "MSR";
|
|
target.reserveAvPercent = 15;
|
|
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
|
|
const updated = run(update);
|
|
assert.deepEqual(
|
|
reader.findById("project-1", balance.id),
|
|
parseEstimateBalanceSnapshot(target)
|
|
);
|
|
|
|
run(updated.inverse, "undo");
|
|
assert.deepEqual(reader.findById("project-1", balance.id), stored);
|
|
run(update, "redo");
|
|
assert.deepEqual(
|
|
reader.findById("project-1", balance.id),
|
|
parseEstimateBalanceSnapshot(target)
|
|
);
|
|
|
|
// A stale expected document is rejected.
|
|
assert.throws(
|
|
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
|
|
/changed before update/
|
|
);
|
|
assert.throws(
|
|
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
|
|
/must change/
|
|
);
|
|
|
|
const current = reader.findById("project-1", balance.id)!;
|
|
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
|
|
assert.equal(reader.findById("project-1", balance.id), null);
|
|
assert.equal(context.db.select().from(estimateRows).all().length, 0);
|
|
run(deleted.inverse, "undo");
|
|
assert.deepEqual(reader.findById("project-1", balance.id), current);
|
|
// The original insert's inverse no longer matches the edited balance.
|
|
assert.throws(() => run(inserted.inverse), /changed before deletion/);
|
|
run(createEstimateBalanceDeleteProjectCommand(current));
|
|
assert.equal(reader.listByProject("project-1").length, 0);
|
|
|
|
assert.throws(
|
|
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
|
|
/does not belong to project/
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
|
|
it("sums detailed device rows of all circuit lists of the project", () => {
|
|
const context = createTestDatabase();
|
|
try {
|
|
context.db
|
|
.insert(distributionBoards)
|
|
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
|
|
.run();
|
|
context.db
|
|
.insert(circuitLists)
|
|
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
|
|
.run();
|
|
context.db
|
|
.insert(circuitSections)
|
|
.values({
|
|
id: "section-1",
|
|
circuitListId: "list-1",
|
|
key: "lighting-1",
|
|
displayName: "Beleuchtung 1",
|
|
prefix: "1F1",
|
|
sortOrder: 0,
|
|
})
|
|
.run();
|
|
context.db
|
|
.insert(circuits)
|
|
.values({
|
|
id: "circuit-1",
|
|
circuitListId: "list-1",
|
|
sectionId: "section-1",
|
|
equipmentIdentifier: "-1F1",
|
|
displayName: "Licht",
|
|
sortOrder: 0,
|
|
})
|
|
.run();
|
|
context.db
|
|
.insert(circuitDeviceRows)
|
|
.values([
|
|
{
|
|
id: "row-1",
|
|
circuitId: "circuit-1",
|
|
name: "Leuchte",
|
|
displayName: "Leuchte",
|
|
costGroup: "445",
|
|
quantity: 10,
|
|
powerPerUnit: 0.05,
|
|
simultaneityFactor: 0.8,
|
|
cosPhi: 0.95,
|
|
},
|
|
{
|
|
id: "row-2",
|
|
circuitId: "circuit-1",
|
|
name: "Steckdose",
|
|
displayName: "Steckdose",
|
|
costGroup: null,
|
|
quantity: 2,
|
|
powerPerUnit: 0.2,
|
|
simultaneityFactor: 0.5,
|
|
cosPhi: null,
|
|
},
|
|
])
|
|
.run();
|
|
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
|
|
"project-1"
|
|
);
|
|
assert.deepEqual(
|
|
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
|
|
[
|
|
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
|
|
{ costGroup: null, kw: 0.2, cosPhi: null },
|
|
]
|
|
);
|
|
assert.equal(
|
|
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
|
|
0
|
|
);
|
|
} finally {
|
|
context.close();
|
|
}
|
|
});
|
|
});
|