Integrate rough estimate balance
This commit is contained in:
commit
573487666c
45 changed files with 9378 additions and 58 deletions
42
src/db/migrations/0010_estimate_balances.sql
Normal file
42
src/db/migrations/0010_estimate_balances.sql
Normal 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`);
|
||||
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,13 @@
|
|||
"when": 1790419185882,
|
||||
"tag": "0009_buildings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1790438092269,
|
||||
"tag": "0010_estimate_balances",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal 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;
|
||||
}
|
||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
src/db/schema/estimate-balances.ts
Normal file
60
src/db/schema/estimate-balances.ts
Normal 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)]
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue