import { UniqueID } from "@repo/rdx-ddd"; import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; 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, allowNull: nullable, references: { model, key: "id" }, onDelete: "RESTRICT", }); export async function migrateInventory(database: Sequelize) { const query = database.getQueryInterface(); const tables = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); const asset = { id, companyId: fk("companies"), 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 }, }; const revision = { id, assetId: fk("assets"), actorId: fk("users"), siteId: fk("company_sites", true), brandId: fk("asset_brands", true), recordedAt: { type: DataTypes.STRING(30), allowNull: false }, values: { type: DataTypes.JSON, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false }, }; if (!tables.some((table) => table.name === "assets")) { await query.createTable("assets", asset); } if (!tables.some((table) => table.name === "asset_revisions")) { await query.createTable("asset_revisions", revision); } 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 = { id: string; companyId: string; siteId: string | null; personId: string; personName: string; siteName: string | null; actorId: string | null; }; type ActiveWorkstationAsset = { assetId: string; personId: string | null }; /** * Convierte la asignación histórica directa activo-persona en puestos activos antes de * retirar el campo. Si ya existe un puesto activo para el mismo activo y persona, la * relación ya está representada y no se duplica. */ export async function migrateLegacyAssetAssignments(database: Sequelize) { const query = database.getQueryInterface(); const assetColumns = await query.describeTable("assets"); if (!("personId" in assetColumns)) { return; } const legacyAssignments = await database.query( "SELECT a.id,a.companyId,a.siteId,a.personId,p.name AS personName,s.name AS siteName,(SELECT r.actorId FROM asset_revisions r WHERE r.assetId=a.id ORDER BY r.position DESC LIMIT 1) AS actorId FROM assets a JOIN company_contacts p ON p.id=a.personId AND p.companyId=a.companyId LEFT JOIN company_sites s ON s.id=a.siteId AND s.companyId=a.companyId WHERE a.personId IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.status')) <> 'retired'", { type: QueryTypes.SELECT } ); const activeWorkstationAssets = await database.query( "SELECT wa.assetId,w.personId FROM workstation_assets wa JOIN workstations w ON w.id=wa.workstationId WHERE wa.activeKey IS NOT NULL", { type: QueryTypes.SELECT } ); const activeByAsset = new Map(activeWorkstationAssets.map((item) => [item.assetId, item])); const groups = new Map(); for (const assignment of legacyAssignments) { const existing = activeByAsset.get(assignment.id); if (existing) { if (existing.personId !== assignment.personId) { throw new Error( `No se puede migrar el activo ${assignment.id}: ya pertenece a un puesto de otra persona.` ); } continue; } const key = [assignment.companyId, assignment.personId, assignment.siteId ?? ""].join(":"); const entries = groups.get(key) ?? []; entries.push(assignment); groups.set(key, entries); } await database.transaction(async (transaction) => { for (const entries of groups.values()) { const first = entries[0]; if (!(first?.actorId && first.personName)) { throw new Error("No se puede reconstruir el autor de la asignación histórica del activo."); } const workstationId = UniqueID.generateNewID().toPrimitive(); const revisionId = UniqueID.generateNewID().toPrimitive(); const values = { name: `Puesto migrado: ${first.personName}${first.siteName ? ` · ${first.siteName}` : ""}`.slice( 0, 100 ), active: true, siteId: first.siteId, personId: first.personId, assetIds: entries.map((entry) => entry.id), sharedAssetIds: [], }; const recordedAt = new Date().toISOString(); await database.query( "INSERT INTO workstations (id,companyId,siteId,personId,`values`) VALUES (:id,:companyId,:siteId,:personId,:values)", { replacements: { id: workstationId, companyId: first.companyId, siteId: first.siteId, personId: first.personId, values: JSON.stringify(values), }, transaction, } ); await database.query( "INSERT INTO workstation_revisions (id,workstationId,actorId,siteId,personId,`values`,recordedAt,position) VALUES (:id,:workstationId,:actorId,:siteId,:personId,:values,:recordedAt,0)", { replacements: { id: revisionId, workstationId, actorId: first.actorId, siteId: first.siteId, personId: first.personId, values: JSON.stringify(values), recordedAt, }, transaction, } ); for (const entry of entries) { await database.query( "INSERT INTO workstation_assets (workstationId,companyId,assetId,activeKey) VALUES (:workstationId,:companyId,:assetId,:activeKey)", { replacements: { workstationId, companyId: first.companyId, assetId: entry.id, activeKey: `${first.companyId}:${entry.id}`, }, transaction, } ); } } await database.query("UPDATE assets SET `values`=JSON_REMOVE(`values`,'$.personId')", { transaction, }); await database.query("UPDATE asset_revisions SET `values`=JSON_REMOVE(`values`,'$.personId')", { transaction, }); }); await removeLegacyPersonColumn(database, "asset_revisions"); await removeLegacyPersonColumn(database, "assets"); } async function removeLegacyPersonColumn(database: Sequelize, table: "assets" | "asset_revisions") { const columns = await database.getQueryInterface().describeTable(table); if (!("personId" in columns)) { return; } const constraints = await database.query<{ constraintName: string }>( "SELECT CONSTRAINT_NAME AS constraintName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='personId' AND REFERENCED_TABLE_NAME IS NOT NULL", { replacements: { table }, type: QueryTypes.SELECT } ); for (const { constraintName } of constraints) { await database.query( `ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraintName.replaceAll("`", "``")}\`` ); } // QueryInterface.removeColumn is incompatible with Sequelize 6 and mariadb 3 here. await database.query(`ALTER TABLE \`${table}\` DROP COLUMN personId`); }