Integrate rough estimate balance

This commit is contained in:
Julian Appel 2026-09-27 12:37:59 +02:00
commit 573487666c
45 changed files with 9378 additions and 58 deletions

View file

@ -0,0 +1,432 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
import {
addEstimateGroup,
addEstimateRow,
createCopyName,
createEstimateRow,
estimateBalanceFingerprint,
moveEstimateGroup,
moveEstimateRow,
normalizeEstimateSortOrder,
removeEstimateGroup,
removeEstimateRow,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
import {
estimateNumberRanges,
formatAmpere,
formatDecimalInput,
formatDeltaPercent,
formatIsoDateGerman,
formatKw,
formatSignedKw,
getTodayIsoDate,
parseGermanDecimal,
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
import {
ApiRequestError,
createEstimateBalance,
deleteEstimateBalance,
getEstimateBalance,
getEstimateComparison,
isRevisionConflictError,
listEstimateBalances,
updateEstimateBalance,
} from "../src/frontend/utils/api.js";
function sequentialIds(prefix = "id") {
let next = 0;
return () => `${prefix}-${++next}`;
}
function sampleBalance(): EstimateBalanceSnapshot {
return {
id: "balance-1",
projectId: "project-1",
name: "Entwurfsplanung",
workStage: "LPH 3",
dateIso: "2026-09-26",
reserveAvPercent: 20,
reserveSvPercent: 10,
sortOrder: 0,
groups: [
{
id: "group-410",
costGroup: "410",
title: "Sanitärtechnik",
editor: null,
sortOrder: 0,
rows: [
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
],
},
{
id: "group-440",
costGroup: "440",
title: "Starkstromanlagen",
editor: "ELT",
sortOrder: 1,
rows: [],
},
],
};
}
describe("estimate balance number input", () => {
it("accepts a comma as decimal separator", () => {
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
});
it("rejects dots, empty input and garbage", () => {
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
assert.equal(parseGermanDecimal(text).ok, false, text);
}
const dot = parseGermanDecimal("0.8");
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
});
it("checks the server ranges of the document fields", () => {
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
});
it("formats input text so it parses back unchanged", () => {
assert.equal(formatDecimalInput(1200), "1200");
assert.equal(formatDecimalInput(0.8), "0,8");
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
});
});
describe("estimate balance display format", () => {
it("rounds half away from zero like Excel", () => {
assert.equal(formatKw(40.175), "40,18");
assert.equal(formatKw(30.24), "30,24");
assert.equal(formatKw(1234.5), "1.234,50");
assert.equal(formatKw(-0.001), "0,00");
assert.equal(formatAmpere(57.75), "57,75");
assert.equal(formatAmpere(235.749), "235,75");
});
it("shows signed deltas and percentages", () => {
assert.equal(formatSignedKw(1.5), "+1,50");
assert.equal(formatSignedKw(-1.825), "-1,83");
assert.equal(formatSignedKw(0.001), "0,00");
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
assert.equal(formatDeltaPercent(-5), "-5,0 %");
assert.equal(formatDeltaPercent(null), "–");
});
it("formats dates", () => {
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
assert.equal(formatIsoDateGerman(null), "–");
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
});
});
describe("estimate balance document editing", () => {
it("adds rows at the end or after a row with continuous sort orders", () => {
const createId = sequentialIds("new");
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
assert.equal(appended.rowId, "new-1");
assert.deepEqual(
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
);
const inserted = addEstimateRow(sampleBalance(), "group-410", {
afterRowId: "row-a",
createId,
});
assert.deepEqual(
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
);
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
assert.equal(unknownGroup.rowId, null);
});
it("creates rows with neutral defaults", () => {
const row = createEstimateRow(3, () => "row-x");
assert.deepEqual(row, {
id: "row-x",
sortOrder: 3,
supply: null,
name: "",
quantity: 1,
unit: "piece",
connection: null,
powerPerUnitW: 0,
simultaneityFactor: 1,
cosPhi: 1,
safetySupply: false,
remark: null,
});
});
it("updates, removes and moves rows without touching the input", () => {
const original = sampleBalance();
const updated = updateEstimateRow(original, "group-410", "row-b", {
quantity: 3,
safetySupply: true,
});
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
const removed = removeEstimateRow(original, "group-410", "row-a");
assert.deepEqual(
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-b", 0]]
);
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
assert.deepEqual(
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-b", 0], ["row-a", 1]]
);
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
});
it("adds, edits, moves and removes groups", () => {
const added = addEstimateGroup(
sampleBalance(),
{ costGroup: "480", title: "Gebäudeautomation" },
() => "group-480"
);
assert.equal(added.groupId, "group-480");
assert.deepEqual(
added.balance.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
);
assert.equal(added.balance.groups[2]!.editor, null);
const edited = updateEstimateGroup(added.balance, "group-480", {
costGroup: "481",
editor: "MSR",
});
assert.equal(edited.groups[2]!.costGroup, "481");
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
const movedUp = moveEstimateGroup(edited, "group-480", -1);
assert.deepEqual(
movedUp.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
);
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
const removed = removeEstimateGroup(movedUp, "group-410");
assert.deepEqual(
removed.groups.map((group) => [group.id, group.sortOrder]),
[["group-480", 0], ["group-440", 1]]
);
assert.equal(removeEstimateGroup(removed, "missing"), removed);
});
it("orders loaded documents by sortOrder", () => {
const balance = sampleBalance();
const shuffled: EstimateBalanceSnapshot = {
...balance,
groups: [
{ ...balance.groups[1]!, sortOrder: 7 },
{
...balance.groups[0]!,
sortOrder: 2,
rows: [
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
],
},
],
};
const normalized = normalizeEstimateSortOrder(shuffled);
assert.deepEqual(
normalized.groups.map((group) => [group.id, group.sortOrder]),
[["group-410", 0], ["group-440", 1]]
);
assert.deepEqual(
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
[["row-a", 0], ["row-b", 1]]
);
});
});
describe("estimate balance saving", () => {
it("builds the trimmed server document with null for empty optional texts", () => {
let balance = sampleBalance();
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
balance = updateEstimateRow(balance, "group-410", "row-a", {
name: " Hebeanlage ",
supply: " ELT ",
remark: " ",
});
const saveable = toSaveableEstimateBalance(balance);
assert.equal(saveable.name, "Entwurf");
assert.equal(saveable.workStage, null);
assert.equal(saveable.dateIso, null);
assert.equal(saveable.groups[1]!.title, "Starkstrom");
assert.equal(saveable.groups[1]!.editor, null);
assert.deepEqual(
{
name: saveable.groups[0]!.rows[0]!.name,
supply: saveable.groups[0]!.rows[0]!.supply,
remark: saveable.groups[0]!.rows[0]!.remark,
},
{ name: "Hebeanlage", supply: "ELT", remark: null }
);
});
it("copies only snapshot fields", () => {
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
balance.uiState = "leak";
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
});
it("treats whitespace-only changes as unchanged", () => {
const balance = sampleBalance();
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
name: "Hebeanlage ",
});
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
});
it("reports documents the server would reject", () => {
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
const invalid = updateEstimateGroup(
{ ...sampleBalance(), name: " " },
"group-440",
{ title: " " }
);
assert.equal(validateEstimateBalance(invalid).length, 2);
});
it("names copies", () => {
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
assert.equal(createCopyName("x".repeat(200)).length, 200);
});
});
describe("estimate balance frontend API", () => {
it("calls the estimate balance endpoints", async () => {
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
[];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
...(init?.keepalive ? { keepalive: true } : {}),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const balance = sampleBalance();
await listEstimateBalances("project-1");
await getEstimateBalance("project-1", "balance-1");
await createEstimateBalance(
"project-1",
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
4
);
await createEstimateBalance(
"project-1",
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
5
);
await updateEstimateBalance("project-1", balance, 6);
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
await deleteEstimateBalance("project-1", "balance-1", 8);
await getEstimateComparison("project-1", "balance-1");
const base = "/api/projects/project-1/estimate-balances";
assert.deepEqual(requests, [
{ url: base, method: "GET", body: undefined },
{ url: `${base}/balance-1`, method: "GET", body: undefined },
{
url: base,
method: "POST",
body: {
name: "Vorplanung",
workStage: null,
dateIso: "2026-09-26",
template: "kg400",
expectedRevision: 4,
},
},
{
url: base,
method: "POST",
body: {
name: "Kopie",
template: "copy",
copyFromBalanceId: "balance-1",
expectedRevision: 5,
},
},
{
url: `${base}/balance-1`,
method: "PUT",
body: { expectedRevision: 6, balance },
},
{
url: `${base}/balance-1`,
method: "PUT",
body: { expectedRevision: 7, balance },
keepalive: true,
},
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it("exposes status and code of failed requests", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
error: "Projektstand veraltet.",
code: "PROJECT_REVISION_CONFLICT",
}),
{ status: 409, headers: { "Content-Type": "application/json" } }
);
try {
await assert.rejects(
updateEstimateBalance("project-1", sampleBalance(), 1),
(error: unknown) => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, "Projektstand veraltet.");
assert.equal(error.status, 409);
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
assert.equal(isRevisionConflictError(error), true);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
assert.equal(isRevisionConflictError(new Error("x")), false);
});
});

View file

@ -0,0 +1,458 @@
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",
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();
}
});
});

View file

@ -49,6 +49,9 @@ it("upgrades a populated pre-print database through the current schema without c
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
migrate(context.db, { migrationsFolder: source });

View file

@ -11,7 +11,13 @@ import {
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "../src/db/repositories/estimate-balance.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
@ -28,8 +34,48 @@ import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js";
function estimateBalance(projectId: string, suffix = "") {
return parseEstimateBalanceSnapshot({
id: `estimate${suffix}`,
projectId,
name: `Vorplanung${suffix}`,
workStage: "LPH 2",
dateIso: "2026-09-27",
reserveAvPercent: 10,
reserveSvPercent: 5,
sortOrder: 0,
groups: [
{
id: `estimate-group${suffix}`,
costGroup: "440",
title: "Starkstromanlagen",
editor: "Planung",
sortOrder: 0,
rows: [
{
id: `estimate-row${suffix}`,
sortOrder: 0,
supply: "ELT",
name: "Beleuchtung",
quantity: 100,
unit: "area",
connection: "fixed",
powerPerUnitW: 12,
simultaneityFactor: 0.8,
cosPhi: 0.95,
safetySupply: false,
remark: null,
},
],
},
],
});
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
@ -463,14 +509,41 @@ describe("project snapshot repository", () => {
});
describe("portable project transfer", () => {
it("keeps estimate balances outside normal snapshot restore", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const current = readProjectStateSnapshot(context.db, "project-1");
assert.ok(current);
new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "restore",
command: createProjectStateRestoreCommand(current.payloadSha256, {
...current.state,
project: { ...current.state.project, name: "Restored project" },
}),
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate"]
);
} finally {
context.close();
}
});
it("exports and atomically duplicates a complete project with new ids", () => {
const context = createTestDatabase();
try {
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
insertEstimateBalance(context.db, estimateBalance("project-1"));
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
assert.equal(transfer.formatVersion, 2);
assert.equal(transfer.estimateBalances.length, 1);
const duplicate = repository.importDuplicate(transfer);
assert.notEqual(duplicate.projectId, "project-1");
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
@ -541,6 +614,18 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].simultaneityFactor,
0.65
);
const copiedBalances = readEstimateBalances(context.db, duplicate.projectId);
assert.equal(copiedBalances.length, 1);
assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id);
assert.notEqual(
copiedBalances[0].groups[0].id,
transfer.estimateBalances[0].groups[0].id
);
assert.notEqual(
copiedBalances[0].groups[0].rows[0].id,
transfer.estimateBalances[0].groups[0].rows[0].id
);
assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung");
} finally {
context.close();
}
@ -566,6 +651,14 @@ describe("portable project transfer", () => {
}),
/checksum verification failed/
);
assert.throws(
() =>
repository.importDuplicate({
...transfer,
estimateBalances: [estimateBalance("project-1", "-tampered")],
}),
/checksum verification failed/
);
assert.equal(
context.db.select().from(projects).all().length,
1
@ -575,15 +668,79 @@ describe("portable project transfer", () => {
}
});
it("imports legacy transfer version 1 with no estimate balances", () => {
const context = createTestDatabase();
try {
const snapshot = readProjectStateSnapshot(context.db, "project-1");
assert.ok(snapshot);
const legacyTransfer = {
format: "leistungsbilanz.project",
formatVersion: 1,
exportedAtIso: "2026-09-27T12:00:00.000Z",
payloadSha256: snapshot.payloadSha256,
projectState: snapshot.state,
};
const duplicate = new ProjectTransferRepository(context.db).importDuplicate(
legacyTransfer
);
assert.deepEqual(
readEstimateBalances(context.db, duplicate.projectId),
[]
);
} finally {
context.close();
}
});
it("replaces exported estimate balances atomically and restores them on undo", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
deleteEstimateBalance(context.db, "project-1", "estimate");
insertEstimateBalance(context.db, estimateBalance("project-1", "-changed"));
const prepared = transferRepository.prepareReplace("project-1", transfer);
assert.ok(prepared);
const restored = new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "restore",
command: prepared.command,
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate"]
);
new ProjectStateRestoreCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.deepEqual(
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
["estimate-changed"]
);
} finally {
context.close();
}
});
it("rolls back a duplicate when a late project-state insert fails", () => {
const context = createTestDatabase();
try {
insertEstimateBalance(context.db, estimateBalance("project-1"));
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
context.sqlite.exec(`
CREATE TRIGGER fail_imported_device_row
BEFORE INSERT ON circuit_device_rows
CREATE TRIGGER fail_imported_estimate_row
BEFORE INSERT ON estimate_rows
BEGIN
SELECT RAISE(ABORT, 'forced project transfer failure');
END;