diff --git a/AGENTS.md b/AGENTS.md index 45b0889..26da3b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,8 @@ Las fuentes normativas se consultan, no se duplican: ## Seguridad y persistencia +- Priorizar un diseño de BD normalizado, con columnas y tablas relacionadas. No almacenar valores en JSON salvo necesidad estricta; en ese caso plantearlo al usuario antes de implementarlo. JSON como formato de transporte HTTP no sustituye el diseño relacional. + - Menor privilegio y validación de toda entrada externa. - Autenticación y autorización antes de acceder a datos de clientes. - El cliente atendido no es el tenant de la empresa que presta soporte. diff --git a/apps/server/src/asset-normalization.integration.ts b/apps/server/src/asset-normalization.integration.ts new file mode 100644 index 0000000..5b8aeb5 --- /dev/null +++ b/apps/server/src/asset-normalization.integration.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; + +import { + AssetResponseSchema, + InventoryResponseSchema, + SaveDefinitionResponseSchema, +} from "@metis/assets/common"; +import type { Sequelize } from "sequelize"; +import { QueryTypes } from "sequelize"; + +import { readDefinitions } from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog-relational"; +import { readInventory } from "../../../modules/assets/src/api/infrastructure/persistence/asset-inventory-relational"; +import { + normalizeAssetCatalog, + normalizeAssetInventory, +} from "../../../modules/assets/src/api/infrastructure/persistence/asset-normalization-migration"; +import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper"; +import { normalizeAssetRelationships } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relationship-normalization"; + +export async function verifyAssetNormalization( + database: Sequelize, + request: (path: string, body?: unknown) => Promise, + companyId: string +) { + const fieldIds: string[] = []; + for (const [type, value] of [ + ["text", "Texto ñ"], + ["number", 0], + ["boolean", false], + ["date", "2026-10-10"], + ["choice", "A"], + ] as const) { + const response = await request(`/asset-catalog/${companyId}`, { + values: { + kind: "field", + name: `Migración ${type}`, + description: "", + unit: "", + type, + choices: type === "choice" ? ["A", "B"] : [], + }, + }); + assert.equal(response.status, 201); + fieldIds.push(SaveDefinitionResponseSchema.parse(await response.json()).savedId); + assert.ok(value !== undefined); + } + const templateResponse = await request(`/asset-catalog/${companyId}`, { + values: { + kind: "template", + name: "Migración tipada", + description: "", + fields: fieldIds.map((fieldId) => ({ fieldId, required: false })), + }, + }); + const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId; + const assetResponse = await request(`/assets/${companyId}`, { + values: { + name: "Migración valores", + templateId, + siteId: null, + status: "available", + notes: "", + characteristics: Object.fromEntries( + fieldIds.map((id, index) => [id, ["Texto ñ", 0, false, "2026-10-10", "A"][index]]) + ), + }, + }); + assert.equal(assetResponse.status, 201); + const created = AssetResponseSchema.parse(await assetResponse.json()).asset; + const catalogRoots = await database.query<{ id: string }>("SELECT id FROM asset_catalogs", { + type: QueryTypes.SELECT, + }); + const companies = await database.query<{ companyId: string }>( + "SELECT DISTINCT companyId FROM assets", + { type: QueryTypes.SELECT } + ); + const before = await database.transaction(async (transaction) => ({ + definitions: ( + await Promise.all(catalogRoots.map((root) => readDefinitions(database, root.id, transaction))) + ).flat(), + inventories: await Promise.all( + companies.map(async (company) => ({ + companyId: company.companyId, + ...(await readInventory(database, company.companyId, transaction)), + })) + ), + })); + const relationships = await database.query<{ + id: string; + sourceId: string; + targetId: string; + typeId: string; + name: string; + inverseName: string; + symmetric: boolean; + notes: string; + }>( + "SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id", + { type: QueryTypes.SELECT } + ); + // Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID. + assert.equal(database.getDatabaseName(), "metis_desk_test"); + for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) { + await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`); + } + await database.query("ALTER TABLE assets ADD COLUMN specification JSON NULL"); + const store = async (table: string, id: string, values: unknown) => + database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, { + replacements: { id, values: JSON.stringify(values) }, + }); + for (const definition of before.definitions) { + await store("asset_definitions", definition.id, definition.values); + } + for (const inventory of before.inventories) { + for (const row of inventory.rows) { + await store("assets", row.id, row.values); + await database.query("UPDATE assets SET specification=:value WHERE id=:id", { + replacements: { id: row.id, value: JSON.stringify(row.specification) }, + }); + } + for (const row of inventory.revisions) { + await store("asset_revisions", row.id, row.values); + } + } + for (const { id, ...values } of relationships) { + await store("asset_relationships", id, { ...values, symmetric: Boolean(values.symmetric) }); + } + const first = before.definitions[0]; + assert.ok(first); + await store("asset_definitions", first.id, { kind: "invalid" }); + await assert.rejects(normalizeAssetCatalog(database)); + assert.ok((await database.getQueryInterface().describeTable("asset_definitions")).values); + await store("asset_definitions", first.id, first.values); + await normalizeAssetCatalog(database); + await database.query("DELETE FROM asset_characteristics"); + await database.query("DELETE FROM asset_revision_characteristics"); + await database.query("UPDATE assets SET name='Origen pendiente'"); + await database.query("UPDATE asset_revisions SET name='Origen pendiente'"); + await normalizeAssetInventory(database); + await normalizeAssetRelationships(database); + // Un segundo arranque no modifica ni duplica el catálogo, los valores ni el historial. + await normalizeAssetCatalog(database); + await normalizeAssetInventory(database); + await normalizeAssetRelationships(database); + await database.transaction(async (transaction) => { + for (const root of catalogRoots) { + const actual = await readDefinitions(database, root.id, transaction); + assert.deepEqual( + actual.sort((a, b) => a.id.localeCompare(b.id)), + before.definitions + .filter((item) => item.catalogId === root.id) + .sort((a, b) => a.id.localeCompare(b.id)) + ); + } + const mapper = new AssetPersistenceMapper(); + for (const expected of before.inventories) { + const actual = await readInventory(database, expected.companyId, transaction); + const project = (inventory: typeof actual) => + inventory.rows + .map((row) => + mapper.toPersistence( + mapper.toDomain( + row, + inventory.revisions.filter((item) => item.assetId === row.id) + ) + ) + ) + .sort((a, b) => a.asset.id.localeCompare(b.asset.id)); + assert.deepEqual(project(actual), project(expected)); + } + }); + // Recuperación del corte entre DDL: las revisiones ya se normalizaron pero + // aún quedan las columnas de origen del activo. + await database.query( + "ALTER TABLE assets ADD COLUMN `values` JSON NULL, ADD COLUMN specification JSON NULL" + ); + for (const inventory of before.inventories) { + for (const row of inventory.rows) { + await store("assets", row.id, row.values); + await database.query("UPDATE assets SET specification=:value WHERE id=:id", { + replacements: { id: row.id, value: JSON.stringify(row.specification) }, + }); + } + } + await normalizeAssetInventory(database); + const after = await request(`/assets/${companyId}`); + assert.equal(after.status, 200); + assert.deepEqual( + InventoryResponseSchema.parse(await after.json()).assets.find((item) => item.id === created.id), + created + ); + const currentRelationships = await database.query<(typeof relationships)[number]>( + "SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id", + { type: QueryTypes.SELECT } + ); + assert.deepEqual(currentRelationships, relationships); + for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) { + const columns = await database.getQueryInterface().describeTable(table); + assert.equal(columns.values, undefined); + assert.equal(columns.specification, undefined); + } + const booleanField = fieldIds[2]; + assert.ok(booleanField); + await assert.rejects( + database.query( + "UPDATE asset_characteristics SET textValue='incompatible' WHERE assetId=:assetId AND fieldId=:fieldId", + { replacements: { assetId: created.id, fieldId: booleanField } } + ) + ); +} diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts index a5f680a..3f9c9a5 100644 --- a/apps/server/src/assets.integration.ts +++ b/apps/server/src/assets.integration.ts @@ -174,17 +174,6 @@ export async function verifyAssets( const binding = monitor.bindings[0]; assert.ok(binding); try { - await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", { - replacements: { - id: monitor.id, - values: JSON.stringify({ - ...monitor.values, - fields: monitor.values.fields.map((entry, index) => - index === 0 ? { ...entry, fieldId: field.id } : entry - ), - }), - }, - }); await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { replacements: { fieldId: field.id, id: binding.id }, }); @@ -196,9 +185,6 @@ export async function verifyAssets( await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { replacements: { fieldId: binding.fieldId, id: binding.id }, }); - await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", { - replacements: { values: JSON.stringify(monitor.values), id: monitor.id }, - }); } const concurrent = await Promise.all([ request(company, { values: { ...localValues, name: "Concurrente" } }), diff --git a/apps/server/src/brands.integration.ts b/apps/server/src/brands.integration.ts index 84a432a..a80d38f 100644 --- a/apps/server/src/brands.integration.ts +++ b/apps/server/src/brands.integration.ts @@ -182,7 +182,7 @@ export async function verifyBrands( "BRAND_UNAVAILABLE" ); const snapshots = await database.query<{ brandId: string; values: unknown }>( - "SELECT brandId,`values` FROM asset_revisions WHERE assetId=:id ORDER BY position", + "SELECT brandId FROM asset_revisions WHERE assetId=:id ORDER BY position", { replacements: { id: first.id }, type: QueryTypes.SELECT } ); assert.ok(snapshots.every((row) => row.brandId === created.id)); diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index c94d08b..a49a5a4 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -25,6 +25,7 @@ import { migrateInterventionTemplates } from "../../../modules/companies/src/api import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; import { verifyApplications } from "./applications.integration"; +import { verifyAssetNormalization } from "./asset-normalization.integration"; import { verifyAssets } from "./assets.integration"; import { verifyBrands } from "./brands.integration"; import { verifyCertificates } from "./certificates.integration"; @@ -114,10 +115,21 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM resource_template_fields"); await database.query("DELETE FROM resource_definitions"); await database.query("DELETE FROM asset_relationships"); + for (const table of [ + "asset_revision_characteristics", + "asset_characteristics", + "asset_specification_field_choices", + "asset_specification_fields", + "asset_specification_sections", + ]) { + await database.query(`DELETE FROM ${table}`); + } await database.query("DELETE FROM asset_revisions"); await database.query("DELETE FROM assets"); await database.query("DELETE FROM asset_availability"); await database.query("DELETE FROM asset_template_fields"); + await database.query("DELETE FROM asset_template_sections"); + await database.query("DELETE FROM asset_field_choices"); await database.query("DELETE FROM asset_definitions"); await database.query("DELETE FROM asset_catalogs"); await database.query("DELETE FROM access_person_references"); @@ -906,6 +918,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de migratedServer.close((error) => (error ? reject(error) : resolve())) ); } + await verifyAssetNormalization( + database, + (path, body) => operator.request(path, body), + customer.id + ); await verifyRecordDeletion((path, body) => operator.request(path, body), database); const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", diff --git a/docs/catalogo-global-de-marcas.md b/docs/catalogo-global-de-marcas.md index 1c68eb7..6b40fb4 100644 --- a/docs/catalogo-global-de-marcas.md +++ b/docs/catalogo-global-de-marcas.md @@ -53,7 +53,7 @@ migrateBrands crea tablas y semillas de manera idempotente, sin reactivar marcas La migración añade logoSvg y brand_catalog_lock.logosImported. Incorpora una sola vez los 23 SVG de fabricantes conocidos donde no haya un logotipo; Clónico queda sin imagen porque no es una marca. No sobrescribe logotipos existentes. El marcador transaccional evita reponer imágenes eliminadas o sustituidas después. Las altas iniciales de marcas de serie reciben su SVG local. Las fuentes se registran en [Fuentes de logotipos de marcas](fuentes-de-logotipos-de-marcas.md). -importLegacyBrands importa una única vez los nombres válidos que encuentre en el campo de serie de los activos existentes, como marcas personalizadas salvo coincidencia con una de serie. No modifica el JSON de activos, no crea revisiones y no toca el historial. Los textos anteriores permanecen visibles como Valor anterior y pueden conservarse o sustituirse por una selección explícita; no se asocian automáticamente por coincidencia de nombre. Valores antiguos que no cumplan el límite del catálogo permanecen en su ficha. +importLegacyBrands importa una única vez los nombres válidos que encuentre en el campo de serie de los activos existentes, como marcas personalizadas salvo coincidencia con una de serie. No modifica los valores de activos, no crea revisiones y no toca el historial. Los textos anteriores permanecen visibles como Valor anterior y pueden conservarse o sustituirse por una selección explícita; no se asocian automáticamente por coincidencia de nombre. Valores antiguos que no cumplan el límite del catálogo permanecen en su ficha. Un marcador transaccional evita que reinicios posteriores vuelvan a crear nombres de marcas personalizadas ya renombradas. Las semillas mantienen los nombres originales protegidos. El catálogo de campos/plantillas de recursos continúa separado. diff --git a/docs/normalizacion-de-activos.md b/docs/normalizacion-de-activos.md new file mode 100644 index 0000000..990afe5 --- /dev/null +++ b/docs/normalizacion-de-activos.md @@ -0,0 +1,83 @@ +# Normalización de activos + +## Objetivo + +La persistencia de activos no utiliza JSON para definiciones, características ni historial. La flexibilidad del catálogo se representa mediante relaciones, no mediante columnas creadas dinámicamente. + +## Catálogo + +`asset_definitions` contiene los atributos comunes de una definición: identidad, catálogo, compañía opcional, origen, tipo (`field`, `template` o `relation`), nombre, descripción y estado. Sus atributos específicos se almacenan en columnas tipadas: + +- un campo usa `fieldType` y `unit`; +- una relación usa `inverseName` y `symmetric`; +- una plantilla se compone mediante sus tablas relacionadas. + +Las opciones de un campo de lista se guardan en `asset_field_choices`. Las secciones de una plantilla se guardan en `asset_template_sections`. `asset_template_fields` une plantilla y campo, e incluye obligatoriedad, posición y sección opcional. + +## Inventario + +`assets` guarda las propiedades estables: compañía, plantilla, sede, marca, nombre, estado y notas. Las características variables se almacenan en `asset_characteristics`, una fila por activo y campo, con columnas tipadas para texto, número, fecha o booleano. + +Una instantánea de la plantilla se conserva mediante `asset_specification_sections`, `asset_specification_fields` y `asset_specification_field_choices`. De este modo, editar posteriormente el catálogo no altera la interpretación de un activo existente. + +`asset_revisions` conserva las propiedades estables de cada revisión. Sus características se guardan en `asset_revision_characteristics`, vinculadas a la instantánea del activo. + +## Migración + +La migración crea las tablas normalizadas, lee una única vez los datos JSON existentes, valida su estructura con los schemas de dominio y crea las filas correspondientes dentro de una transacción. Solo elimina las columnas JSON después de una transferencia íntegra. Cualquier dato que no cumpla el contrato detiene el arranque sin descartar el origen. + +## Relaciones entre activos + +`asset_relationships` guarda sus extremos, tipo, nombre e inverso capturados, simetría, notas y auditoría en columnas. Se conservan sus UUID, clave de relación activa, autor y fechas de creación y retirada. Los nombres capturados no cambian cuando se renombra el tipo en el catálogo. + +## Integridad y contratos + +Las FK compuestas impiden asignar una sección de otra plantilla, usar un campo que no esté en la instantánea del activo o asociar una característica histórica a una revisión de otro activo. Los CHECK limitan los tipos de definición, estados y valores tipados: exactamente una columna de valor debe estar informada y corresponder al tipo declarado. La fecha se almacena como DATE; la lectura la convierte explícitamente a ISO sin depender de la zona horaria del proceso. + +`sectionsDefined` y `brandDefined` conservan la distinción del contrato entre una propiedad ausente, una lista vacía y una marca nula en datos históricos. Una marca no nula siempre implica `brandDefined=1`. + +El catálogo global, los particulares de compañía, disponibilidad, secciones, orden y campos obligatorios mantienen su comportamiento. Dominio, DTO y HTTP continúan utilizando objetos `values` y `specification`; estos objetos se ensamblan desde las tablas normalizadas y no se almacenan como JSON. Se adaptan también las lecturas de dashboard y asistencia técnica. + +## Ejecución y recuperación + +Las migraciones se ejecutan antes de exponer las rutas durante `createAssetModule`. Primero crean las estructuras necesarias y transfieren datos dentro de transacciones. La lectura desde el destino se compara con el origen antes de retirar sus columnas. Se valida el historial completo, además de los valores actuales. Un dato inválido detiene la transferencia y conserva las columnas originales. + +MariaDB confirma las operaciones DDL de forma independiente: la migración no promete rollback del esquema completo. Cada paso comprueba el estado existente y permite repetir un arranque, incluido el corte entre la retirada del JSON de las revisiones y la del activo. No se deben mantener escritores de versiones antiguas funcionando durante una actualización del esquema. + +Antes de aplicar cambios a la BD de desarrollo puede ejecutarse `bash scripts/backup-assets-normalization.sh`. La copia SQL completa se guarda fuera del repositorio en `/home/david/.local/state/metis-desk/backups`, con permisos restringidos. Contiene datos privados y no debe versionarse. + +## Alcance + +Esta entrega normaliza catálogo de activos, inventario, especificaciones, revisiones y relaciones entre activos. No cambia la persistencia de recursos, puestos de trabajo, asistencia técnica, tickets ni los otros módulos que todavía tienen JSON. Su normalización requiere migraciones específicas posteriores; no se introduce JSON nuevo para guardar valores en el diseño de activos. + +## Verificación + +`apps/server/src/asset-normalization.integration.ts`, incluido en `pnpm --filter @metis/server test`, reconstruye el formato anterior únicamente en `metis_desk_test` y comprueba transferencia, dato inválido sin descarte del origen, idempotencia, recuperación de DDL interrumpido y restricciones de valores. Cubre los cinco tipos, cero, falso, campos omitidos, secciones congeladas, marcas, identidades y revisiones existentes. La integración HTTP habitual verifica catálogo global/local, creación y edición, relaciones, puestos, asistencia y dashboards. + +## Esquema de relaciones + +```mermaid +erDiagram + asset_catalogs ||--o{ asset_definitions : contiene + asset_definitions ||--o{ asset_field_choices : opciones + asset_definitions ||--o{ asset_template_sections : secciones + asset_definitions ||--o{ asset_template_fields : plantilla_y_campo + asset_template_sections o|--o{ asset_template_fields : agrupa + asset_definitions ||--o{ asset_availability : disponibilidad_por_compania + asset_definitions ||--o{ assets : plantilla + asset_brands o|--o{ assets : marca + assets ||--o{ asset_specification_sections : captura + assets ||--|{ asset_specification_fields : captura + asset_specification_sections o|--o{ asset_specification_fields : agrupa + asset_specification_fields ||--o{ asset_specification_field_choices : captura_opciones + asset_specification_fields ||--o{ asset_characteristics : valor_actual + assets ||--|{ asset_revisions : historial + asset_revisions ||--o{ asset_revision_characteristics : valores_historicos + asset_specification_fields ||--o{ asset_revision_characteristics : interpreta + assets ||--o{ asset_relationships : origen_o_destino + asset_definitions ||--o{ asset_relationships : tipo +``` + +La tabla `asset_template_fields` referencia `asset_definitions` dos veces: plantilla y campo. `asset_relationships` referencia `assets` dos veces: origen y destino. Las cardinalidades de valor por campo se interpretan dentro de cada activo o revisión; los campos opcionales pueden no tener fila. + +En desarrollo se comprobó la lectura de las tres compañías y la conservación de 52 definiciones, 5 activos, 6 revisiones y 1 relación. La copia previa de esta ejecución se guardó en `/home/david/.local/state/metis-desk/backups/pre-normalizacion-activos-20261010-113023.sql`. diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts index c8835a3..fbfb1b1 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper.ts @@ -10,12 +10,8 @@ import { } from "../../domain/asset-definition.entity"; import { AssetDefinitionValues } from "../../domain/value-objects/asset-definition-values.vo"; -import type { - AvailabilityModel, - BindingModel, - CatalogModel, - DefinitionModel, -} from "./asset-catalog.model"; +import type { AvailabilityModel, CatalogModel } from "./asset-catalog.model"; +import type { BindingRow, DefinitionRow } from "./asset-catalog-relational"; function identity(value: string) { const parsed = UniqueID.create(value); @@ -27,8 +23,8 @@ function identity(value: string) { export class AssetCatalogPersistenceMapper { toDomain( root: CatalogModel, - rows: readonly DefinitionModel[], - fields: readonly BindingModel[], + rows: readonly DefinitionRow[], + fields: readonly BindingRow[], overrides: readonly AvailabilityModel[] ) { const definitions = rows.map((row) => { diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts new file mode 100644 index 0000000..1b72b77 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts @@ -0,0 +1,211 @@ +import type { Sequelize, Transaction } from "sequelize"; +import { z } from "zod/v4"; + +import type { AssetDefinitionSpec } from "../../domain/asset-definition"; +import { AssetDefinitionSchema } from "../../domain/asset-definition"; +import type { TemplateSectionData } from "../../domain/template-sections"; + +import { readAssetRows, writeAssetRow } from "./asset-relational-sql"; + +export type DefinitionRow = { + id: string; + catalogId: string; + companyId: string | null; + stockKey: string | null; + active: boolean; + values: unknown; +}; +type RelationalDefinition = Omit & { + kind: string; + name: string; + description: string; + fieldType: string | null; + unit: string | null; + inverseName: string | null; + symmetric: boolean | null; + sectionsDefined: boolean; +}; +type ChoiceRow = { fieldId: string; position: number; value: string }; +type SectionRow = TemplateSectionData & { templateId: string; position: number }; +export type BindingRow = { + id: string; + templateId: string; + fieldId: string; + required: boolean; + position: number; + sectionId?: string | null; +}; + +export async function readDefinitions( + database: Sequelize, + catalogId: string, + transaction: Transaction +): Promise { + const rows = await readAssetRows( + database, + "asset_definitions", + "catalogId", + [catalogId], + transaction + ); + const ids = rows.map((row) => row.id); + const choices = await readAssetRows( + database, + "asset_field_choices", + "fieldId", + ids, + transaction + ); + const sections = await readAssetRows( + database, + "asset_template_sections", + "templateId", + ids, + transaction + ); + const bindings = await readAssetRows( + database, + "asset_template_fields", + "templateId", + ids, + transaction + ); + return rows.map((row) => { + const common = { name: row.name, description: row.description }; + let values: unknown; + switch (row.kind) { + case "field": + values = { + ...common, + kind: row.kind, + type: row.fieldType, + unit: row.unit, + choices: choices + .filter((choice) => choice.fieldId === row.id) + .sort((a, b) => a.position - b.position) + .map((choice) => choice.value), + }; + break; + case "relation": + values = { + ...common, + kind: row.kind, + inverseName: row.inverseName, + symmetric: Boolean(row.symmetric), + }; + break; + case "template": + values = { + ...common, + kind: row.kind, + fields: bindings + .filter((binding) => binding.templateId === row.id) + .sort((a, b) => a.position - b.position) + .map((binding) => ({ + fieldId: binding.fieldId, + required: Boolean(binding.required), + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), + })), + ...(row.sectionsDefined + ? { + sections: sections + .filter((section) => section.templateId === row.id) + .sort((a, b) => a.position - b.position) + .map(({ id, name, description }) => ({ id, name, description })), + } + : {}), + }; + break; + default: + throw new Error("Tipo de definición persistida inconsistente."); + } + return { + id: row.id, + catalogId: row.catalogId, + companyId: row.companyId, + stockKey: row.stockKey, + active: Boolean(row.active), + values: AssetDefinitionSchema.parse(values), + }; + }); +} + +export async function writeDefinition( + database: Sequelize, + row: Omit & { values: AssetDefinitionSpec }, + transaction: Transaction +) { + const values = row.values; + await writeAssetRow( + database, + "asset_definitions", + { + id: row.id, + catalogId: row.catalogId, + companyId: row.companyId, + stockKey: row.stockKey, + active: row.active, + kind: values.kind, + name: values.name, + description: values.description, + fieldType: values.kind === "field" ? values.type : null, + unit: values.kind === "field" ? values.unit : null, + inverseName: values.kind === "relation" ? values.inverseName : null, + symmetric: values.kind === "relation" ? values.symmetric : null, + sectionsDefined: values.kind === "template" && values.sections !== undefined, + }, + transaction + ); + if (values.kind === "field") { + await database.query("DELETE FROM asset_field_choices WHERE fieldId=:id", { + replacements: { id: row.id }, + transaction, + }); + for (const [position, value] of values.choices.entries()) { + await writeAssetRow( + database, + "asset_field_choices", + { fieldId: row.id, position, value }, + transaction + ); + } + } + if (values.kind === "template") { + // Desvincular antes de retirar secciones permite mover campos a «Sin sección». + await database.query("UPDATE asset_template_fields SET sectionId=NULL WHERE templateId=:id", { + replacements: { id: row.id }, + transaction, + }); + await database.query("DELETE FROM asset_template_sections WHERE templateId=:id", { + replacements: { id: row.id }, + transaction, + }); + for (const [position, section] of (values.sections ?? []).entries()) { + await writeAssetRow( + database, + "asset_template_sections", + { ...section, templateId: row.id, position }, + transaction + ); + } + } +} + +export async function readBindings( + database: Sequelize, + ids: readonly string[], + transaction: Transaction +) { + const rows = await readAssetRows( + database, + "asset_template_fields", + "templateId", + ids, + transaction + ); + return rows.map((row) => ({ ...row, required: Boolean(row.required) })); +} + +export const LegacyJsonSchema = z + .unknown() + .transform((value) => (typeof value === "string" ? JSON.parse(value) : value)); diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts index 2f44bdd..992b85e 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts @@ -1,5 +1,7 @@ import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; + +import { definitionColumns, normalizeAssetCatalog } from "./asset-normalization-migration"; export class CatalogModel extends Model< InferAttributes, InferCreationAttributes @@ -7,17 +9,6 @@ export class CatalogModel extends Model< declare id: string; declare catalogKey: string; } -export class DefinitionModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; - declare catalogId: string; - declare companyId: string | null; - declare stockKey: string | null; - declare values: unknown; - declare active: boolean; -} export class BindingModel extends Model< InferAttributes, InferCreationAttributes @@ -65,7 +56,7 @@ export async function migrateAssetCatalog(database: Sequelize) { catalogId: fk("asset_catalogs"), companyId: fk("companies", true), stockKey: { type: DataTypes.STRING(100), allowNull: true, unique: true }, - values: { type: DataTypes.JSON, allowNull: false }, + ...definitionColumns, active: { type: DataTypes.BOOLEAN, allowNull: false }, }; const binding = { @@ -98,16 +89,12 @@ export async function migrateAssetCatalog(database: Sequelize) { await query.addIndex(table, [...fields], { unique: true, name }); } } + await normalizeAssetCatalog(database); CatalogModel.init(catalog, { sequelize: database, tableName: "asset_catalogs", timestamps: false, }); - DefinitionModel.init(definition, { - sequelize: database, - tableName: "asset_definitions", - timestamps: false, - }); BindingModel.init(binding, { sequelize: database, tableName: "asset_template_fields", diff --git a/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts b/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts index 1b98091..611df39 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts @@ -30,7 +30,7 @@ export class AssetDashboardReader { { type: QueryTypes.SELECT, replacements } ); const items = await this.database.query( - `SELECT a.id,a.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(a.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName FROM assets a JOIN companies c ON c.id=a.companyId ${where} ORDER BY name,a.id LIMIT 10 OFFSET :offset`, + `SELECT a.id,a.companyId,c.name AS companyName,a.name AS name,NULL AS recordedAt,NULL AS actorName FROM assets a JOIN companies c ON c.id=a.companyId ${where} ORDER BY name,a.id LIMIT 10 OFFSET :offset`, { type: QueryTypes.SELECT, replacements } ); return { total: Number(counts[0]?.total ?? 0), items }; @@ -43,7 +43,7 @@ export class AssetDashboardReader { } activity(companyId?: string) { return this.database.query( - "SELECT a.id,r.id AS eventId,a.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.name')) AS name,r.recordedAt,u.name AS actorName FROM asset_revisions r JOIN assets a ON a.id=r.assetId JOIN companies c ON c.id=a.companyId LEFT JOIN users u ON u.id=r.actorId WHERE (:companyId IS NULL OR a.companyId=:companyId) ORDER BY r.recordedAt DESC,r.id DESC LIMIT 6", + "SELECT a.id,r.id AS eventId,a.companyId,c.name AS companyName,a.name AS name,r.recordedAt,u.name AS actorName FROM asset_revisions r JOIN assets a ON a.id=r.assetId JOIN companies c ON c.id=a.companyId LEFT JOIN users u ON u.id=r.actorId WHERE (:companyId IS NULL OR a.companyId=:companyId) ORDER BY r.recordedAt DESC,r.id DESC LIMIT 6", { type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } } ); } diff --git a/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts b/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts new file mode 100644 index 0000000..0e25bfd --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts @@ -0,0 +1,350 @@ +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 "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 === "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 } + ); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-normalization-migration.ts b/modules/assets/src/api/infrastructure/persistence/asset-normalization-migration.ts new file mode 100644 index 0000000..7df9b25 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-normalization-migration.ts @@ -0,0 +1,312 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { Sequelize, Transaction } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +import { AssetDefinitionSchema } from "../../domain/asset-definition"; +import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values"; + +import type { BindingRow, DefinitionRow } from "./asset-catalog-relational"; +import { LegacyJsonSchema, readDefinitions, writeDefinition } from "./asset-catalog-relational"; +import type { InventoryRow, RevisionRow } from "./asset-inventory-relational"; +import { readInventory, writeInventory } from "./asset-inventory-relational"; +import { AssetPersistenceMapper } from "./asset-persistence.mapper"; +import { removeAssetColumn } from "./asset-relational-sql"; + +const fk = (model: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model, key: "id" }, + onDelete: model === "asset_definitions" ? "RESTRICT" : "CASCADE", +}); +const title = { type: DataTypes.STRING(100), allowNull: false }; +const position = { type: DataTypes.INTEGER, allowNull: false }; +export const definitionColumns = { + kind: { type: DataTypes.STRING(12), allowNull: true }, + name: { ...title, allowNull: true }, + description: { type: DataTypes.TEXT, allowNull: true }, + fieldType: { type: DataTypes.STRING(12), allowNull: true }, + unit: { type: DataTypes.STRING(30), allowNull: true }, + inverseName: { ...title, allowNull: true }, + symmetric: { type: DataTypes.BOOLEAN, allowNull: true }, + sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, +}; +export const inventoryColumns = { + name: { ...title, allowNull: true }, + status: { type: DataTypes.STRING(15), allowNull: true }, + notes: { type: DataTypes.TEXT, allowNull: true }, + brandDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, +}; +export const specificationColumns = { + templateName: { ...title, allowNull: true }, + sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, +}; +type Columns = Parameters["createTable"]>[1]; + +async function create(database: Sequelize, name: string, columns: Columns) { + const tables = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name", + { replacements: { name }, type: QueryTypes.SELECT } + ); + if (!tables.length) { + await database.getQueryInterface().createTable(name, columns); + } +} +async function add(database: Sequelize, table: string, columns: Columns) { + const previous = await database.getQueryInterface().describeTable(table); + for (const [column, definition] of Object.entries(columns)) { + if (!(column in previous)) { + await database.getQueryInterface().addColumn(table, column, definition); + } + } +} +function legacy(value: unknown) { + return LegacyJsonSchema.parse(value); +} + +export async function normalizeAssetCatalog(database: Sequelize) { + await add(database, "asset_definitions", definitionColumns); + await create(database, "asset_field_choices", { + fieldId: { ...fk("asset_definitions"), primaryKey: true }, + position: { ...position, primaryKey: true }, + value: title, + }); + await create(database, "asset_template_sections", { + id: { type: DataTypes.UUID, primaryKey: true }, + templateId: fk("asset_definitions"), + name: title, + description: { type: DataTypes.TEXT, allowNull: false }, + position, + }); + await add(database, "asset_template_fields", { sectionId: fk("asset_template_sections", true) }); + const columns = await database.getQueryInterface().describeTable("asset_definitions"); + if (!columns.values) { + return; + } + // Las columnas originales se conservan hasta terminar la transferencia validada. + await database.query("ALTER TABLE asset_definitions MODIFY COLUMN `values` LONGTEXT NULL"); + await database.transaction(async (transaction) => { + const rows = await database.query("SELECT * FROM asset_definitions", { + type: QueryTypes.SELECT, + transaction, + }); + const bindings = await database.query("SELECT * FROM asset_template_fields", { + type: QueryTypes.SELECT, + transaction, + }); + for (const row of rows) { + const values = AssetDefinitionSchema.parse(legacy(row.values)); + validateLegacyBindings(row.id, values, bindings); + await writeDefinition(database, { ...row, values }, transaction); + if (values.kind === "template") { + for (const field of values.fields) { + await database.query( + "UPDATE asset_template_fields SET sectionId=:sectionId WHERE templateId=:id AND fieldId=:fieldId", + { + replacements: { + sectionId: field.sectionId ?? null, + id: row.id, + fieldId: field.fieldId, + }, + transaction, + } + ); + } + } + } + await verifyCatalogTransfer(database, rows, transaction); + }); + await removeAssetColumn(database, "asset_definitions", "values"); +} + +export async function normalizeAssetInventory(database: Sequelize) { + await add(database, "assets", { ...inventoryColumns, ...specificationColumns }); + await add(database, "asset_revisions", { + ...inventoryColumns, + templateId: { ...fk("asset_definitions"), allowNull: true }, + }); + await create(database, "asset_specification_sections", { + assetId: { ...fk("assets"), primaryKey: true }, + id: { type: DataTypes.UUID, primaryKey: true }, + name: title, + description: { type: DataTypes.TEXT, allowNull: false }, + position, + }); + await create(database, "asset_specification_fields", { + assetId: { ...fk("assets"), primaryKey: true }, + fieldId: { ...fk("asset_definitions"), primaryKey: true }, + name: title, + fieldType: { type: DataTypes.STRING(12), allowNull: false }, + unit: { type: DataTypes.STRING(30), allowNull: false }, + required: { type: DataTypes.BOOLEAN, allowNull: false }, + sectionId: { type: DataTypes.UUID, allowNull: true }, + position, + }); + await create(database, "asset_specification_field_choices", { + assetId: { ...fk("assets"), primaryKey: true }, + fieldId: { ...fk("asset_definitions"), primaryKey: true }, + position: { ...position, primaryKey: true }, + value: title, + }); + const typedValues = { + fieldId: { ...fk("asset_definitions"), primaryKey: true }, + valueType: { type: DataTypes.STRING(12), allowNull: false }, + textValue: { type: DataTypes.TEXT, allowNull: true }, + numberValue: { type: DataTypes.DOUBLE, allowNull: true }, + booleanValue: { type: DataTypes.BOOLEAN, allowNull: true }, + dateValue: { type: DataTypes.DATEONLY, allowNull: true }, + }; + await create(database, "asset_characteristics", { + assetId: { ...fk("assets"), primaryKey: true }, + ...typedValues, + }); + await create(database, "asset_revision_characteristics", { + revisionId: { ...fk("asset_revisions"), primaryKey: true }, + assetId: fk("assets"), + ...typedValues, + }); + // Una referencia no nula implica que la propiedad ya está definida, incluso al + // recuperar una instalación que se interrumpió antes de añadir el indicador. + for (const table of ["assets", "asset_revisions"]) { + await database.query( + `UPDATE ${table} SET brandDefined=1 WHERE brandId IS NOT NULL AND brandDefined=0` + ); + } + const columns = await database.getQueryInterface().describeTable("assets"); + if (!columns.values) { + await removeAssetColumn(database, "assets", "specification"); + return; + } + await database.query( + "ALTER TABLE assets MODIFY COLUMN `values` LONGTEXT NULL, MODIFY COLUMN specification LONGTEXT NULL" + ); + const revisionColumns = await database.getQueryInterface().describeTable("asset_revisions"); + if (revisionColumns.values) { + await database.query("ALTER TABLE asset_revisions MODIFY COLUMN `values` LONGTEXT NULL"); + } + await database.transaction(async (transaction) => { + const assets = await database.query("SELECT * FROM assets", { + type: QueryTypes.SELECT, + transaction, + }); + const revisions = await database.query("SELECT * FROM asset_revisions", { + type: QueryTypes.SELECT, + transaction, + }); + const mapper = new AssetPersistenceMapper(); + if (!revisionColumns.values) { + await restoreTransferredRevisions(database, assets, revisions, transaction); + } + const expectedAssets = []; + for (const row of assets) { + const asset = mapper.toDomain( + { + ...row, + values: AssetInputSchema.parse(legacy(row.values)), + specification: AssetSpecificationSchema.parse(legacy(row.specification)), + }, + revisions + .filter((revision) => revision.assetId === row.id) + .map((revision) => ({ + ...revision, + values: AssetInputSchema.parse(legacy(revision.values)), + })) + ); + const expected = mapper.toPersistence(asset); + await writeInventory(database, expected, new Set(), transaction); + expectedAssets.push(expected); + } + await verifyInventoryTransfer(database, expectedAssets, transaction); + }); + await removeAssetColumn(database, "asset_revisions", "values"); + await removeAssetColumn(database, "assets", "values"); + await removeAssetColumn(database, "assets", "specification"); +} + +function validateLegacyBindings( + id: string, + values: ReturnType, + bindings: BindingRow[] +) { + if (values.kind !== "template") { + return; + } + const stored = bindings + .filter((binding) => binding.templateId === id) + .sort((a, b) => a.position - b.position); + if ( + stored.length !== values.fields.length || + stored.some( + (binding, index) => + binding.fieldId !== values.fields[index]?.fieldId || + Boolean(binding.required) !== values.fields[index]?.required + ) + ) { + throw new Error("Referencias de plantilla incompatibles con la normalización."); + } +} +async function verifyCatalogTransfer( + database: Sequelize, + rows: DefinitionRow[], + transaction: Transaction +) { + for (const catalogId of new Set(rows.map((row) => row.catalogId))) { + const actual = await readDefinitions(database, catalogId, transaction); + for (const row of rows.filter((item) => item.catalogId === catalogId)) { + const transferred = actual.find((item) => item.id === row.id); + if ( + !( + transferred && + isDeepStrictEqual(AssetDefinitionSchema.parse(legacy(row.values)), transferred.values) + ) + ) { + throw new Error("La verificación del catálogo normalizado no coincide con el origen."); + } + } + } +} +async function restoreTransferredRevisions( + database: Sequelize, + assets: InventoryRow[], + revisions: RevisionRow[], + transaction: Transaction +) { + for (const companyId of new Set(assets.map((row) => row.companyId))) { + const previous = await readInventory(database, companyId, transaction); + for (const revision of revisions) { + const transferred = previous.revisions.find((item) => item.id === revision.id); + if (transferred) { + revision.values = transferred.values; + } + } + } +} +async function verifyInventoryTransfer( + database: Sequelize, + expectedAssets: ReturnType[], + transaction: Transaction +) { + const mapper = new AssetPersistenceMapper(); + for (const companyId of new Set(expectedAssets.map((item) => item.asset.companyId))) { + const transferred = await readInventory(database, companyId, transaction); + for (const expected of expectedAssets.filter((item) => item.asset.companyId === companyId)) { + const row = transferred.rows.find((item) => item.id === expected.asset.id); + if ( + !( + row && + isDeepStrictEqual( + expected, + mapper.toPersistence( + mapper.toDomain( + row, + transferred.revisions.filter((item) => item.assetId === row.id) + ) + ) + ) + ) + ) { + throw new Error("La verificación del inventario normalizado no coincide con el origen."); + } + } + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts index 82b0f64..8a77751 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts @@ -6,7 +6,7 @@ import { AssetRevision } from "../../domain/asset-revision.entity"; import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values"; import { AssetSpecification, AssetValues } from "../../domain/value-objects/asset-values.vo"; -import type { AssetModel, AssetRevisionModel } from "./asset.model"; +import type { InventoryRow, RevisionRow } from "./asset-inventory-relational"; export function inventoryIdentity(value: string) { const parsed = UniqueID.create(value); if (!parsed.isSuccess) { @@ -15,7 +15,7 @@ export function inventoryIdentity(value: string) { return parsed.data; } export class AssetPersistenceMapper { - toDomain(row: AssetModel, revisions: readonly AssetRevisionModel[]) { + toDomain(row: InventoryRow, revisions: readonly RevisionRow[]) { const specification = AssetSpecification.create( AssetSpecificationSchema.parse(row.specification) ); diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts b/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts new file mode 100644 index 0000000..54adc5a --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts @@ -0,0 +1,105 @@ +import type { Sequelize } from "sequelize"; +import { QueryTypes } from "sequelize"; + +import { sqlIdentifier } from "./asset-relational-sql"; + +/** Restricciones posteriores al traslado: nunca se aceptan valores parcialmente migrados. */ +export async function enforceAssetRelationalConstraints(database: Sequelize) { + await requireAssetColumns(database); + const constraints = await database.query<{ tableName: string; name: string }>( + "SELECT TABLE_NAME AS tableName,CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const add = async (table: string, name: string, definition: string) => { + if (!constraints.some((item) => item.tableName === table && item.name === name)) { + await database.query( + `ALTER TABLE ${sqlIdentifier(table)} ADD CONSTRAINT ${sqlIdentifier(name)} ${definition}` + ); + } + }; + await add( + "asset_definitions", + "asset_definition_kind", + "CHECK ((kind='field' AND fieldType IN ('text','number','date','boolean','choice') AND unit IS NOT NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='template' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='relation' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NOT NULL AND symmetric IS NOT NULL))" + ); + for (const table of ["assets", "asset_revisions"]) { + await add(table, `${table}_brand_presence`, "CHECK (brandId IS NULL OR brandDefined=1)"); + await add( + table, + `${table}_status`, + "CHECK (status IN ('available','in_use','maintenance','retired'))" + ); + } + await add("asset_template_sections", "asset_section_template_identity", "UNIQUE (templateId,id)"); + await add( + "asset_template_fields", + "asset_binding_section_template", + "FOREIGN KEY (templateId,sectionId) REFERENCES asset_template_sections(templateId,id)" + ); + await add( + "asset_specification_fields", + "asset_snapshot_section", + "FOREIGN KEY (assetId,sectionId) REFERENCES asset_specification_sections(assetId,id)" + ); + for (const table of [ + "asset_specification_field_choices", + "asset_characteristics", + "asset_revision_characteristics", + ]) { + await add( + table, + `${table}_snapshot`, + "FOREIGN KEY (assetId,fieldId) REFERENCES asset_specification_fields(assetId,fieldId)" + ); + } + await add("asset_revisions", "asset_revision_identity", "UNIQUE (id,assetId)"); + await add( + "asset_revision_characteristics", + "asset_value_revision_owner", + "FOREIGN KEY (revisionId,assetId) REFERENCES asset_revisions(id,assetId)" + ); + for (const table of ["asset_characteristics", "asset_revision_characteristics"]) { + await add( + table, + `${table}_typed_value`, + "CHECK ((valueType IN ('text','choice') AND textValue IS NOT NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='number' AND textValue IS NULL AND numberValue IS NOT NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='boolean' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IN (0,1) AND booleanValue IS NOT NULL AND dateValue IS NULL) OR (valueType='date' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NOT NULL AND dateValue REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'))" + ); + } +} + +async function requireAssetColumns(database: Sequelize) { + for (const [table, columns] of [ + [ + "asset_definitions", + "MODIFY kind VARCHAR(12) NOT NULL, MODIFY name VARCHAR(100) NOT NULL, MODIFY description TEXT NOT NULL", + ], + [ + "assets", + "MODIFY name VARCHAR(100) NOT NULL, MODIFY status VARCHAR(15) NOT NULL, MODIFY notes TEXT NOT NULL, MODIFY templateName VARCHAR(100) NOT NULL", + ], + [ + "asset_revisions", + "MODIFY name VARCHAR(100) NOT NULL, MODIFY status VARCHAR(15) NOT NULL, MODIFY notes TEXT NOT NULL, MODIFY templateId CHAR(36) BINARY NOT NULL", + ], + ]) { + if (!(table && columns)) { + throw new Error("Contrato de normalización incompleto."); + } + const description = await database.getQueryInterface().describeTable(table); + const required = + table === "asset_definitions" + ? ["kind", "name", "description"] + : table === "assets" + ? ["name", "status", "notes", "templateName"] + : ["name", "status", "notes", "templateId"]; + if (required.some((column) => description[column]?.allowNull)) { + await database.query(`ALTER TABLE ${sqlIdentifier(table)} ${columns}`); + } + } + for (const table of ["asset_characteristics", "asset_revision_characteristics"]) { + const columns = await database.getQueryInterface().describeTable(table); + if (columns.dateValue?.type !== "DATE") { + await database.query(`ALTER TABLE ${sqlIdentifier(table)} MODIFY dateValue DATE NULL`); + } + } +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relational-sql.ts b/modules/assets/src/api/infrastructure/persistence/asset-relational-sql.ts new file mode 100644 index 0000000..0982b58 --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-relational-sql.ts @@ -0,0 +1,54 @@ +import type { Sequelize, Transaction } from "sequelize"; +import { QueryTypes } from "sequelize"; + +/** Identificadores internos; los valores externos siempre se parametrizan. */ +export function sqlIdentifier(value: string) { + return `\`${value.replaceAll("`", "``")}\``; +} + +export async function writeAssetRow( + database: Sequelize, + table: string, + row: Record, + transaction: Transaction +) { + const columns = Object.keys(row); + await database.query( + `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(",")}) VALUES (${columns.map((key) => `:${key}`).join(",")}) ON DUPLICATE KEY UPDATE ${columns.map((key) => `${sqlIdentifier(key)}=VALUES(${sqlIdentifier(key)})`).join(",")}`, + { replacements: row, transaction } + ); +} + +export async function readAssetRows( + database: Sequelize, + table: string, + column: string, + ids: readonly string[], + transaction: Transaction +) { + if (!ids.length) { + return []; + } + return database.query( + `SELECT * FROM ${sqlIdentifier(table)} WHERE ${sqlIdentifier(column)} IN (:ids)`, + { replacements: { ids }, type: QueryTypes.SELECT, transaction } + ); +} + +export async function removeAssetColumn(database: Sequelize, table: string, column: string) { + const columns = await database.getQueryInterface().describeTable(table); + if (!(column in columns)) { + return; + } + const constraints = await database.query<{ name: string }>( + "SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME=:column AND REFERENCED_TABLE_NAME IS NOT NULL", + { replacements: { table, column }, type: QueryTypes.SELECT } + ); + for (const { name } of constraints) { + await database.query( + `ALTER TABLE ${sqlIdentifier(table)} DROP FOREIGN KEY ${sqlIdentifier(name)}` + ); + } + // Evita el fallo de QueryInterface.removeColumn con Sequelize 6 y mariadb 3. + await database.query(`ALTER TABLE ${sqlIdentifier(table)} DROP COLUMN ${sqlIdentifier(column)}`); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship-normalization.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship-normalization.ts new file mode 100644 index 0000000..e2b799a --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship-normalization.ts @@ -0,0 +1,65 @@ +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +import { RelationshipValuesSchema } from "../../domain/value-objects/asset-relationship-values.vo"; + +import { LegacyJsonSchema } from "./asset-catalog-relational"; +import { removeAssetColumn } from "./asset-relational-sql"; + +export const relationshipColumns = { + name: { type: DataTypes.STRING(100), allowNull: false }, + inverseName: { type: DataTypes.STRING(100), allowNull: false }, + symmetric: { type: DataTypes.BOOLEAN, allowNull: false }, + notes: { type: DataTypes.TEXT, allowNull: false }, +}; +export async function normalizeAssetRelationships(database: Sequelize) { + const query = database.getQueryInterface(); + const columns = await query.describeTable("asset_relationships"); + if (!columns.values) { + return; + } + for (const [column, definition] of Object.entries(relationshipColumns)) { + if (!(column in columns)) { + await query.addColumn("asset_relationships", column, { ...definition, allowNull: true }); + } + } + await database.transaction(async (transaction) => { + const rows = await database.query<{ + id: string; + sourceId: string; + targetId: string; + typeId: string; + values: unknown; + }>("SELECT id,sourceId,targetId,typeId,`values` FROM asset_relationships", { + type: QueryTypes.SELECT, + transaction, + }); + for (const row of rows) { + const values = RelationshipValuesSchema.parse(LegacyJsonSchema.parse(row.values)); + if ( + values.sourceId !== row.sourceId || + values.targetId !== row.targetId || + values.typeId !== row.typeId + ) { + throw new Error("Referencias de relación incompatibles con la normalización."); + } + await database.query( + "UPDATE asset_relationships SET name=:name,inverseName=:inverseName,symmetric=:symmetric,notes=:notes WHERE id=:id", + { + replacements: { + id: row.id, + name: values.name, + inverseName: values.inverseName, + symmetric: values.symmetric, + notes: values.notes, + }, + transaction, + } + ); + } + }); + for (const [column, definition] of Object.entries(relationshipColumns)) { + await query.changeColumn("asset_relationships", column, definition); + } + await removeAssetColumn(database, "asset_relationships", "values"); +} diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts index fc054c8..2d6c242 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts @@ -12,7 +12,17 @@ import type { RelationshipModel } from "./asset-relationship.model"; export class AssetRelationshipPersistenceMapper { toDomain(row: RelationshipModel) { - const values = AssetRelationshipValues.create(RelationshipValuesSchema.parse(row.values)); + const values = AssetRelationshipValues.create( + RelationshipValuesSchema.parse({ + sourceId: row.sourceId, + targetId: row.targetId, + typeId: row.typeId, + name: row.name, + inverseName: row.inverseName, + symmetric: Boolean(row.symmetric), + notes: row.notes, + }) + ); if ( !(values.isSuccess && z.iso.datetime().safeParse(row.createdAt).success) || (row.endedAt !== null && @@ -50,7 +60,10 @@ export class AssetRelationshipPersistenceMapper { sourceId: values.sourceId, targetId: values.targetId, typeId: values.typeId, - values, + name: values.name, + inverseName: values.inverseName, + symmetric: values.symmetric, + notes: values.notes, createdBy: item.createdBy.toPrimitive(), createdAt: item.createdAt, endedBy: item.endedBy.getOrUndefined()?.toPrimitive() ?? null, diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts index 9fc49d0..d6024aa 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts @@ -1,5 +1,10 @@ import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; + +import { + normalizeAssetRelationships, + relationshipColumns, +} from "./asset-relationship-normalization"; export class RelationshipModel extends Model< InferAttributes, InferCreationAttributes @@ -9,7 +14,10 @@ export class RelationshipModel extends Model< declare sourceId: string; declare targetId: string; declare typeId: string; - declare values: unknown; + declare name: string; + declare inverseName: string; + declare symmetric: boolean; + declare notes: string; declare createdBy: string; declare createdAt: string; declare endedBy: string | null; @@ -34,7 +42,7 @@ export async function migrateRelationships(database: Sequelize) { sourceId: fk("assets"), targetId: fk("assets"), typeId: fk("asset_definitions"), - values: { type: DataTypes.JSON, allowNull: false }, + ...relationshipColumns, createdBy: fk("users"), createdAt: { type: DataTypes.STRING(30), allowNull: false }, endedBy: fk("users", true), @@ -69,6 +77,7 @@ export async function migrateRelationships(database: Sequelize) { ); } } + await normalizeAssetRelationships(database); RelationshipModel.init(columns, { sequelize: database, tableName: "asset_relationships", diff --git a/modules/assets/src/api/infrastructure/persistence/asset.model.ts b/modules/assets/src/api/infrastructure/persistence/asset.model.ts index 32168b9..8794a39 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset.model.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset.model.ts @@ -1,32 +1,9 @@ import { UniqueID } from "@repo/rdx-ddd"; -import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; -import { DataTypes, Model, QueryTypes } from "sequelize"; +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +import { inventoryColumns, specificationColumns } from "./asset-normalization-migration"; -export class AssetModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; - declare companyId: string; - declare templateId: string; - declare siteId: string | null; - declare brandId: string | null; - declare specification: unknown; - declare values: unknown; -} -export class AssetRevisionModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; - declare assetId: string; - declare actorId: string; - declare recordedAt: string; - declare values: unknown; - declare position: number; - declare siteId: string | null; - declare brandId: string | null; -} const id = { type: DataTypes.UUID, primaryKey: true }; const fk = (model: string, nullable = false) => ({ type: DataTypes.UUID, @@ -47,8 +24,8 @@ export async function migrateInventory(database: Sequelize) { templateId: fk("asset_definitions"), siteId: fk("company_sites", true), brandId: fk("asset_brands", true), - specification: { type: DataTypes.JSON, allowNull: false }, - values: { type: DataTypes.JSON, allowNull: false }, + ...inventoryColumns, + ...specificationColumns, }; const revision = { id, @@ -57,7 +34,8 @@ export async function migrateInventory(database: Sequelize) { siteId: fk("company_sites", true), brandId: fk("asset_brands", true), recordedAt: { type: DataTypes.STRING(30), allowNull: false }, - values: { type: DataTypes.JSON, allowNull: false }, + ...inventoryColumns, + templateId: fk("asset_definitions"), position: { type: DataTypes.INTEGER, allowNull: false }, }; if (!tables.some((table) => table.name === "assets")) { @@ -66,18 +44,21 @@ export async function migrateInventory(database: Sequelize) { if (!tables.some((table) => table.name === "asset_revisions")) { await query.createTable("asset_revisions", revision); } + const revisionColumns = await query.describeTable("asset_revisions"); + if (!("siteId" in revisionColumns)) { + await query.addColumn("asset_revisions", "siteId", fk("company_sites", true)); + if (revisionColumns.values) { + await database.query( + "UPDATE asset_revisions SET siteId=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`,'$.siteId')),'null')" + ); + } + } for (const table of ["assets", "asset_revisions"]) { const columns = await query.describeTable(table); if (!("brandId" in columns)) { await query.addColumn(table, "brandId", fk("asset_brands", true)); } } - AssetModel.init(asset, { sequelize: database, tableName: "assets", timestamps: false }); - AssetRevisionModel.init(revision, { - sequelize: database, - tableName: "asset_revisions", - timestamps: false, - }); } type LegacyAssignment = { diff --git a/modules/assets/src/api/infrastructure/persistence/brand-migration.ts b/modules/assets/src/api/infrastructure/persistence/brand-migration.ts index be766df..7ed7786 100644 --- a/modules/assets/src/api/infrastructure/persistence/brand-migration.ts +++ b/modules/assets/src/api/infrastructure/persistence/brand-migration.ts @@ -106,10 +106,18 @@ export async function importLegacyBrands(database: Sequelize) { ); const fieldId = definitions[0]?.id; if (fieldId) { - const rows = await database.query<{ values: unknown }>("SELECT `values` FROM assets", { - transaction, - type: QueryTypes.SELECT, - }); + const columns = await queryColumns(database); + const rows = columns.values + ? await database.query<{ values: unknown }>("SELECT `values` FROM assets", { + transaction, + type: QueryTypes.SELECT, + }) + : ( + await database.query<{ textValue: string }>( + "SELECT textValue FROM asset_characteristics WHERE fieldId=:fieldId AND textValue IS NOT NULL", + { replacements: { fieldId }, transaction, type: QueryTypes.SELECT } + ) + ).map((row) => ({ values: { characteristics: { [fieldId]: row.textValue } } })); for (const row of rows) { const name = legacyBrandName(row.values, fieldId); if (name === undefined) { @@ -146,3 +154,7 @@ function legacyBrandName(raw: unknown, fieldId: string): string | undefined { const mapped = mapBrandInput({ name, active: true }); return mapped.isSuccess ? mapped.data.name.toPrimitive() : undefined; } + +function queryColumns(database: Sequelize) { + return database.getQueryInterface().describeTable("assets"); +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts index c56e9b3..0639d2d 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository.ts @@ -5,15 +5,13 @@ import type { Sequelize, Transaction } from "sequelize"; import { Op, QueryTypes } from "sequelize"; import type { IAssetCatalogRepository } from "../../application/asset-catalog.repository"; +import type { AssetCatalog } from "../../domain/asset-catalog.aggregate"; import { AssetCatalogError } from "../../domain/asset-definition"; -import { - AvailabilityModel, - BindingModel, - CatalogModel, - DefinitionModel, -} from "./asset-catalog.model"; +import { AvailabilityModel, BindingModel, CatalogModel } from "./asset-catalog.model"; import type { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper"; +import { readBindings, readDefinitions, writeDefinition } from "./asset-catalog-relational"; +import { writeAssetRow } from "./asset-relational-sql"; export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository { constructor( @@ -40,16 +38,12 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")); } } - const definitions = await DefinitionModel.findAll({ - where: { catalogId: root.id }, - transaction, - }); - const bindings = definitions.length - ? await BindingModel.findAll({ - where: { templateId: definitions.map((item) => item.id) }, - transaction, - }) - : []; + const definitions = await readDefinitions(this.database, root.id, transaction); + const bindings = await readBindings( + this.database, + definitions.map((item) => item.id), + transaction + ); const overrides = await AvailabilityModel.findAll({ where: { catalogId: root.id }, transaction, @@ -72,7 +66,7 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository const rows = this.mapper.toPersistence(changed.data); // Primero las definiciones, después sus relaciones: las FK no admiten huérfanos. for (const row of rows.definitions) { - await DefinitionModel.upsert(row, { transaction }); + await writeDefinition(this.database, row, transaction); } const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings); const retained = new Set(rows.bindings.map((row) => row.id)); @@ -84,7 +78,15 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository }); } for (const row of rows.bindings) { - await BindingModel.upsert(row, { transaction }); + await writeAssetRow( + this.database, + "asset_template_fields", + { + ...row, + sectionId: sectionForBinding(changed.data, row.templateId, row.fieldId), + }, + transaction + ); } for (const row of rows.availability) { await AvailabilityModel.upsert(row, { transaction }); @@ -93,3 +95,11 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository }); } } + +function sectionForBinding(catalog: AssetCatalog, templateId: string, fieldId: string) { + const values = catalog.definitions.find((item) => item.id.toPrimitive() === templateId)?.values + .data; + return values?.kind === "template" + ? (values.fields.find((field) => field.fieldId === fieldId)?.sectionId ?? null) + : null; +} diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index 45f41b8..e37466e 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -7,14 +7,10 @@ import type { IAssetRepository, InventoryContext } from "../../application/asset import { AssetCatalogError } from "../../domain/asset-definition"; import type { Workstation } from "../../domain/workstation.aggregate"; -import { AssetModel, AssetRevisionModel } from "./asset.model"; -import { - AvailabilityModel, - BindingModel, - CatalogModel, - DefinitionModel, -} from "./asset-catalog.model"; +import { AvailabilityModel, CatalogModel } from "./asset-catalog.model"; import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper"; +import { readBindings, readDefinitions } from "./asset-catalog-relational"; +import { readInventory, writeInventory } from "./asset-inventory-relational"; import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper"; import { RelationshipModel } from "./asset-relationship.model"; import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper"; @@ -51,16 +47,12 @@ export class SequelizeAssetRepository implements IAssetRepository { if (!companies.length) { return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")); } - const definitions = await DefinitionModel.findAll({ - where: { catalogId: root.id }, - transaction, - }); - const bindings = definitions.length - ? await BindingModel.findAll({ - where: { templateId: definitions.map((item) => item.id) }, - transaction, - }) - : []; + const definitions = await readDefinitions(this.database, root.id, transaction); + const bindings = await readBindings( + this.database, + definitions.map((item) => item.id), + transaction + ); const overrides = await AvailabilityModel.findAll({ where: { catalogId: root.id }, transaction, @@ -71,16 +63,11 @@ export class SequelizeAssetRepository implements IAssetRepository { bindings, overrides ); - const rows = await AssetModel.findAll({ - where: { companyId: companyId.toPrimitive() }, - transaction, - }); - const revisions = rows.length - ? await AssetRevisionModel.findAll({ - where: { assetId: rows.map((item) => item.id) }, - transaction, - }) - : []; + const { rows, revisions } = await readInventory( + this.database, + companyId.toPrimitive(), + transaction + ); const sites = await this.database.query<{ id: string }>( "SELECT id FROM company_sites WHERE companyId=:id", { replacements, type: QueryTypes.SELECT, transaction } @@ -245,13 +232,8 @@ export class SequelizeAssetRepository implements IAssetRepository { const previous = loaded.data.assets.find((asset) => asset.id.equals(changed.data.id)); if (changed.data !== previous) { const rows = this.mapper.toPersistence(changed.data); - await AssetModel.upsert(rows.asset, { transaction }); const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive())); - for (const revision of rows.revisions) { - if (!existing.has(revision.id)) { - await AssetRevisionModel.create(revision, { transaction }); - } - } + await writeInventory(this.database, rows, existing, transaction); } return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames }); }); diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts index 6fffae9..08586bd 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-support.repository.ts @@ -4,7 +4,7 @@ import type { Sequelize, Transaction } from "sequelize"; import { QueryTypes } from "sequelize"; import type { ISupportRepository, SupportContext } from "../../application/support.repository"; -import { AssetInputSchema } from "../../domain/asset-values"; +import { AssetStatusSchema } from "../../domain/asset-values"; import type { SupportConnection } from "../../domain/support-connection.aggregate"; import type { SupportTool } from "../../domain/support-tool.aggregate"; import { SupportError } from "../../domain/support-values"; @@ -32,16 +32,14 @@ export class SequelizeSupportRepository implements ISupportRepository { transaction: Transaction ): Promise> { const replacements = { companyId: company.toPrimitive(), assetId: asset.toPrimitive() }; - const assets = await this.database.query<{ values: unknown }>( - "SELECT `values` FROM assets WHERE companyId=:companyId AND id=:assetId", + const assets = await this.database.query<{ status: string }>( + "SELECT status FROM assets WHERE companyId=:companyId AND id=:assetId", { replacements, transaction, type: QueryTypes.SELECT } ); if (!assets[0]) { return Result.fail(new SupportError("ASSET_NOT_FOUND")); } - const assetValues = - typeof assets[0].values === "string" ? JSON.parse(assets[0].values) : assets[0].values; - const assetInput = AssetInputSchema.parse(assetValues); + const assetStatus = AssetStatusSchema.parse(assets[0].status); const roots = await this.database.query>( "SELECT id,companyId,assetId,toolId,`values`,preferredKey FROM asset_support WHERE companyId=:companyId AND assetId=:assetId", { replacements, transaction, type: QueryTypes.SELECT } @@ -55,7 +53,7 @@ export class SequelizeSupportRepository implements ISupportRepository { { transaction, type: QueryTypes.SELECT } ); return Result.ok({ - assetRetired: assetInput.status === "retired", + assetRetired: assetStatus === "retired", tools: await this.readTools(transaction), connections: roots.map((root) => this.mapper.toDomain( diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index 95cd2d7..ac400ef 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -27,6 +27,8 @@ import { } from "./infrastructure/persistence/asset-catalog.model"; import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper"; import { AssetDashboardReader } from "./infrastructure/persistence/asset-dashboard-reader"; +import { normalizeAssetInventory } from "./infrastructure/persistence/asset-normalization-migration"; +import { enforceAssetRelationalConstraints } from "./infrastructure/persistence/asset-relational-constraints"; import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model"; import { importLegacyBrands, migrateBrands } from "./infrastructure/persistence/brand-migration"; import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; @@ -53,11 +55,13 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque } await migrateBrands(database); await migrateInventory(database); - await importLegacyBrands(database); const brands = new BrandUseCases(new SequelizeBrandRepository(database)); await migrateRelationships(database); await migrateWorkstations(database); await migrateLegacyAssetAssignments(database); + await normalizeAssetInventory(database); + await importLegacyBrands(database); + await enforceAssetRelationalConstraints(database); await migrateSupport(database); const support = new SupportUseCases(new SequelizeSupportRepository(database)); const assetRepository = new SequelizeAssetRepository(database); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts index aded47e..d069bd8 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts @@ -14,7 +14,7 @@ const OPEN_TICKET = "JSON_UNQUOTE(JSON_EXTRACT(t.`values`,'$.state')) IN ('open','in_progress','waiting')"; const PENDING_INTERVENTION = "JSON_UNQUOTE(JSON_EXTRACT(i.`values`,'$.state')) IN ('pending','in_progress')"; -const PROFILE_REVIEW = `JSON_EXTRACT(p.\`values\`,'$.active')=true AND (person.active=0 OR JSON_UNQUOTE(JSON_EXTRACT(a.\`values\`,'$.status'))='retired' OR (p.accountId IS NOT NULL AND ( +const PROFILE_REVIEW = `JSON_EXTRACT(p.\`values\`,'$.active')=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND ( account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical' OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true) diff --git a/scripts/backup-assets-normalization.sh b/scripts/backup-assets-normalization.sh new file mode 100644 index 0000000..e5f3c68 --- /dev/null +++ b/scripts/backup-assets-normalization.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +cd /home/david/dev/metis-desk +backup_dir=/home/david/.local/state/metis-desk/backups +mkdir -p "$backup_dir" +chmod 700 "$backup_dir" +backup_file="$backup_dir/pre-normalizacion-activos-$(date +%Y%m%d-%H%M%S).sql" +umask 077 +docker compose exec -T database sh -c 'MYSQL_PWD="$MARIADB_PASSWORD" mariadb-dump --user="$MARIADB_USER" --single-transaction --hex-blob "$MARIADB_DATABASE"' > "$backup_file" +printf '%s\n' "$backup_file"