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,42 @@
CREATE TABLE `estimate_balances` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`work_stage` text,
`date_iso` text,
`reserve_av_percent` real DEFAULT 0 NOT NULL,
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
CREATE TABLE `estimate_groups` (
`id` text PRIMARY KEY NOT NULL,
`balance_id` text NOT NULL,
`cost_group` text DEFAULT '' NOT NULL,
`title` text NOT NULL,
`editor` text,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
CREATE TABLE `estimate_rows` (
`id` text PRIMARY KEY NOT NULL,
`group_id` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`supply` text,
`name` text DEFAULT '' NOT NULL,
`quantity` real DEFAULT 0 NOT NULL,
`unit` text DEFAULT 'piece' NOT NULL,
`connection` text,
`power_per_unit_w` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real DEFAULT 1 NOT NULL,
`safety_supply` integer DEFAULT false NOT NULL,
`remark` text,
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1790419185882,
"tag": "0009_buildings",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790438092269,
"tag": "0010_estimate_balances",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,132 @@
import { eq } from "drizzle-orm";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
normalizeEstimateBalanceSnapshot,
sameEstimateBalance,
type EstimateBalanceProjectCommand,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type {
EstimateBalanceProjectCommandStore,
ExecuteEstimateBalanceCommandInput,
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
export class EstimateBalanceProjectCommandRepository
implements EstimateBalanceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteEstimateBalanceCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: EstimateBalanceProjectCommand
): EstimateBalanceProjectCommand {
switch (command.type) {
case estimateBalanceInsertCommandType:
assertEstimateBalanceInsertProjectCommand(command);
return this.insert(database, projectId, command.payload.balance);
case estimateBalanceDeleteCommandType:
assertEstimateBalanceDeleteProjectCommand(command);
return this.delete(database, projectId, command.payload.balance);
case estimateBalanceUpdateCommandType:
assertEstimateBalanceUpdateProjectCommand(command);
return this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
}
}
private insert(
database: AppDatabase,
projectId: string,
balance: EstimateBalanceSnapshot
) {
if (balance.projectId !== projectId) {
throw new Error("Estimate-balance snapshot does not belong to project.");
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: estimateBalances.id })
.from(estimateBalances)
.where(eq(estimateBalances.id, balance.id))
.get();
if (existing) {
throw new Error("Estimate-balance id already exists.");
}
const normalized = normalizeEstimateBalanceSnapshot(balance);
insertEstimateBalance(database, normalized);
return createEstimateBalanceDeleteProjectCommand(normalized);
}
private delete(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "deletion");
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
throw new Error("Estimate balance could not be deleted.");
}
return createEstimateBalanceInsertProjectCommand(expected);
}
private update(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "update");
// Group and row ids stay stable; replacing the whole document keeps the
// command exact and its inverse trivially the swapped pair.
deleteEstimateBalance(database, projectId, expected.id);
const normalized = normalizeEstimateBalanceSnapshot(target);
insertEstimateBalance(database, normalized);
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
}
private assertPersisted(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
action: string
) {
const [persisted] = readEstimateBalances(database, projectId, expected.id);
if (!persisted || !sameEstimateBalance(persisted, expected)) {
throw new Error(`Estimate balance changed before ${action}.`);
}
}
}

View file

@ -0,0 +1,121 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import {
estimateBalances,
estimateGroups,
estimateRows,
} from "../schema/estimate-balances.js";
/** Reads complete balance documents of a project, ordered like the UI shows them. */
export function readEstimateBalances(
database: AppDatabase,
projectId: string,
balanceId?: string
): EstimateBalanceSnapshot[] {
const balances = database
.select()
.from(estimateBalances)
.where(
balanceId
? and(
eq(estimateBalances.projectId, projectId),
eq(estimateBalances.id, balanceId)
)
: eq(estimateBalances.projectId, projectId)
)
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
.all();
if (!balances.length) {
return [];
}
const groups = database
.select()
.from(estimateGroups)
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
.all();
const rows = groups.length
? database
.select()
.from(estimateRows)
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
.all()
: [];
return balances.map((balance) => ({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
groups: groups
.filter((group) => group.balanceId === balance.id)
.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: rows
.filter((row) => row.groupId === group.id)
.map(({ groupId: _groupId, ...row }) => row),
})),
}));
}
/** Writes a complete balance document (the caller removed an older version first). */
export function insertEstimateBalance(
database: AppDatabase,
balance: EstimateBalanceSnapshot
) {
database
.insert(estimateBalances)
.values({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
})
.run();
for (const group of balance.groups) {
database
.insert(estimateGroups)
.values({
id: group.id,
balanceId: balance.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
})
.run();
if (group.rows.length) {
database
.insert(estimateRows)
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
.run();
}
}
}
/** Removes a balance with its groups and rows (foreign keys cascade). */
export function deleteEstimateBalance(
database: AppDatabase,
projectId: string,
balanceId: string
) {
return database
.delete(estimateBalances)
.where(
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
)
.run().changes;
}

View file

@ -0,0 +1,60 @@
import { asc, eq } from "drizzle-orm";
import type {
DetailedDeviceRowPower,
EstimateBalanceSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { readEstimateBalances } from "./estimate-balance.persistence.js";
/** Read side of the estimate-balance module; writes go through commands. */
export class EstimateBalanceRepository {
constructor(private readonly database: AppDatabase) {}
listByProject(projectId: string): EstimateBalanceSnapshot[] {
return readEstimateBalances(this.database, projectId);
}
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
}
nextSortOrder(projectId: string): number {
const rows = this.database
.select({ sortOrder: estimateBalances.sortOrder })
.from(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.orderBy(asc(estimateBalances.sortOrder))
.all();
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
}
/**
* Device rows of all circuit lists of the project with their total power
* (quantity · power per unit · device GZF, like the circuit tree).
*/
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
return this.database
.select({
costGroup: circuitDeviceRows.costGroup,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all()
.map((row) => ({
costGroup: row.costGroup,
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
cosPhi: row.cosPhi,
}));
}
}

View file

@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
import {
assertProjectStateRestoreCommand,
createProjectStateRestoreCommand,
createProjectTransferRestoreCommand,
projectStateRestoreCommandSchemaVersion,
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
@ -13,6 +15,8 @@ import type {
ProjectStateRestoreCommandStore,
} from "../../domain/ports/project-state-restore-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
@ -32,6 +36,11 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
@ -66,10 +75,39 @@ export class ProjectStateRestoreCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
const transferCommand =
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
? input.command
: null;
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
if (
transferCommand &&
canonicalEstimateBalances(currentEstimateBalances) !==
canonicalEstimateBalances(
transferCommand.payload.expectedEstimateBalances
) &&
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
@ -82,14 +120,28 @@ export class ProjectStateRestoreCommandRepository
input.command.payload.targetState
);
replaceProjectState(tx, targetState);
if (transferCommand) {
replaceEstimateBalances(
tx,
input.projectId,
transferCommand.payload.targetEstimateBalances
);
}
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
const inverse = transferCommand
? createProjectTransferRestoreCommand(
persistedTarget.payloadSha256,
current.state,
transferCommand.payload.targetEstimateBalances,
currentEstimateBalances
)
: createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
return inverse;
}
@ -98,7 +150,8 @@ export class ProjectStateRestoreCommandRepository
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
currentState: ProjectStateSnapshot,
currentEstimateBalances: EstimateBalanceSnapshot[]
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
@ -122,25 +175,45 @@ function matchesHistoryRestoreTarget(
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
const command = JSON.parse(serialized) as unknown;
assertProjectStateRestoreCommand(command);
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
const stateMatches =
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState);
if (!stateMatches) {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
canonicalEstimateBalances(currentEstimateBalances);
} catch {
return false;
}
}
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
return JSON.stringify(
[...balances]
.sort((left, right) => left.id.localeCompare(right.id))
.map((balance) => canonicalEstimateBalance(balance))
);
}
function replaceEstimateBalances(
database: AppDatabase,
projectId: string,
balances: EstimateBalanceSnapshot[]
) {
database
.delete(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.run();
for (const balance of balances) {
insertEstimateBalance(database, balance);
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,

View file

@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapEstimateBalances,
remapProjectState,
serializeProjectTransferPayload,
} from "../../domain/models/project-transfer.model.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
@ -26,6 +28,10 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
@ -40,10 +46,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
if (!snapshot) {
return null;
}
const estimateBalances = readEstimateBalances(this.database, projectId);
const payloadSha256 = hashProjectStatePayload(
serializeProjectTransferPayload(snapshot.state, estimateBalances)
);
return createProjectTransferEnvelope(
new Date().toISOString(),
snapshot.payloadSha256,
snapshot.state
payloadSha256,
snapshot.state,
estimateBalances
);
}
@ -61,10 +72,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
projectId,
() => crypto.randomUUID()
);
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
const targetEstimateBalances =
transfer.projectState.project.id === projectId
? transfer.estimateBalances
: remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
return {
command: createProjectStateRestoreCommand(
command: createProjectTransferRestoreCommand(
current.payloadSha256,
targetState
targetState,
currentEstimateBalances,
targetEstimateBalances
),
};
}
@ -78,20 +100,39 @@ export class ProjectTransferRepository implements ProjectTransferStore {
() => crypto.randomUUID(),
`${transfer.projectState.project.name} (Kopie)`
);
this.database.transaction((tx) => insertProjectState(tx, state));
const estimateBalances = remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
this.database.transaction((tx) => {
insertProjectState(tx, state);
for (const balance of estimateBalances) {
insertEstimateBalance(tx, balance);
}
});
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
? hashProjectStatePayload(
isLegacy
? JSON.stringify(value.projectState)
: JSON.stringify({
projectState: value.projectState,
estimateBalances: value.estimateBalances,
})
)
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
const payloadJson = isLegacy
? serializeProjectStateSnapshot(transfer.projectState)
: serializeProjectTransferPayload(
transfer.projectState,
transfer.estimateBalances
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256

View file

@ -0,0 +1,60 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
// circuit structure. See docs/estimate-balance.md.
export const estimateBalances = sqliteTable(
"estimate_balances",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workStage: text("work_stage"),
dateIso: text("date_iso"),
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
);
export const estimateGroups = sqliteTable(
"estimate_groups",
{
id: text("id").primaryKey(),
balanceId: text("balance_id")
.notNull()
.references(() => estimateBalances.id, { onDelete: "cascade" }),
costGroup: text("cost_group").notNull().default(""),
title: text("title").notNull(),
editor: text("editor"),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
);
export const estimateRows = sqliteTable(
"estimate_rows",
{
id: text("id").primaryKey(),
groupId: text("group_id")
.notNull()
.references(() => estimateGroups.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
supply: text("supply"),
name: text("name").notNull().default(""),
quantity: real("quantity").notNull().default(0),
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
connection: text("connection", { enum: ["fixed", "socket"] }),
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
cosPhi: real("cos_phi").notNull().default(1),
safetySupply: integer("safety_supply", { mode: "boolean" })
.notNull()
.default(false),
remark: text("remark"),
},
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
);