The Versorgung field suggests ELT and MSR (plus values already used in the balance) through a datalist and still accepts free text. The circuit number column is not needed in early design phases; it is removed from the data model, the regenerated migration 0010, the editor and the print. All cells are centered except "Verbraucher / Flächen", group titles and total labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
458 lines
16 KiB
TypeScript
458 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",
|
|
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();
|
|
}
|
|
});
|
|
});
|