import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot, } from "./circuit-device-row-structure-project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; export const externalObjectRowAssignmentCommandType = "external-object.update-row-assignment" as const; export const externalObjectRowAssignmentCommandSchemaVersion = 1 as const; export interface ExternalObjectRowAssignmentPayload { rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>; objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>; confirmedConflictObjectIds: string[]; } export interface ExternalObjectRowAssignmentProjectCommand extends SerializedProjectCommand { schemaVersion: typeof externalObjectRowAssignmentCommandSchemaVersion; type: typeof externalObjectRowAssignmentCommandType; } export function createExternalObjectRowAssignmentProjectCommand( payload: ExternalObjectRowAssignmentPayload ): ExternalObjectRowAssignmentProjectCommand { const normalizedPayload: ExternalObjectRowAssignmentPayload = { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target), })), confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds], }; const command: ExternalObjectRowAssignmentProjectCommand = { schemaVersion: externalObjectRowAssignmentCommandSchemaVersion, type: externalObjectRowAssignmentCommandType, payload: normalizedPayload, }; assertExternalObjectRowAssignmentProjectCommand(command); return command; } export function invertExternalObjectRowAssignmentProjectCommand( command: ExternalObjectRowAssignmentProjectCommand ) { return createExternalObjectRowAssignmentProjectCommand({ rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })), objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })), confirmedConflictObjectIds: [...command.payload.confirmedConflictObjectIds], }); } export function assertExternalObjectRowAssignmentProjectCommand( command: SerializedProjectCommand ): asserts command is ExternalObjectRowAssignmentProjectCommand { if ( command.schemaVersion !== externalObjectRowAssignmentCommandSchemaVersion || command.type !== externalObjectRowAssignmentCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.rows) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.confirmedConflictObjectIds) || command.payload.rows.length === 0 || command.payload.objects.length === 0 ) { throw new Error("Unsupported external object row-assignment command."); } const rowIds = new Set(); for (const transition of command.payload.rows) { if (!isRecord(transition)) throw new Error("Invalid row assignment transition."); const expected = parseRow(transition.expected); const target = parseRow(transition.target); if (expected.manualQuantity === undefined || target.manualQuantity === undefined) { throw new Error("Row assignment requires an explicit manual quantity."); } if (expected.id !== target.id || rowIds.has(expected.id)) { throw new Error("Row assignment contains mismatched or duplicate rows."); } rowIds.add(expected.id); if (expected.manualQuantity !== target.manualQuantity) { throw new Error("Row assignment must preserve the manual quantity."); } assertOnlyFieldChanged( expected as unknown as Record, target as unknown as Record, "quantity", "row" ); } const objectIds = new Set(); const affectedRowIds = new Set(); for (const transition of command.payload.objects) { if (!isRecord(transition)) throw new Error("Invalid object assignment transition."); const expected = parseObject(transition.expected); const target = parseObject(transition.target); if (expected.id !== target.id || objectIds.has(expected.id)) { throw new Error("Row assignment contains mismatched or duplicate objects."); } objectIds.add(expected.id); if (expected.circuitDeviceRowId === target.circuitDeviceRowId) { throw new Error("External object row assignment must change its row link."); } assertOnlyFieldChanged( expected as unknown as Record, target as unknown as Record, "circuitDeviceRowId", "external object" ); if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId); if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId); } const confirmedIds = new Set(); for (const objectId of command.payload.confirmedConflictObjectIds) { if ( typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId) ) { throw new Error("Row assignment contains an invalid conflict confirmation."); } confirmedIds.add(objectId); } if ( affectedRowIds.size !== rowIds.size || [...affectedRowIds].some((rowId) => !rowIds.has(rowId)) ) { throw new Error("Row assignment must include every and only affected device row."); } } function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; } function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value }, }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; } function parseObject(value: unknown): ExternalModelObjectSnapshot { const state = parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value], }); return state.objects[0]!; } function assertOnlyFieldChanged( expected: Record, target: Record, allowedField: string, label: string ) { const expectedRest = { ...expected }; const targetRest = { ...target }; delete expectedRest[allowedField]; delete targetRest[allowedField]; if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { throw new Error(`Row assignment may only change the ${label} assignment state.`); } } function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (isRecord(value)) { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}` ).join(",")}}`; } return JSON.stringify(value); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }