import type { Sequelize, Transaction } from "sequelize"; import { QueryTypes } from "sequelize"; import type { AssetInput, AssetSpecificationData } from "../../domain/asset-values"; import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values"; import type { AssetPersistenceMapper } from "./asset-persistence.mapper"; import { readAssetRows, sqlIdentifier, writeAssetRow } from "./asset-relational-sql"; export type InventoryRow = { id: string; companyId: string; templateId: string; siteId: string | null; brandId: string | null; values: unknown; specification: unknown; }; export type RevisionRow = { id: string; assetId: string; actorId: string; recordedAt: string; position: number; siteId: string | null; brandId: string | null; values: unknown; }; type AssetRow = Omit & { name: string; status: string; notes: string; brandDefined: boolean; templateName: string; sectionsDefined: boolean; }; type RevisionColumns = Omit & { name: string; status: string; notes: string; brandDefined: boolean; templateId: string; }; type SpecificationField = Omit & { assetId: string; fieldType: string; position: number; }; type SpecificationSection = { assetId: string; id: string; name: string; description: string; position: number; }; type SpecificationChoice = { assetId: string; fieldId: string; position: number; value: string }; type CharacteristicRow = { assetId: string; revisionId?: string; fieldId: string; valueType: string; textValue: string | null; numberValue: number | null; booleanValue: boolean | null; dateValue: string | null; }; export function decodeCharacteristic( row: Omit ) { const populated = [row.textValue, row.numberValue, row.booleanValue, row.dateValue].filter( (value) => value !== null ); if (populated.length !== 1) { throw new Error("Valor tipado de activo persistido inconsistente."); } switch (row.valueType) { case "text": case "multiline": case "choice": if (row.textValue !== null) { return row.textValue; } break; case "number": if (row.numberValue !== null) { return Number(row.numberValue); } break; case "boolean": if (row.booleanValue !== null) { return Boolean(row.booleanValue); } break; case "date": if (row.dateValue !== null) { return row.dateValue; } break; } throw new Error("Tipo de característica persistida inconsistente."); } function characteristicRows( assetId: string, values: AssetInput, specification: AssetSpecificationData, revisionId?: string ) { return Object.entries(values.characteristics).map(([fieldId, value]) => { const type = specification.fields.find((field) => field.fieldId === fieldId)?.type; if (!type) { throw new Error("Campo de activo sin especificación."); } return { assetId, ...(revisionId ? { revisionId } : {}), fieldId, valueType: type, textValue: type === "text" || type === "multiline" || type === "choice" ? String(value) : null, numberValue: type === "number" && typeof value === "number" ? value : null, booleanValue: type === "boolean" && typeof value === "boolean" ? value : null, dateValue: type === "date" ? String(value) : null, }; }); } export async function readInventory( database: Sequelize, companyId: string, transaction: Transaction ) { const assets = await readAssetRows( database, "assets", "companyId", [companyId], transaction ); const ids = assets.map((row) => row.id); const fields = await readAssetRows( database, "asset_specification_fields", "assetId", ids, transaction ); const sections = await readAssetRows( database, "asset_specification_sections", "assetId", ids, transaction ); const choices = await readAssetRows( database, "asset_specification_field_choices", "assetId", ids, transaction ); const current = await readCharacteristics(database, "asset_characteristics", ids, transaction); const histories = await readAssetRows( database, "asset_revisions", "assetId", ids, transaction ); const historical = await readCharacteristics( database, "asset_revision_characteristics", ids, transaction ); const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) => AssetInputSchema.parse({ name: row.name, templateId: row.templateId, siteId: row.siteId, ...(row.brandDefined ? { brandId: row.brandId } : {}), status: row.status, notes: row.notes, characteristics: Object.fromEntries( entries.map((entry) => [entry.fieldId, decodeCharacteristic(entry)]) ), }); const rows: InventoryRow[] = assets.map((row) => ({ ...row, values: values( row, current.filter((entry) => entry.assetId === row.id) ), specification: AssetSpecificationSchema.parse({ templateId: row.templateId, templateName: row.templateName, ...(row.sectionsDefined ? { sections: sections .filter((section) => section.assetId === row.id) .sort((a, b) => a.position - b.position) .map(({ id, name, description }) => ({ id, name, description })), } : {}), fields: fields .filter((field) => field.assetId === row.id) .sort((a, b) => a.position - b.position) .map((field) => ({ fieldId: field.fieldId, name: field.name, type: field.fieldType, unit: field.unit, required: Boolean(field.required), ...(field.sectionId ? { sectionId: field.sectionId } : {}), choices: choices .filter((choice) => choice.assetId === row.id && choice.fieldId === field.fieldId) .sort((a, b) => a.position - b.position) .map((choice) => choice.value), })), }), })); const revisions: RevisionRow[] = histories.map((row) => ({ ...row, values: values( row, historical.filter((entry) => entry.revisionId === row.id) ), })); return { rows, revisions }; } export async function writeInventory( database: Sequelize, rows: ReturnType, existing: Set, transaction: Transaction ) { const { asset, revisions } = rows; const v = asset.values; const specification = asset.specification; await writeAssetRow( database, "assets", { id: asset.id, companyId: asset.companyId, templateId: v.templateId, siteId: v.siteId, brandId: v.brandId ?? null, name: v.name, status: v.status, notes: v.notes, brandDefined: v.brandId !== undefined, templateName: specification.templateName, sectionsDefined: specification.sections !== undefined, }, transaction ); // La especificación permanece congelada durante toda la vida del activo. for (const [position, section] of (specification.sections ?? []).entries()) { await writeAssetRow( database, "asset_specification_sections", { ...section, assetId: asset.id, position }, transaction ); } for (const [position, field] of specification.fields.entries()) { await writeAssetRow( database, "asset_specification_fields", { assetId: asset.id, fieldId: field.fieldId, name: field.name, fieldType: field.type, unit: field.unit, required: field.required, sectionId: field.sectionId ?? null, position, }, transaction ); for (const [choicePosition, value] of field.choices.entries()) { await writeAssetRow( database, "asset_specification_field_choices", { assetId: asset.id, fieldId: field.fieldId, position: choicePosition, value, }, transaction ); } } await database.query("DELETE FROM asset_characteristics WHERE assetId=:id", { replacements: { id: asset.id }, transaction, }); for (const row of characteristicRows(asset.id, v, specification)) { await writeAssetRow(database, "asset_characteristics", row, transaction); } for (const revision of revisions) { if (existing.has(revision.id)) { continue; } const previous = revision.values; await writeAssetRow( database, "asset_revisions", { id: revision.id, assetId: asset.id, actorId: revision.actorId, recordedAt: revision.recordedAt, position: revision.position, templateId: previous.templateId, siteId: previous.siteId, brandId: previous.brandId ?? null, name: previous.name, status: previous.status, notes: previous.notes, brandDefined: previous.brandId !== undefined, }, transaction ); for (const row of characteristicRows(asset.id, previous, specification, revision.id)) { await writeAssetRow(database, "asset_revision_characteristics", row, transaction); } } } async function readCharacteristics( database: Sequelize, table: "asset_characteristics" | "asset_revision_characteristics", ids: string[], transaction: Transaction ) { if (!ids.length) { return []; } // El protocolo de MariaDB puede devolver DATE como Date: fijar ISO evita depender // de la zona horaria del proceso y mantiene el contrato de fecha sin hora. const revision = table === "asset_revision_characteristics" ? "revisionId," : ""; return database.query( `SELECT ${revision}assetId,fieldId,valueType,textValue,numberValue,booleanValue,DATE_FORMAT(dateValue,'%Y-%m-%d') AS dateValue FROM ${sqlIdentifier(table)} WHERE assetId IN (:ids)`, { replacements: { ids }, type: QueryTypes.SELECT, transaction } ); }