forked from jappel/leistungsbilanz-ts
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>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { z } from "zod";
|
|
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
|
import {
|
|
estimateDefaultGroups,
|
|
type EstimateBalanceSnapshot,
|
|
type EstimateGroupSnapshot,
|
|
} from "../../shared/estimate-balance/estimate-balance.js";
|
|
|
|
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
|
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
|
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
|
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
|
|
|
const idSchema = z.string().trim().min(1).max(100);
|
|
const trimmed = (max: number) =>
|
|
z.string().max(max).refine((value) => value === value.trim(), {
|
|
message: "must be trimmed",
|
|
});
|
|
const nullableText = (max: number) =>
|
|
trimmed(max)
|
|
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
|
.nullable();
|
|
const finite = z.number().finite();
|
|
|
|
export const estimateRowSnapshotSchema = z
|
|
.object({
|
|
id: idSchema,
|
|
sortOrder: z.number().int().min(0),
|
|
supply: nullableText(50),
|
|
name: trimmed(200),
|
|
quantity: finite.min(0),
|
|
unit: z.enum(["piece", "area"]),
|
|
connection: z.enum(["fixed", "socket"]).nullable(),
|
|
powerPerUnitW: finite.min(0),
|
|
simultaneityFactor: finite.min(0).max(1),
|
|
cosPhi: finite.gt(0).max(1),
|
|
safetySupply: z.boolean(),
|
|
remark: nullableText(500),
|
|
})
|
|
.strict();
|
|
|
|
export const estimateGroupSnapshotSchema = z
|
|
.object({
|
|
id: idSchema,
|
|
costGroup: trimmed(20),
|
|
title: trimmed(200).refine((value) => value.length > 0, {
|
|
message: "title must not be empty",
|
|
}),
|
|
editor: nullableText(100),
|
|
sortOrder: z.number().int().min(0),
|
|
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
|
})
|
|
.strict();
|
|
|
|
export const estimateBalanceSnapshotSchema = z
|
|
.object({
|
|
id: idSchema,
|
|
projectId: idSchema,
|
|
name: trimmed(200).refine((value) => value.length > 0, {
|
|
message: "name must not be empty",
|
|
}),
|
|
workStage: nullableText(50),
|
|
dateIso: z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
|
message: "invalid date",
|
|
})
|
|
.nullable(),
|
|
reserveAvPercent: finite.min(0).max(100),
|
|
reserveSvPercent: finite.min(0).max(100),
|
|
sortOrder: z.number().int().min(0),
|
|
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
|
})
|
|
.strict()
|
|
.superRefine((balance, context) => {
|
|
const ids = new Set<string>();
|
|
for (const group of balance.groups) {
|
|
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
|
if (ids.has(id)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: `duplicate id ${id}`,
|
|
path: ["groups"],
|
|
});
|
|
}
|
|
ids.add(id);
|
|
}
|
|
}
|
|
});
|
|
|
|
export interface EstimateBalanceInsertProjectCommand
|
|
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
type: typeof estimateBalanceInsertCommandType;
|
|
}
|
|
|
|
export interface EstimateBalanceDeleteProjectCommand
|
|
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
type: typeof estimateBalanceDeleteCommandType;
|
|
}
|
|
|
|
export interface EstimateBalanceUpdateProjectCommand
|
|
extends SerializedProjectCommand<{
|
|
expected: EstimateBalanceSnapshot;
|
|
target: EstimateBalanceSnapshot;
|
|
}> {
|
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
type: typeof estimateBalanceUpdateCommandType;
|
|
}
|
|
|
|
export type EstimateBalanceProjectCommand =
|
|
| EstimateBalanceInsertProjectCommand
|
|
| EstimateBalanceDeleteProjectCommand
|
|
| EstimateBalanceUpdateProjectCommand;
|
|
|
|
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
|
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
|
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
|
return normalizeEstimateBalanceSnapshot(parsed);
|
|
}
|
|
|
|
export function normalizeEstimateBalanceSnapshot(
|
|
balance: EstimateBalanceSnapshot
|
|
): EstimateBalanceSnapshot {
|
|
return {
|
|
...balance,
|
|
groups: balance.groups.map((group, groupIndex) => ({
|
|
...group,
|
|
sortOrder: groupIndex,
|
|
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** Stable JSON for exact equality checks of two snapshots. */
|
|
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
|
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
|
return JSON.stringify({
|
|
id: normalized.id,
|
|
projectId: normalized.projectId,
|
|
name: normalized.name,
|
|
workStage: normalized.workStage,
|
|
dateIso: normalized.dateIso,
|
|
reserveAvPercent: normalized.reserveAvPercent,
|
|
reserveSvPercent: normalized.reserveSvPercent,
|
|
sortOrder: normalized.sortOrder,
|
|
groups: normalized.groups.map((group) => ({
|
|
id: group.id,
|
|
costGroup: group.costGroup,
|
|
title: group.title,
|
|
editor: group.editor,
|
|
sortOrder: group.sortOrder,
|
|
rows: group.rows.map((row) => ({
|
|
id: row.id,
|
|
sortOrder: row.sortOrder,
|
|
supply: row.supply,
|
|
name: row.name,
|
|
quantity: row.quantity,
|
|
unit: row.unit,
|
|
connection: row.connection,
|
|
powerPerUnitW: row.powerPerUnitW,
|
|
simultaneityFactor: row.simultaneityFactor,
|
|
cosPhi: row.cosPhi,
|
|
safetySupply: row.safetySupply,
|
|
remark: row.remark,
|
|
})),
|
|
})),
|
|
});
|
|
}
|
|
|
|
export function sameEstimateBalance(
|
|
left: EstimateBalanceSnapshot,
|
|
right: EstimateBalanceSnapshot
|
|
): boolean {
|
|
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
|
}
|
|
|
|
export function createEstimateBalanceSnapshot(input: {
|
|
projectId: string;
|
|
name: string;
|
|
workStage: string | null;
|
|
dateIso: string | null;
|
|
sortOrder: number;
|
|
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
|
createId?: () => string;
|
|
}): EstimateBalanceSnapshot {
|
|
const createId = input.createId ?? (() => crypto.randomUUID());
|
|
let groups: EstimateGroupSnapshot[] = [];
|
|
let reserveAvPercent = 10;
|
|
let reserveSvPercent = 0;
|
|
if (input.template === "kg400") {
|
|
groups = estimateDefaultGroups.map((group, index) => ({
|
|
id: createId(),
|
|
costGroup: group.costGroup,
|
|
title: group.title,
|
|
editor: null,
|
|
sortOrder: index,
|
|
rows: [],
|
|
}));
|
|
} else if (input.template !== "empty") {
|
|
const source = input.template.copyFrom;
|
|
reserveAvPercent = source.reserveAvPercent;
|
|
reserveSvPercent = source.reserveSvPercent;
|
|
groups = source.groups.map((group) => ({
|
|
...group,
|
|
id: createId(),
|
|
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
|
}));
|
|
}
|
|
return parseEstimateBalanceSnapshot({
|
|
id: createId(),
|
|
projectId: input.projectId,
|
|
name: input.name.trim(),
|
|
workStage: input.workStage?.trim() || null,
|
|
dateIso: input.dateIso || null,
|
|
reserveAvPercent,
|
|
reserveSvPercent,
|
|
sortOrder: input.sortOrder,
|
|
groups,
|
|
});
|
|
}
|
|
|
|
export function createEstimateBalanceInsertProjectCommand(
|
|
balance: EstimateBalanceSnapshot
|
|
): EstimateBalanceInsertProjectCommand {
|
|
return {
|
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
type: estimateBalanceInsertCommandType,
|
|
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
};
|
|
}
|
|
|
|
export function createEstimateBalanceDeleteProjectCommand(
|
|
balance: EstimateBalanceSnapshot
|
|
): EstimateBalanceDeleteProjectCommand {
|
|
return {
|
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
type: estimateBalanceDeleteCommandType,
|
|
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
};
|
|
}
|
|
|
|
export function createEstimateBalanceUpdateProjectCommand(
|
|
expected: EstimateBalanceSnapshot,
|
|
target: EstimateBalanceSnapshot
|
|
): EstimateBalanceUpdateProjectCommand {
|
|
const payload = {
|
|
expected: parseEstimateBalanceSnapshot(expected),
|
|
target: parseEstimateBalanceSnapshot(target),
|
|
};
|
|
assertMatchingUpdate(payload.expected, payload.target);
|
|
return {
|
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
type: estimateBalanceUpdateCommandType,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function assertMatchingUpdate(
|
|
expected: EstimateBalanceSnapshot,
|
|
target: EstimateBalanceSnapshot
|
|
) {
|
|
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
|
throw new Error("Estimate-balance update must keep id and project.");
|
|
}
|
|
if (sameEstimateBalance(expected, target)) {
|
|
throw new Error("Estimate-balance update must change the balance.");
|
|
}
|
|
}
|
|
|
|
function assertCommandShape(
|
|
command: SerializedProjectCommand<unknown>,
|
|
type: string,
|
|
keys: string[]
|
|
): Record<string, unknown> {
|
|
const payload = command.payload;
|
|
if (
|
|
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
|
command.type !== type ||
|
|
payload === null ||
|
|
typeof payload !== "object" ||
|
|
Array.isArray(payload) ||
|
|
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
|
) {
|
|
throw new Error("Unsupported estimate-balance command.");
|
|
}
|
|
return payload as Record<string, unknown>;
|
|
}
|
|
|
|
export function assertEstimateBalanceInsertProjectCommand(
|
|
command: SerializedProjectCommand<unknown>
|
|
): asserts command is EstimateBalanceInsertProjectCommand {
|
|
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
|
parseEstimateBalanceSnapshot(payload.balance);
|
|
}
|
|
|
|
export function assertEstimateBalanceDeleteProjectCommand(
|
|
command: SerializedProjectCommand<unknown>
|
|
): asserts command is EstimateBalanceDeleteProjectCommand {
|
|
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
|
parseEstimateBalanceSnapshot(payload.balance);
|
|
}
|
|
|
|
export function assertEstimateBalanceUpdateProjectCommand(
|
|
command: SerializedProjectCommand<unknown>
|
|
): asserts command is EstimateBalanceUpdateProjectCommand {
|
|
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
|
"expected",
|
|
"target",
|
|
]);
|
|
assertMatchingUpdate(
|
|
parseEstimateBalanceSnapshot(payload.expected),
|
|
parseEstimateBalanceSnapshot(payload.target)
|
|
);
|
|
}
|