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

@ -3,32 +3,80 @@ import {
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
parseEstimateBalanceSnapshot,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export const projectStateRestoreCommandSchemaVersion = 2 as const;
const snapshotRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type ProjectStateRestoreCommand = SerializedProjectCommand<
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export interface ProjectTransferRestoreCommandPayload
extends ProjectStateRestoreCommandPayload {
expectedEstimateBalances: EstimateBalanceSnapshot[];
targetEstimateBalances: EstimateBalanceSnapshot[];
}
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
ProjectTransferRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export type ProjectStateRestoreCommand =
| SnapshotProjectStateRestoreCommand
| ProjectTransferRestoreCommand;
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: ProjectStateRestoreCommand = {
const command: SnapshotProjectStateRestoreCommand = {
schemaVersion: snapshotRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function createProjectTransferRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot,
expectedEstimateBalances: EstimateBalanceSnapshot[],
targetEstimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferRestoreCommand {
const command: ProjectTransferRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
expectedEstimateBalances: parseEstimateBalances(
expectedEstimateBalances,
targetState.project.id
),
targetEstimateBalances: parseEstimateBalances(
targetEstimateBalances,
targetState.project.id
),
},
};
assertProjectStateRestoreCommand(command);
@ -42,10 +90,7 @@ export function assertProjectStateRestoreCommand(
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
if (command.type !== projectStateRestoreCommandType) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
@ -58,5 +103,43 @@ export function assertProjectStateRestoreCommand(
) {
throw new Error("Expected project state checksum is invalid.");
}
parseProjectStateSnapshot(payload.targetState);
const targetState = parseProjectStateSnapshot(payload.targetState);
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
if (Object.keys(payload).length !== 2) {
throw new Error("Snapshot restore payload contains unknown fields.");
}
return;
}
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
throw new Error("Unsupported project state restore command.");
}
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
parseEstimateBalances(
transferPayload.expectedEstimateBalances,
targetState.project.id
);
parseEstimateBalances(
transferPayload.targetEstimateBalances,
targetState.project.id
);
if (Object.keys(payload).length !== 4) {
throw new Error("Project transfer restore payload contains unknown fields.");
}
}
function parseEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
throw new Error("Project transfer contains duplicate estimate balances.");
}
return balances;
}

View file

@ -3,9 +3,12 @@ import {
projectStateSnapshotSchemaVersion,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectTransferFormat = "leistungsbilanz.project" as const;
export const projectTransferFormatVersion = 1 as const;
export const projectTransferFormatVersion = 2 as const;
const legacyProjectTransferFormatVersion = 1 as const;
export interface ProjectTransferEnvelope {
format: typeof projectTransferFormat;
@ -13,12 +16,14 @@ export interface ProjectTransferEnvelope {
exportedAtIso: string;
payloadSha256: string;
projectState: ProjectStateSnapshot;
estimateBalances: EstimateBalanceSnapshot[];
}
export function createProjectTransferEnvelope(
exportedAtIso: string,
payloadSha256: string,
projectState: ProjectStateSnapshot
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferEnvelope {
return parseProjectTransferEnvelope({
format: projectTransferFormat,
@ -26,6 +31,7 @@ export function createProjectTransferEnvelope(
exportedAtIso,
payloadSha256,
projectState,
estimateBalances,
});
}
@ -37,7 +43,8 @@ export function parseProjectTransferEnvelope(
}
if (
value.format !== projectTransferFormat ||
value.formatVersion !== projectTransferFormatVersion
(value.formatVersion !== legacyProjectTransferFormatVersion &&
value.formatVersion !== projectTransferFormatVersion)
) {
throw new Error("Project transfer format is not supported.");
}
@ -57,7 +64,13 @@ export function parseProjectTransferEnvelope(
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project transfer snapshot version is not supported.");
}
if (Object.keys(value).length !== 5) {
const estimateBalances =
value.formatVersion === legacyProjectTransferFormatVersion
? []
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
const expectedFieldCount =
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
if (Object.keys(value).length !== expectedFieldCount) {
throw new Error("Project transfer file contains unknown fields.");
}
return {
@ -66,9 +79,43 @@ export function parseProjectTransferEnvelope(
exportedAtIso: value.exportedAtIso,
payloadSha256: value.payloadSha256,
projectState,
estimateBalances,
};
}
export function serializeProjectTransferPayload(
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
) {
const parsedState = parseProjectStateSnapshot(projectState);
return JSON.stringify({
projectState: parsedState,
estimateBalances: parseTransferEstimateBalances(
estimateBalances,
parsedState.project.id
),
});
}
export function remapEstimateBalances(
source: EstimateBalanceSnapshot[],
targetProjectId: string,
createId: () => string
): EstimateBalanceSnapshot[] {
return source.map((balance) =>
parseEstimateBalanceSnapshot({
...balance,
id: createId(),
projectId: targetProjectId,
groups: balance.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
})),
})
);
}
export function remapProjectState(
source: ProjectStateSnapshot,
targetProjectId: string,
@ -282,6 +329,37 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
return targetId;
}
function parseTransferEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
assertUniqueIds(
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
"estimate group"
);
assertUniqueIds(
balances.flatMap((balance) =>
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
),
"estimate row"
);
return balances;
}
function assertUniqueIds(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) {
throw new Error(`Project transfer contains a duplicate ${label} id.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}