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

@ -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;