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(); 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, type: string, keys: string[] ): Record { 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; } export function assertEstimateBalanceInsertProjectCommand( command: SerializedProjectCommand ): asserts command is EstimateBalanceInsertProjectCommand { const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]); parseEstimateBalanceSnapshot(payload.balance); } export function assertEstimateBalanceDeleteProjectCommand( command: SerializedProjectCommand ): asserts command is EstimateBalanceDeleteProjectCommand { const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]); parseEstimateBalanceSnapshot(payload.balance); } export function assertEstimateBalanceUpdateProjectCommand( command: SerializedProjectCommand ): asserts command is EstimateBalanceUpdateProjectCommand { const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [ "expected", "target", ]); assertMatchingUpdate( parseEstimateBalanceSnapshot(payload.expected), parseEstimateBalanceSnapshot(payload.target) ); }