feat: add AssetRelatedInformation component for displaying asset profiles and software installations
test: implement resource template propagation tests for resource revisions feat: create prepareResourceTemplateUpdates function for handling resource template updates feat: add resource revision specifications for managing resource revisions in the database test: add tests for entity column visibility and local storage handling feat: implement entity column visibility storage for managing column preferences feat: create synchronizeTemplateStructure utility for managing template field and section synchronization
This commit is contained in:
parent
4bfbb30be0
commit
ded45e1c42
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts",
|
||||
"test": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/template-propagation.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts",
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"start": "node --env-file=../../.env dist/index.js"
|
||||
},
|
||||
|
||||
@ -14,9 +14,11 @@ import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastr
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { revisionSpecificationTestTables } from "./template-specification-test-tables";
|
||||
|
||||
// Tablas temporales de la conexión: no se modifica el esquema ni el inventario de la base de pruebas.
|
||||
const TABLES = {
|
||||
...revisionSpecificationTestTables("asset"),
|
||||
assets:
|
||||
"id CHAR(36) PRIMARY KEY, companyId CHAR(36), templateId CHAR(36), siteId CHAR(36), brandId CHAR(36), name VARCHAR(100), registeredOn DATE, status VARCHAR(15), notes TEXT, brandDefined BOOLEAN, templateName VARCHAR(100), sectionsDefined BOOLEAN",
|
||||
asset_revisions:
|
||||
|
||||
468
apps/server/src/template-propagation.integration.test.ts
Normal file
468
apps/server/src/template-propagation.integration.test.ts
Normal file
@ -0,0 +1,468 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
|
||||
import { AssetCatalogUseCases } from "../../../modules/assets/src/api/application/asset-catalog.use-cases";
|
||||
import { Asset } from "../../../modules/assets/src/api/domain/asset.aggregate";
|
||||
import {
|
||||
AssetSpecification,
|
||||
AssetValues,
|
||||
} from "../../../modules/assets/src/api/domain/value-objects/asset-values.vo";
|
||||
import {
|
||||
AvailabilityModel,
|
||||
BindingModel,
|
||||
CatalogModel,
|
||||
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog.model";
|
||||
import { AssetCatalogPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog-persistence.mapper";
|
||||
import {
|
||||
readInventory as readAssets,
|
||||
writeInventory as writeAssets,
|
||||
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-inventory-relational";
|
||||
import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper";
|
||||
import { SequelizeAssetCatalogRepository } from "../../../modules/assets/src/api/infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||
import { ResourceCatalogUseCases } from "../../../modules/resources/src/api/application/resource-catalog.use-cases";
|
||||
import { Resource } from "../../../modules/resources/src/api/domain/resource.aggregate";
|
||||
import {
|
||||
ResourceSpecification,
|
||||
ResourceValues,
|
||||
} from "../../../modules/resources/src/api/domain/value-objects/resource-values.vo";
|
||||
import {
|
||||
ResourceAvailabilityModel,
|
||||
ResourceBindingModel,
|
||||
ResourceCatalogModel,
|
||||
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog.model";
|
||||
import { ResourceCatalogPersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog-persistence.mapper";
|
||||
import {
|
||||
readInventory as readResources,
|
||||
writeInventory as writeResources,
|
||||
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational";
|
||||
import { ResourcePersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper";
|
||||
import { SequelizeResourceCatalogRepository } from "../../../modules/resources/src/api/infrastructure/persistence/sequelize-resource-catalog.repository";
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { revisionSpecificationTestTables } from "./template-specification-test-tables";
|
||||
|
||||
const id = () => UniqueID.generateNewID();
|
||||
|
||||
function tables(entity: "asset" | "resource") {
|
||||
const owner = `${entity}Id`;
|
||||
const integration =
|
||||
"integrationKind VARCHAR(10),addressFieldId CHAR(36),contextFieldId CHAR(36),mailboxKind VARCHAR(10),accountId CHAR(36),referenceFieldId CHAR(36),resourceTypeId CHAR(36),systemId CHAR(36),environmentId CHAR(36)";
|
||||
const values =
|
||||
entity === "asset"
|
||||
? "siteId CHAR(36),brandId CHAR(36),registeredOn DATE,status VARCHAR(15),brandDefined BOOLEAN"
|
||||
: `active BOOLEAN,${integration}`;
|
||||
const characteristic =
|
||||
"fieldId CHAR(36),valueType VARCHAR(12),textValue TEXT,numberValue DOUBLE,booleanValue BOOLEAN,dateValue DATE";
|
||||
|
||||
return {
|
||||
companies: "id CHAR(36) PRIMARY KEY",
|
||||
[`${entity}_catalogs`]: "id CHAR(36) PRIMARY KEY,catalogKey VARCHAR(20)",
|
||||
[`${entity}_definitions`]: `id CHAR(36) PRIMARY KEY,catalogId CHAR(36),companyId CHAR(36),stockKey VARCHAR(100),active BOOLEAN,kind VARCHAR(12),name VARCHAR(100),description TEXT,icon VARCHAR(30),fieldType VARCHAR(12),unit VARCHAR(30),sectionsDefined BOOLEAN,${entity === "asset" ? "inverseName VARCHAR(100),symmetric BOOLEAN" : "`usage` VARCHAR(15)"}`,
|
||||
[`${entity}_template_fields`]:
|
||||
"id CHAR(36) PRIMARY KEY,templateId CHAR(36),fieldId CHAR(36),required BOOLEAN,position INTEGER,sectionId CHAR(36)",
|
||||
[`${entity}_template_sections`]:
|
||||
"id CHAR(36),templateId CHAR(36),name VARCHAR(100),description TEXT,position INTEGER,PRIMARY KEY(templateId,id)",
|
||||
[`${entity}_field_choices`]:
|
||||
"fieldId CHAR(36),position INTEGER,value VARCHAR(100),PRIMARY KEY(fieldId,position)",
|
||||
[`${entity}_availability`]:
|
||||
"id CHAR(36) PRIMARY KEY,catalogId CHAR(36),companyId CHAR(36),definitionId CHAR(36),active BOOLEAN",
|
||||
[`${entity}s`]: `id CHAR(36) PRIMARY KEY,companyId CHAR(36),templateId CHAR(36),name VARCHAR(100),notes TEXT,templateName VARCHAR(100),sectionsDefined BOOLEAN,${values}, CHECK (name <> 'Second' OR templateName <> 'Rechazada')`,
|
||||
[`${entity}_revisions`]: `id CHAR(36) PRIMARY KEY,${owner} CHAR(36),actorId CHAR(36),recordedAt VARCHAR(30),position INTEGER,templateId CHAR(36),name VARCHAR(100),notes TEXT,${values}`,
|
||||
[`${entity}_specification_fields`]: `${owner} CHAR(36),fieldId CHAR(36),name VARCHAR(100),fieldType VARCHAR(12),unit VARCHAR(30),required BOOLEAN,sectionId CHAR(36),position INTEGER,PRIMARY KEY(${owner},fieldId)`,
|
||||
[`${entity}_specification_sections`]: `${owner} CHAR(36),id CHAR(36),name VARCHAR(100),description TEXT,position INTEGER,PRIMARY KEY(${owner},id)`,
|
||||
[`${entity}_specification_field_choices`]: `${owner} CHAR(36),fieldId CHAR(36),position INTEGER,value VARCHAR(100),PRIMARY KEY(${owner},fieldId,position)`,
|
||||
[`${entity}_characteristics`]: `${owner} CHAR(36),${characteristic},PRIMARY KEY(${owner},fieldId)`,
|
||||
[`${entity}_revision_characteristics`]: `revisionId CHAR(36),${owner} CHAR(36),${characteristic},PRIMARY KEY(revisionId,fieldId)`,
|
||||
...revisionSpecificationTestTables(entity),
|
||||
};
|
||||
}
|
||||
|
||||
function initializeModels(database: Sequelize) {
|
||||
const identity = () => ({ type: DataTypes.UUID, allowNull: false });
|
||||
|
||||
const common = () => ({
|
||||
id: { ...identity(), primaryKey: true },
|
||||
catalogId: identity(),
|
||||
companyId: identity(),
|
||||
definitionId: identity(),
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: true },
|
||||
});
|
||||
|
||||
const bindings = () => ({
|
||||
id: { ...identity(), primaryKey: true },
|
||||
templateId: identity(),
|
||||
fieldId: identity(),
|
||||
required: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
sectionId: { type: DataTypes.UUID, allowNull: true },
|
||||
});
|
||||
|
||||
CatalogModel.init(
|
||||
{
|
||||
id: { ...identity(), primaryKey: true },
|
||||
catalogKey: { type: DataTypes.STRING(20), allowNull: false },
|
||||
},
|
||||
{ sequelize: database, tableName: "asset_catalogs", timestamps: false }
|
||||
);
|
||||
AvailabilityModel.init(common(), {
|
||||
sequelize: database,
|
||||
tableName: "asset_availability",
|
||||
timestamps: false,
|
||||
});
|
||||
BindingModel.init(bindings(), {
|
||||
sequelize: database,
|
||||
tableName: "asset_template_fields",
|
||||
timestamps: false,
|
||||
});
|
||||
ResourceCatalogModel.init(
|
||||
{
|
||||
id: { ...identity(), primaryKey: true },
|
||||
catalogKey: { type: DataTypes.STRING(20), allowNull: false },
|
||||
},
|
||||
{ sequelize: database, tableName: "resource_catalogs", timestamps: false }
|
||||
);
|
||||
ResourceAvailabilityModel.init(common(), {
|
||||
sequelize: database,
|
||||
tableName: "resource_availability",
|
||||
timestamps: false,
|
||||
});
|
||||
ResourceBindingModel.init(bindings(), {
|
||||
sequelize: database,
|
||||
tableName: "resource_template_fields",
|
||||
timestamps: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntity(
|
||||
database: Sequelize,
|
||||
entity: "asset" | "resource",
|
||||
company: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
if (entity === "asset") {
|
||||
const data = await readAssets(database, company, transaction);
|
||||
const row = data.rows[0];
|
||||
|
||||
assert.ok(row);
|
||||
|
||||
return new AssetPersistenceMapper().toDomain(row, data.revisions);
|
||||
}
|
||||
const data = await readResources(database, company, transaction);
|
||||
const row = data.rows[0];
|
||||
|
||||
assert.ok(row);
|
||||
|
||||
return new ResourcePersistenceMapper().toDomain(row, data.revisions);
|
||||
}
|
||||
|
||||
function isolateTableQueries(database: Sequelize, names: readonly string[]) {
|
||||
const prefix = `tp_${id().toPrimitive().slice(-8)}_`;
|
||||
const tableNames = new Set(names);
|
||||
const originalQuery = database.query;
|
||||
|
||||
// Las tablas temporales de MariaDB no restauran DELETE + INSERT al volver a un savepoint.
|
||||
// Se aíslan tablas InnoDB reales mediante nombres exclusivos dentro de la base de pruebas.
|
||||
database.query = new Proxy(originalQuery, {
|
||||
apply(target, thisArg, args) {
|
||||
const sql: unknown = args[0];
|
||||
|
||||
const rewrite = (text: string) =>
|
||||
text.replace(/\b[a-z_]+\b/g, (name) => (tableNames.has(name) ? `${prefix}${name}` : name));
|
||||
|
||||
if (typeof sql === "string") {
|
||||
args[0] = rewrite(sql);
|
||||
} else if (
|
||||
sql &&
|
||||
typeof sql === "object" &&
|
||||
"query" in sql &&
|
||||
typeof sql.query === "string"
|
||||
) {
|
||||
args[0] = { ...sql, query: rewrite(sql.query) };
|
||||
} else {
|
||||
throw new Error("Consulta SQL de prueba no reconocida.");
|
||||
}
|
||||
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
database.query = originalQuery;
|
||||
};
|
||||
}
|
||||
|
||||
function nestedTransactionDatabase(database: Sequelize, transaction: Transaction) {
|
||||
// Los repositorios usan savepoints de la transacción que aísla los datos de la prueba.
|
||||
const originalTransaction = database.transaction.bind(database);
|
||||
|
||||
return new Proxy(database, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== "transaction") {
|
||||
return Reflect.get(target, property, receiver);
|
||||
}
|
||||
|
||||
return async (operation: (transaction: Transaction) => Promise<unknown>) => {
|
||||
const savepoint = await originalTransaction({ transaction });
|
||||
|
||||
try {
|
||||
const result = await operation(savepoint);
|
||||
|
||||
await savepoint.commit();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await savepoint.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyPropagation(database: Sequelize, entity: "asset" | "resource") {
|
||||
const schemas = tables(entity);
|
||||
const restoreQueries = isolateTableQueries(database, Object.keys(schemas));
|
||||
|
||||
for (const [table, columns] of Object.entries(schemas)) {
|
||||
await database.query(`CREATE TABLE ${table} (${columns}) ENGINE=InnoDB`);
|
||||
}
|
||||
const transaction = await database.transaction();
|
||||
const fixedDatabase = nestedTransactionDatabase(database, transaction);
|
||||
|
||||
try {
|
||||
const actor = id();
|
||||
const companies = [id(), id()].sort((a, b) => a.toPrimitive().localeCompare(b.toPrimitive()));
|
||||
const catalogId = id();
|
||||
|
||||
await database.query(`INSERT INTO ${entity}_catalogs (id,catalogKey) VALUES (:id,'global')`, {
|
||||
replacements: { id: catalogId.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
for (const company of companies) {
|
||||
await database.query("INSERT INTO companies (id) VALUES (:id)", {
|
||||
replacements: { id: company.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
const service =
|
||||
entity === "asset"
|
||||
? new AssetCatalogUseCases(
|
||||
new SequelizeAssetCatalogRepository(fixedDatabase, new AssetCatalogPersistenceMapper())
|
||||
)
|
||||
: new ResourceCatalogUseCases(
|
||||
new SequelizeResourceCatalogRepository(
|
||||
fixedDatabase,
|
||||
new ResourceCatalogPersistenceMapper()
|
||||
)
|
||||
);
|
||||
const saved = await service.save(
|
||||
"global",
|
||||
undefined,
|
||||
{ kind: "field", name: "Valor", description: "", type: "text", unit: "", choices: [] },
|
||||
actor.toPrimitive()
|
||||
);
|
||||
|
||||
assert.ok(saved.isSuccess);
|
||||
const field = saved.data.definitions.find((item) => item.values.data.name === "Valor");
|
||||
|
||||
assert.ok(field);
|
||||
const section = { id: id().toPrimitive(), name: "Original", description: "" };
|
||||
const templateValues = {
|
||||
kind: "template" as const,
|
||||
name: "Modelo",
|
||||
description: "",
|
||||
fields: [{ fieldId: field.id.toPrimitive(), required: false, sectionId: section.id }],
|
||||
sections: [section],
|
||||
};
|
||||
const templateSaved = await service.save(
|
||||
"global",
|
||||
undefined,
|
||||
templateValues,
|
||||
actor.toPrimitive()
|
||||
);
|
||||
|
||||
assert.ok(templateSaved.isSuccess);
|
||||
const template = templateSaved.data.definitions.find(
|
||||
(item) => item.values.data.name === "Modelo"
|
||||
);
|
||||
|
||||
assert.ok(template);
|
||||
for (const company of companies) {
|
||||
const spec = {
|
||||
templateId: template.id.toPrimitive(),
|
||||
templateName: "Modelo",
|
||||
sections: [section],
|
||||
fields: [
|
||||
{
|
||||
fieldId: field.id.toPrimitive(),
|
||||
name: "Valor",
|
||||
type: "text" as const,
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
sectionId: section.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
const common = {
|
||||
name: company.equals(companies[1]) ? "Second" : "First",
|
||||
templateId: template.id.toPrimitive(),
|
||||
notes: "",
|
||||
characteristics: { [field.id.toPrimitive()]: "No perder" },
|
||||
};
|
||||
|
||||
if (entity === "asset") {
|
||||
const specification = AssetSpecification.create(spec);
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
const values = AssetValues.create(
|
||||
{ ...common, siteId: null, status: "operational" },
|
||||
specification.data
|
||||
);
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const item = Asset.create(
|
||||
company,
|
||||
specification.data,
|
||||
values.data,
|
||||
actor,
|
||||
"2026-10-18T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(item.isSuccess);
|
||||
await writeAssets(
|
||||
database,
|
||||
new AssetPersistenceMapper().toPersistence(item.data),
|
||||
new Set(),
|
||||
transaction
|
||||
);
|
||||
} else {
|
||||
const specification = ResourceSpecification.create(spec);
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
const values = ResourceValues.create({ ...common, active: true }, specification.data);
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const item = Resource.create(
|
||||
company,
|
||||
specification.data,
|
||||
values.data,
|
||||
actor,
|
||||
"2026-10-18T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(item.isSuccess);
|
||||
await writeResources(
|
||||
database,
|
||||
new ResourcePersistenceMapper().toPersistence(item.data),
|
||||
new Set(),
|
||||
transaction
|
||||
);
|
||||
}
|
||||
}
|
||||
const added = await service.save(
|
||||
"global",
|
||||
undefined,
|
||||
{ kind: "field", name: "Nuevo", description: "", type: "text", unit: "", choices: [] },
|
||||
actor.toPrimitive()
|
||||
);
|
||||
|
||||
assert.ok(added.isSuccess);
|
||||
const newField = added.data.definitions.find((item) => item.values.data.name === "Nuevo");
|
||||
|
||||
assert.ok(newField);
|
||||
const next = {
|
||||
...templateValues,
|
||||
sections: [{ ...section, name: "Reordenada" }],
|
||||
fields: [{ fieldId: newField.id.toPrimitive(), required: true, sectionId: section.id }],
|
||||
};
|
||||
const changed = await service.save(
|
||||
"global",
|
||||
template.id.toPrimitive(),
|
||||
next,
|
||||
actor.toPrimitive()
|
||||
);
|
||||
|
||||
assert.ok(changed.isSuccess);
|
||||
const read = entity === "asset" ? readAssets : readResources;
|
||||
|
||||
for (const company of companies) {
|
||||
const data = await read(database, company.toPrimitive(), transaction);
|
||||
|
||||
assert.equal(data.rows.length, 1);
|
||||
const parsed = await readEntity(database, entity, company.toPrimitive(), transaction);
|
||||
|
||||
assert.equal(parsed.history.length, 2);
|
||||
assert.equal(
|
||||
parsed.values.toPrimitive().characteristics[field.id.toPrimitive()],
|
||||
"No perder"
|
||||
);
|
||||
assert.deepEqual(
|
||||
parsed.specification.toPrimitive().fields.map((item) => item.fieldId),
|
||||
[newField.id.toPrimitive(), field.id.toPrimitive()]
|
||||
);
|
||||
assert.equal(parsed.history[0]?.specification.toPrimitive().sections?.[0]?.name, "Original");
|
||||
}
|
||||
const repeated = await service.save(
|
||||
"global",
|
||||
template.id.toPrimitive(),
|
||||
next,
|
||||
actor.toPrimitive()
|
||||
);
|
||||
|
||||
assert.ok(repeated.isSuccess);
|
||||
const count = await database.query<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM ${entity}_revisions`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
assert.equal(Number(count[0]?.count), 4);
|
||||
await assert.rejects(() =>
|
||||
service.save(
|
||||
"global",
|
||||
template.id.toPrimitive(),
|
||||
{ ...next, name: "Rechazada" },
|
||||
actor.toPrimitive()
|
||||
)
|
||||
);
|
||||
const after = await service.get("global");
|
||||
|
||||
assert.ok(after.isSuccess);
|
||||
assert.equal(
|
||||
after.data.definitions.find((item) => item.id.equals(template.id))?.values.data.name,
|
||||
"Modelo"
|
||||
);
|
||||
for (const company of companies) {
|
||||
const data = await read(database, company.toPrimitive(), transaction);
|
||||
|
||||
assert.equal(data.revisions.length, 2);
|
||||
}
|
||||
} finally {
|
||||
await transaction.rollback();
|
||||
for (const table of Object.keys(schemas).reverse()) {
|
||||
await database.query(`DROP TABLE IF EXISTS ${table}`);
|
||||
}
|
||||
restoreQueries();
|
||||
}
|
||||
}
|
||||
|
||||
test("MariaDB: las plantillas propagan estructura en todas las compañías de forma atómica e idempotente", async () => {
|
||||
const env = readDatabaseEnvironment(process.env);
|
||||
|
||||
assert.equal(env.DB_NAME, "metis_desk_test");
|
||||
assert.equal(env.NODE_ENV, "test");
|
||||
const database = createDatabase(env);
|
||||
|
||||
initializeModels(database);
|
||||
try {
|
||||
for (const entity of ["asset", "resource"] as const) {
|
||||
await verifyPropagation(database, entity);
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
11
apps/server/src/template-specification-test-tables.ts
Normal file
11
apps/server/src/template-specification-test-tables.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** Esquemas temporales sin datos compartidos: las pruebas reales usan la misma lectura/escritura relacional. */
|
||||
export function revisionSpecificationTestTables(entity: "asset" | "resource") {
|
||||
const owner = `${entity}Id`;
|
||||
|
||||
return {
|
||||
[`${entity}_revision_specifications`]: `revisionId CHAR(36) PRIMARY KEY, ${owner} CHAR(36), templateId CHAR(36), templateName VARCHAR(100), sectionsDefined BOOLEAN`,
|
||||
[`${entity}_revision_specification_fields`]: `revisionId CHAR(36), ${owner} CHAR(36), fieldId CHAR(36), name VARCHAR(100), fieldType VARCHAR(12), unit VARCHAR(30), required BOOLEAN, sectionId CHAR(36), position INTEGER, PRIMARY KEY (revisionId,fieldId)`,
|
||||
[`${entity}_revision_specification_sections`]: `revisionId CHAR(36), ${owner} CHAR(36), id CHAR(36), name VARCHAR(100), description TEXT, position INTEGER, PRIMARY KEY (revisionId,id)`,
|
||||
[`${entity}_revision_specification_choices`]: `revisionId CHAR(36), ${owner} CHAR(36), fieldId CHAR(36), position INTEGER, value VARCHAR(100), PRIMARY KEY (revisionId,fieldId,position)`,
|
||||
};
|
||||
}
|
||||
@ -60,7 +60,7 @@ AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateFiel
|
||||
|
||||
ResourceCatalog es la raíz del catálogo de recursos; ResourceDefinition, TemplateField y CatalogAvailability tienen UUID propios e independientes de activos. ResourceDefinitionValues encapsula las definiciones. Resource es la raíz del recurso registrado y ResourceRevision conserva sus revisiones; ResourceSpecification y ResourceValues capturan la plantilla y los valores inmutables. Consultar [Recursos, campos y plantillas](../recursos-campos-y-plantillas.md).
|
||||
|
||||
Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md).
|
||||
Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación actual sincroniza los cambios de plantilla conservando los campos retirados con valor; cada revisión conserva su especificación histórica. Consultar [Inventario de activos](../inventario-de-activos.md).
|
||||
|
||||
AssetRelationship es la raíz de un vínculo entre dos activos de una compañía, con UUID propio y auditoría de creación y retirada. AssetRelationshipValues reconstruye extremos, tipo y nombres como Value Objects y conserva una instantánea. Consultar [Relaciones entre activos](../relaciones-entre-activos.md).
|
||||
|
||||
|
||||
229
docs/auditoria-exportacion-unifi-abeto-design.md
Normal file
229
docs/auditoria-exportacion-unifi-abeto-design.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Auditoría de la exportación UniFi de Abeto Design
|
||||
|
||||
Fecha: 18 de octubre de 2026.
|
||||
|
||||
Fuentes contrastadas:
|
||||
|
||||
- `UniFi_Inventario_20261018_141642_88f7af54-98f8-306a-a1c7-c9349722b1f6.json`, en la raíz del repositorio.
|
||||
- Modelo, contratos y persistencia actuales de Assets y configuraciones de impresoras de Companies.
|
||||
- Consulta de la base local, dentro de una transacción de **solo lectura**, para Abeto Design (Uecko).
|
||||
|
||||
No se han importado activos, modificado relaciones ni cambiado datos de la compañía.
|
||||
El fichero original se conserva intacto. Por indicación del usuario, se ignoran los
|
||||
errores 404 y las colecciones vacías: no se consideran incidencias ni condicionan
|
||||
las recomendaciones. Este análisis distingue datos explícitos, coincidencias candidatas
|
||||
e información que los registros presentes no permiten determinar.
|
||||
|
||||
## Dictamen
|
||||
|
||||
El modelo actual sirve para el alcance acordado: inventario de equipos, interfaces
|
||||
con MAC/IP, conexiones entre activos y uso de impresoras mediante perfiles. Las 36
|
||||
conexiones cliente → equipo de red reportadas en el fichero pueden representarse con
|
||||
**Conectado a**, una vez identificados sus extremos. No hacen falta puertos ni cableado.
|
||||
|
||||
No conviene importar automáticamente todo el fichero. Los registros presentes no
|
||||
identifican todas las interfaces ni la topología entre dispositivos de infraestructura,
|
||||
y es necesario conciliar los hostnames con los activos existentes. Además,
|
||||
hay ambigüedades concretas en el inventario.
|
||||
La prioridad es preparar una conciliación revisable y conservar la procedencia de las
|
||||
observaciones; no ampliar Metis hasta convertirlo en otro controlador de red.
|
||||
|
||||
## 1. Contenido y calidad de la exportación
|
||||
|
||||
El instante indicado en `metadata.fecha_utc` es el 18/10/2026 a las 12:16:04 UTC
|
||||
(14:16:04 en Madrid). La versión declarada de UniFi Network es 10.6.106 y el sitio se
|
||||
llama **Default**. La vinculación con Abeto procede del contexto proporcionado por el
|
||||
usuario; ese nombre de sitio, por sí solo, no identifica una compañía ni una sede física.
|
||||
|
||||
| Conjunto | Registros | Observación |
|
||||
| --- | ---: | --- |
|
||||
| Dispositivos gestionados | 16 | 8 switches, 7 puntos de acceso y 1 UDM Pro, clasificados por los modelos y funciones exportados |
|
||||
| Clientes | 36 | 24 WIRED y 12 WIRELESS |
|
||||
| Redes/VLAN | 3 | Default/1, GUEST/4 y Energía/6 |
|
||||
| Redes Wi-Fi | 2 | Una referencia NATIVE y otra SPECIFIC con `networkId` |
|
||||
| WAN | 2 | Solo identificador y nombre |
|
||||
| Políticas de firewall | 94 | 73 SYSTEM_DEFINED y 21 USER_DEFINED |
|
||||
| Zonas de firewall | 6 | Incluyen referencias a redes |
|
||||
| Listas de tráfico | 1 | Contiene 6 elementos |
|
||||
| Perfiles RADIUS | 2 | Información resumida |
|
||||
| Servidores VPN | 1 | Tipo WIREGUARD |
|
||||
| Túneles VPN | 8 | 6 IPSEC y 2 OPENVPN; no incluyen toda su configuración |
|
||||
|
||||
Observaciones verificadas:
|
||||
|
||||
- Las colecciones principales son arrays dentro de un array: `datos.clientes[0]`,
|
||||
`datos.dispositivos[0]`, etc. Un lector debe normalizar esta estructura.
|
||||
- Hay 52 identificadores, MAC e IP en las dos colecciones de equipos/clientes,
|
||||
sin duplicados entre ellas. Las 52 MAC tienen formato válido y todas las IP son IPv4.
|
||||
**Esto no prueba que existan 52 activos físicos diferentes**: pueden ser interfaces,
|
||||
controladoras de gestión u observaciones del mismo equipo.
|
||||
- Los 36 `uplinkDeviceId` se resuelven contra los 16 dispositivos exportados.
|
||||
- Todos los dispositivos gestionados figuran ONLINE. El fichero no representa un
|
||||
inventario completo de todos los equipos de la empresa ni de todos sus estados.
|
||||
- Los dispositivos contienen `interfaces: ["ports"]` o `["radios"]`. Son etiquetas
|
||||
de capacidades; no hay nombres de interfaces, listas de NIC, IP por NIC ni uplinks
|
||||
de los switches/AP. Un AP con radio no permite asignar su IP de gestión a Wi-Fi.
|
||||
- Hay texto con codificación dañada: tres nombres de dispositivos, uno de cliente y
|
||||
el nombre de la red Energía. Corregir la decodificación en el origen antes de importar.
|
||||
- Dos clientes tienen MAC administradas localmente. Esa característica no demuestra
|
||||
aleatorización, pero aconseja no asumir que toda MAC sea una identidad permanente.
|
||||
|
||||
## 2. Cobertura del modelo de Metis Desk
|
||||
|
||||
| Información de UniFi | Encaje actual | Condición o límite |
|
||||
| --- | --- | --- |
|
||||
| Switch, AP, gateway, servidor, NAS, impresora, ordenador | Activos y plantillas | Elegir la plantilla por evidencia. Las funciones de infraestructura complementan el tipo de activo |
|
||||
| Nombre y modelo | Nombre y características | Guardables; los nombres de cliente pueden ser alias o incluir sufijos de MAC |
|
||||
| MAC e IP de un cliente | Interfaz con direcciones | WIRED/WIRELESS permite proponer Ethernet/Wi-Fi; confirmar la correspondencia con el activo |
|
||||
| IP/MAC de gestión de dispositivo UniFi | Infraestructura del activo | Guardables, pero el resumen no demuestra qué interfaz física es propietaria de ambos valores |
|
||||
| Varias interfaces y varias IP | Soportado directamente | Hasta 32 interfaces por activo y 32 IP por interfaz; el fichero no contiene su inventario completo |
|
||||
| Cliente → switch/AP | Relación Conectado a | 36 candidatos con extremos externos resueltos; falta conciliarlos con activos de Metis |
|
||||
| Switch → switch, AP → switch o switch → gateway | El modelo lo soporta | El fichero no trae esos uplinks; no se deben inventar a partir de IP o ubicación |
|
||||
| Firmware | Característica de plantilla o nota | No es un campo estándar de las plantillas de serie ni hay sincronización automática |
|
||||
| ONLINE, actualización disponible, acceso DEFAULT | Observación operativa | No equivale al estado técnico, la asignación o la disponibilidad de un activo en Metis |
|
||||
| `connectedAt` | Fecha de conexión observada | No equivale a compra, alta del activo ni creación de una relación en Metis |
|
||||
| Qué ordenadores usan una impresora | Configuraciones de impresora por perfil | El fichero no incluye instalaciones, colas, perfiles ni actividad de impresión |
|
||||
| Redes, VLAN, SSID, WAN, zonas y reglas | Sin modelo especializado equivalente | Se podrían documentar descripciones en recursos; eso no preserva su estructura ni relaciones como modelo de red |
|
||||
| VPN de sitio y sus túneles | Sin modelo especializado equivalente | No confundir túneles entre redes con autorizaciones personales de acceso remoto |
|
||||
| Identidad UniFi | No se almacena por decisión del usuario | Se concilia por hostname y se revisan las ambigüedades; MAC/IP se conservan como información de las interfaces |
|
||||
|
||||
No debe afirmarse un porcentaje único de cobertura: conservar un nombre en notas
|
||||
no equivale a representar una regla de firewall, una interfaz real o una relación
|
||||
consultable. Para el objetivo **qué equipo es, qué IP tiene y a qué equipo de red se
|
||||
conecta**, la cobertura conceptual es suficiente; la preparación de datos e importación
|
||||
siguen pendientes.
|
||||
|
||||
## 3. Contraste con los datos actuales de Abeto Design
|
||||
|
||||
La consulta previa a la documentación de infraestructura encontró **53 activos**, de los que 7 tienen plantilla Impresora
|
||||
y uno Firewall. Hay tres agregados de infraestructura, tres interfaces actuales,
|
||||
dos direcciones actuales, una interfaz con MAC y **cero relaciones entre activos**.
|
||||
Existe una configuración de impresora, asociada a la impresora de administración;
|
||||
el recuento no se interpreta como confirmación de instalación efectiva.
|
||||
|
||||
### Impresoras
|
||||
|
||||
| Cliente observado | IP | Uplink exportado | Candidato existente en Metis |
|
||||
| --- | --- | --- | --- |
|
||||
| KYOCERA PRODUCCIÓN | 192.168.5.201 | USW Pro 24 | Impresora de producción; coincide la IP guardada como característica |
|
||||
| Kyocera ECOSYS MA3500cix | 192.168.5.151 | USW Pro 24 | Impresora de compras; coincide IP y modelo |
|
||||
| KM21A26C | 192.168.5.204 | Switch Lite 8 PoE (Administración) | Posible duplicidad entre Impresora de administración y ECOSYS MA3500ci |
|
||||
| D0J201401228 04:b7 | 192.168.5.50 | USW-Lite-8-PoE (Empaquetadora) | Impresora de empaquetadora; coincide IP y nombre de host sin sufijo |
|
||||
|
||||
Son candidatos de conciliación, no uniones confirmadas por MAC: ninguna de estas
|
||||
impresoras tiene actualmente una MAC estructurada que permita confirmarlas así.
|
||||
|
||||
- La impresora de administración conserva `.204` como característica y tiene una
|
||||
interfaz Ethernet **sin IP**. El activo ECOSYS MA3500ci guarda `KM21A26C` como nombre
|
||||
de host. UniFi vincula precisamente ese nombre con `.204`. Revisar físicamente o
|
||||
mediante serie/MAC antes de decidir si son el mismo equipo. No borrar ni fusionar
|
||||
automáticamente: uno tiene una configuración de impresión asociada.
|
||||
- KM621D07 (`.202`) sí tiene `.202` en una interfaz actual; no aparece en esta
|
||||
exportación. La etiquetadora de técnicos (`.203`) tampoco aparece. Su ausencia no
|
||||
permite concluir que estén retiradas, averiadas o desconectadas permanentemente.
|
||||
- Las cuatro conexiones de la tabla son representables con Conectado a. Los switches
|
||||
correspondientes todavía no constan como tales en el inventario consultado.
|
||||
- Las IP como características y las IP de interfaces coexisten. Para nuevos datos,
|
||||
usar interfaces como referencia operativa y revisar los valores anteriores, sin
|
||||
mantener dos fuentes editables para el mismo dato a largo plazo.
|
||||
|
||||
### UDM y otros equipos
|
||||
|
||||
- El UDM Pro existente **coincide por MAC** con el dispositivo Uecko de UniFi. Metis
|
||||
conserva una interfaz Local con `192.168.5.1`; la exportación presenta una IP pública.
|
||||
No es una contradicción suficiente para reemplazar la IP local. El resumen no
|
||||
identifica la interfaz WAN ni su MAC; hace falta el detalle antes de asignar esa
|
||||
IP pública a una interfaz concreta. Tampoco debe crearse un segundo activo UDM.
|
||||
- Los 16 clientes con nombres normalizados coincidentes apuntan a 15 nombres de
|
||||
ordenadores existentes. La normalización usada solo ignora mayúsculas, espacios
|
||||
exteriores y sufijos finales `xx:xx`. Son candidatos, no identidades verificadas.
|
||||
- Dos entradas, `DDES1-PC` y `DDES1-PC 77:df`, tienen MAC, IP y uplinks distintos y
|
||||
apuntan al mismo nombre base. Pueden corresponder a dos interfaces del mismo equipo
|
||||
o a equipos diferentes. No unirlas ni crear dos puestos sin comprobarlo.
|
||||
- `idrac-…` es un candidato a controladora de gestión de servidor, no una prueba de
|
||||
un servidor físico independiente. NOVASRV02 y UECKO-DS sugieren servidor/NAS por
|
||||
nombre, pero el tipo debe confirmarse. El resto incluye dispositivos que pueden
|
||||
necesitar plantillas propias y no necesariamente un puesto de trabajo.
|
||||
- Hay un cliente WIRED cuyo uplink es U6 Mesh Pro. Conservar el dato como conexión
|
||||
reportada y revisarlo; no convertirlo en Wi-Fi por el tipo del equipo aguas arriba.
|
||||
|
||||
## 4. Auditoría de relaciones
|
||||
|
||||
Las relaciones actuales tienen buenas bases para este caso:
|
||||
|
||||
- Extremos obligatorios de la misma compañía y prohibición de autovínculos.
|
||||
- Simetría de Conectado a, que permite consultar la conexión desde ambos activos.
|
||||
- Prevención de duplicados activos, también invirtiendo extremos simétricos.
|
||||
- Alta repetida equivalente idempotente y retirada que conserva autor y fecha.
|
||||
- Separación entre composición exclusiva del puesto y uso compartido de impresoras.
|
||||
|
||||
Las limitaciones para una importación repetible son concretas:
|
||||
|
||||
1. **Granularidad:** el vínculo identifica dos activos, no qué interfaz establece
|
||||
la conexión. Es suficiente para una impresora y un switch. Si un portátil usa
|
||||
Ethernet y Wi-Fi con uplinks diferentes, el grafo puede guardar ambos vecinos,
|
||||
pero no asignarlos de forma estructurada a sus interfaces. Si ambas interfaces
|
||||
llegan al mismo activo, Conectado a solo conserva un vínculo por tipo y extremos.
|
||||
2. **Dirección:** Conectado a es simétrica. Responde a qué equipos están conectados;
|
||||
no identifica por sí sola cuál es el uplink. No cambiar su simetría: conservar la
|
||||
dirección observada en los datos de procedencia o, si aparece una necesidad de
|
||||
consulta real, introducir un tipo dirigido adicional.
|
||||
3. **Procedencia y vigencia observada:** el vínculo guarda quién lo documentó y
|
||||
cuándo, pero no fuente externa, fecha de observación ni última confirmación.
|
||||
Una observación UniFi y una relación introducida manualmente no son intercambiables.
|
||||
4. **Movilidad:** una asociación Wi-Fi describe una observación temporal. Una nueva
|
||||
conexión no autoriza a retirar vínculos manuales ni a interpretar como bajas los
|
||||
equipos ausentes en una captura parcial.
|
||||
5. **Semántica:** no hay restricciones por plantilla ni detección de ciclos. Esto
|
||||
no bloquea el caso acordado; el importador debe validar compatibilidad y mostrar
|
||||
casos ambiguos antes de proponer relaciones.
|
||||
|
||||
Conectado a no debe utilizarse para expresar «utiliza esta impresora»: ese uso
|
||||
corresponde a las configuraciones por perfil. Tampoco estar en la misma red demuestra
|
||||
que un equipo imprima en una impresora o consuma un servicio de un NAS.
|
||||
|
||||
## 5. Recomendación de evolución
|
||||
|
||||
Orden propuesto, sin implementar cambios de dominio en esta auditoría:
|
||||
|
||||
1. **Normalizar los datos presentes:** aplanar los arrays y revisar la codificación
|
||||
de los nombres. No es necesario repetir la exportación ni resolver los 404 para
|
||||
aprovechar los equipos, IP/MAC y conexiones que ya contiene.
|
||||
2. **Conciliar antes de escribir:** pantalla de propuesta con activos existentes,
|
||||
altas nuevas y conflictos. Revisar especialmente UDM, administración y DDES1-PC.
|
||||
Tratar MAC/IP/nombre como evidencias con distinta fuerza, no como claves infalibles.
|
||||
3. **Conciliación por hostname:** por decisión posterior del usuario no se guardan
|
||||
identificadores UniFi. Normalizar mayúsculas, espacios y sufijos de presentación
|
||||
identificados; conservar los nombres de los activos existentes. Los nombres
|
||||
repetidos o referencias que pueden señalar un duplicado requieren revisión.
|
||||
Los dos U6 Mesh Pro se distinguen con sus IP, según la elección del usuario.
|
||||
4. **Procedencia de las conexiones:** guardar fecha de observación y origen,
|
||||
distinguir información manual/importada y mostrar qué va a cambiar. Una relación
|
||||
de importación con la interfaz local puede resolver Ethernet/Wi-Fi sin modelar
|
||||
puertos del switch. Evitar sustituir fechas de auditoría por `connectedAt`.
|
||||
5. **Importación mínima:** equipos aprobados, MAC/IP verificadas y las 36 conexiones
|
||||
candidatas. Sin sobrescribir valores manuales conflictivos ni inferir retiradas.
|
||||
Dejar redes/VLAN detalladas, firewall, RADIUS y VPN en UniFi por ahora.
|
||||
|
||||
No se recomienda crear otra entidad paralela de equipo de red, guardar el JSON
|
||||
completo como modelo de negocio ni convertir las redes, SSID y reglas en activos
|
||||
físicos. Si más adelante se necesitan redes o servicios lógicos consultables, definir
|
||||
un modelo acotado y relacional con consumidores reales, separado de la telemetría.
|
||||
|
||||
## Referencias de implementación revisadas
|
||||
|
||||
- [Infraestructura: contrato y límites](../modules/assets/src/api/domain/infrastructure-values.ts).
|
||||
- [Activo: datos y características](../modules/assets/src/api/domain/asset-values.ts).
|
||||
- [Plantillas de serie](../modules/assets/src/api/domain/asset-catalog-seed.ts).
|
||||
- [Semántica de relación](../modules/assets/src/api/domain/value-objects/asset-relationship-values.vo.ts).
|
||||
- [Contrato HTTP de relaciones](../modules/assets/src/common/relationships.ts).
|
||||
- [Configuración de impresoras por perfil](../modules/companies/src/api/domain/printer-configuration-values.ts).
|
||||
- [Documentación de infraestructura](infraestructura-informatica.md).
|
||||
- [Reglas de relaciones entre activos](relaciones-entre-activos.md).
|
||||
|
||||
## Documentación posterior a la auditoría
|
||||
|
||||
El usuario autorizó documentar la infraestructura a partir de los registros presentes.
|
||||
Se mantuvo el criterio de no guardar identidades UniFi. El resultado y los casos
|
||||
pendientes se recogen en [Documentación de infraestructura de Abeto Design](documentacion-infraestructura-abeto-design.md).
|
||||
Los recuentos del apartado 3 describen el estado previo a esa carga.
|
||||
@ -49,10 +49,7 @@ El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. E
|
||||
|
||||
Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes.
|
||||
|
||||
La [propuesta de evolución de plantillas de activos](evolucion-de-plantillas-de-activos.md)
|
||||
define cómo propagar cambios a los activos existentes de todas las compañías,
|
||||
conservando valores e historial. Está pendiente de implementación; no modifica el
|
||||
contrato actual de instantáneas.
|
||||
La [sincronización de plantillas](sincronizacion-de-plantillas.md) propaga los cambios a las fichas existentes de todas las compañías, conservando valores de campos retirados y la estructura original de cada revisión.
|
||||
|
||||
## Iconos de plantilla
|
||||
|
||||
|
||||
@ -1063,3 +1063,64 @@ comparten claves y estados remotos existentes. Son componentes propios de sus m
|
||||
las primitivas visuales genéricas siguen en @repo/rdx-ui y @repo/shadcn-ui.
|
||||
|
||||
Véase [Infraestructura informática](infraestructura-informatica.md).
|
||||
|
||||
### Ficha de activo con información e historial
|
||||
|
||||
`AssetDetailPage` (`@metis/assets/client`) representa la ruta
|
||||
`/companies/:companyId/assets/:assetId`. Reutiliza `ScrollableTabsList`, `DetailPanel`
|
||||
y `TimeLine`: Información agrupa los datos actuales, las características por sección,
|
||||
los puestos, las interfaces, el uso de impresoras, las relaciones y el soporte remoto.
|
||||
Companies compone además los perfiles del equipo y su software mediante `relatedContent`;
|
||||
Assets no importa consultas ni componentes de Companies.
|
||||
|
||||
Historial combina cronológicamente las revisiones del activo, las instantáneas de
|
||||
infraestructura, las altas/finalizaciones de relaciones y las revisiones de soporte.
|
||||
Cada revisión muestra sus valores registrados, incluidas interfaces retiradas. Las
|
||||
fechas y los actores proceden de los registros existentes; no se infieren eventos.
|
||||
Los perfiles y las instalaciones mantienen además sus historiales propios.
|
||||
|
||||
El nombre del activo y la acción de ver su ficha abren esta página. La selección de
|
||||
una zona no interactiva de la fila conserva el panel lateral en escritorio y el
|
||||
diálogo en móvil; este último comparte las dos pestañas mediante `AssetDetailContent`.
|
||||
La vuelta al inventario conserva los parámetros de búsqueda y plantilla de la URL.
|
||||
|
||||
### Columnas opcionales de activos
|
||||
|
||||
`AssetList` ofrece las características del catálogo activo y de las instantáneas de los
|
||||
activos, unidas por UUID de campo. Las columnas siguen disponibles al cambiar de pestaña;
|
||||
no se limitan a las filas filtradas. Dos campos distintos con el mismo nombre conservan
|
||||
columnas independientes y etiquetas numeradas. Cada celda usa el tipo y la unidad de la
|
||||
instantánea del activo, aunque después cambie el catálogo.
|
||||
|
||||
También ofrece notas, estado técnico, asignación a puesto, fechas/autores de creación y
|
||||
último cambio del activo, funciones de infraestructura e interfaces con sus IP, MAC y
|
||||
notas. Las direcciones conservan el nombre de la interfaz al presentarse. Las revisiones
|
||||
de infraestructura tienen su historial propio; no modifican la fecha de revisión del
|
||||
agregado Asset. Los datos de red llegan mediante props y consultas compartidas de sus
|
||||
consumidores; el listado presentacional no conoce endpoints.
|
||||
|
||||
Las columnas adicionales empiezan desmarcadas en Columnas para conservar el ancho inicial.
|
||||
`EntityColumn.defaultVisible` permite este comportamiento sin duplicar tablas. Las columnas
|
||||
nuevas que llegan tras una consulta también respetan ese valor; las elecciones locales del
|
||||
usuario prevalecen. Las columnas no ocultables y Acciones permanecen visibles. Los valores
|
||||
predeterminados anteriores se conservan en los consumidores que no usan esta propiedad.
|
||||
|
||||
Los booleanos se traducen a Sí/No, el cero se conserva y las fechas de calendario no se
|
||||
desplazan por zona horaria. Las celdas sin dato muestran exclusivamente «—». Las consultas
|
||||
de infraestructura muestran carga/error y reintento propios sin bloquear la selección de
|
||||
activos cuando el inventario y el catálogo están disponibles.
|
||||
|
||||
### Persistencia de columnas del inventario
|
||||
|
||||
El inventario de activos guarda la visibilidad elegida en `localStorage`, bajo
|
||||
`asset-inventory-columns`. La preferencia se comparte entre compañías en ese navegador
|
||||
para los campos con la misma identidad y se restaura al volver al listado o recargarlo.
|
||||
Los selectores de activos conservan su configuración independiente.
|
||||
|
||||
`EntityTable.columnVisibilityStorageKey` habilita esta persistencia de forma explícita;
|
||||
los demás listados mantienen el comportamiento local anterior. Solo se guardan IDs de
|
||||
columnas y booleanos, nunca valores de las filas. Se conservan preferencias de campos
|
||||
que todavía no se han cargado. Las columnas obligatorias siguen visibles aunque el
|
||||
almacenamiento contenga una preferencia contraria. Los datos inválidos o el almacenamiento
|
||||
bloqueado no impiden utilizar la tabla. Cambiar de clave carga las preferencias de la
|
||||
nueva vista sin escribir sobre ellas la selección de la anterior.
|
||||
|
||||
128
docs/documentacion-infraestructura-abeto-design.md
Normal file
128
docs/documentacion-infraestructura-abeto-design.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Documentación de infraestructura de Abeto Design
|
||||
|
||||
Carga realizada el 18 de octubre de 2026 a partir de la exportación de red facilitada
|
||||
por el usuario. Los errores 404 y las colecciones vacías no condicionan esta carga.
|
||||
No se han almacenado identificadores UniFi ni creado una integración automática.
|
||||
|
||||
## Resultado guardado en Metis
|
||||
|
||||
- 15 activos nuevos: 8 switches y 7 puntos de acceso.
|
||||
- UDM Pro existente reutilizado, con su nombre y dirección local conservados.
|
||||
- 19 relaciones Conectado a con activos existentes: 15 Ethernet y 4 Wi-Fi.
|
||||
- Interfaces con MAC/IP documentadas en esos 19 activos y en los 15 equipos nuevos.
|
||||
- 17 observaciones pendientes anotadas en la ficha del switch/AP/gateway correspondiente,
|
||||
sin inventar activos ni decidir asociaciones ambiguas.
|
||||
- Total de compañía después de la carga: 68 activos, 19 relaciones, 37 configuraciones
|
||||
de infraestructura, 37 interfaces actuales y 36 direcciones actuales.
|
||||
|
||||
Las capturas describen una observación del 18/10/2026, no conectividad comprobada en
|
||||
tiempo real. No se han creado puestos de trabajo ni configuraciones de impresión
|
||||
nuevas por el hecho de aparecer un cliente conectado a la red.
|
||||
|
||||
## Equipos de red
|
||||
|
||||
| Activo en Metis | Tipo | Dirección documentada |
|
||||
| --- | --- | --- |
|
||||
| USW Pro 24 | Switch | 192.168.5.21 |
|
||||
| USW Pro 48 (Rack) | Switch | 192.168.5.20 |
|
||||
| U6-Lite Oficina | Punto de acceso Wi-Fi | 192.168.5.22 |
|
||||
| U6-Lite Reuniones | Punto de acceso Wi-Fi | 192.168.5.11 |
|
||||
| U6-Lite Cafetería | Punto de acceso Wi-Fi | 192.168.5.15 |
|
||||
| U6-Lite Exposición | Punto de acceso Wi-Fi | 192.168.5.19 |
|
||||
| UDM Pro | Firewall / gateway | 192.168.5.1 conservada; IP pública observada documentada en notas |
|
||||
| USW-Lite-8-PoE (Empaquetadora) | Switch | 192.168.5.43 |
|
||||
| U6 Mesh Pro (192.168.5.82) | Punto de acceso Wi-Fi | 192.168.5.82 |
|
||||
| U6 Mesh Pro (192.168.5.84) | Punto de acceso Wi-Fi | 192.168.5.84 |
|
||||
| Switch Lite 8 PoE (Administración) | Switch | 192.168.5.10 |
|
||||
| USW Flex Mini Desarrollo | Switch | 192.168.5.51 |
|
||||
| USW Flex Mini Presupuestos | Switch | 192.168.5.110 |
|
||||
| USW Flex Mini Marketing | Switch | 192.168.5.98 |
|
||||
| USW Flex Mini Oficina Nueva | Switch | 192.168.5.64 |
|
||||
| AC LR Oficina nueva | Punto de acceso Wi-Fi | 192.168.5.143 |
|
||||
|
||||
Los dos U6 Mesh Pro llevan la IP en su nombre según la elección expresa del usuario.
|
||||
Se han corregido los caracteres dañados de los nombres al documentarlos.
|
||||
|
||||
La interfaz **Gestión (observada)** de los dispositivos nuevos utiliza el tipo Otra:
|
||||
la exportación aporta MAC/IP por dispositivo, pero no identifica su NIC física.
|
||||
Su nombre no representa un puerto de switch. El firmware y el nombre observado
|
||||
se conservan en las notas del activo. Las sedes desconocidas no se han deducido del
|
||||
nombre del sitio UniFi.
|
||||
|
||||
## Conexiones estructuradas
|
||||
|
||||
| Activo existente | Equipo de red | Conexión | IP observada |
|
||||
| --- | --- | --- | --- |
|
||||
| DADM1-PC | USW Pro 24 | Ethernet | 192.168.5.36 |
|
||||
| COMPRAS2-PC | USW Pro 24 | Ethernet | 192.168.5.37 |
|
||||
| COMPRAS2-PORT | USW Pro 24 | Ethernet | 192.168.5.79 |
|
||||
| Impresora de empaquetadora | USW-Lite-8-PoE (Empaquetadora) | Ethernet | 192.168.5.50 |
|
||||
| LOLA-PC | USW Pro 24 | Ethernet | 192.168.5.100 |
|
||||
| Comercial2-PC | USW Pro 48 (Rack) | Ethernet | 192.168.5.60 |
|
||||
| Impresora de producción | USW Pro 24 | Ethernet | 192.168.5.201 |
|
||||
| POSTVENTA-PC | U6-Lite Exposición | Wi-Fi | 192.168.5.102 |
|
||||
| Impresora de compras | USW Pro 24 | Ethernet | 192.168.5.151 |
|
||||
| DTECNICO4-PC | U6-Lite Oficina | Wi-Fi | 192.168.5.127 |
|
||||
| MARCOS-PORTATIL | USW Pro 48 (Rack) | Ethernet | 192.168.5.109 |
|
||||
| PUESTO8-PC | USW Flex Mini Presupuestos | Ethernet | 192.168.5.115 |
|
||||
| Vicente-PC | USW Pro 24 | Ethernet | 192.168.5.103 |
|
||||
| DTECNICO8-PC (Sobremesa montado por nosotros factura 13/10/2025) | USW Pro 48 (Rack) | Ethernet | 192.168.5.41 |
|
||||
| DADM2-PORT | Switch Lite 8 PoE (Administración) | Ethernet | 192.168.5.111 |
|
||||
| PUESTO6-PC (embalaje1) | USW-Lite-8-PoE (Empaquetadora) | Ethernet | 192.168.5.34 |
|
||||
| RRHH-PC | U6-Lite Oficina | Wi-Fi | 192.168.5.120 |
|
||||
| DTECNICO9-PORT | USW Flex Mini Desarrollo | Ethernet | 192.168.5.97 |
|
||||
| DDES2-PORT | U6-Lite Oficina | Wi-Fi | 192.168.5.46 |
|
||||
|
||||
Las tres impresoras vinculadas son Producción, Compras y Empaquetadora. La conexión
|
||||
física a su switch no declara qué usuarios imprimen en ellas: ese uso sigue siendo
|
||||
responsabilidad de las configuraciones por perfil.
|
||||
|
||||
## Observaciones pendientes
|
||||
|
||||
Estas entradas están documentadas en las notas del equipo de red indicado. No se
|
||||
han dado de alta como activos nuevos porque no se ha identificado con seguridad
|
||||
su correspondencia o su tipo de activo.
|
||||
|
||||
| Hostname o nombre observado | Equipo de red | IP | Motivo |
|
||||
| --- | --- | --- | --- |
|
||||
| 39E5AAHPSFA52FE c7:f6 | U6-Lite Reuniones | 192.168.5.148 | Sin correspondencia única con un activo existente. |
|
||||
| NOVASRV02 | USW Pro 48 (Rack) | 192.168.5.198 | Sin correspondencia única con un activo existente. |
|
||||
| UECKO-DS | USW Pro 48 (Rack) | 192.168.5.254 | Sin correspondencia única con un activo existente. |
|
||||
| idrac-BBZL2J2 6a:9b | UDM Pro | 192.168.5.53 | Sin correspondencia única con un activo existente. |
|
||||
| NetCam 0e:97 | U6-Lite Reuniones | 192.168.5.55 | Sin correspondencia única con un activo existente. |
|
||||
| 239D7AFPSF529B9 0f:b7 | U6 Mesh Pro (192.168.5.82) | 192.168.5.140 | Sin correspondencia única con un activo existente. |
|
||||
| 239D7AFPSFAEC30 0f:be | U6 Mesh Pro (192.168.5.84) | 192.168.5.136 | Sin correspondencia única con un activo existente. |
|
||||
| Huawei SmartLogger SL3000A | USW Pro 24 | 192.168.66.2 | Sin correspondencia única con un activo existente. |
|
||||
| KIT-FPC2500646 cc:b9 | U6 Mesh Pro (192.168.5.82) | 192.168.5.147 | Sin correspondencia única con un activo existente. |
|
||||
| 20063AHPSF590DC 9c:f9 | U6-Lite Cafetería | 192.168.5.126 | Sin correspondencia única con un activo existente. |
|
||||
| SDongleA-HV2190119039 ab:24 | U6-Lite Cafetería | 192.168.5.12 | Sin correspondencia única con un activo existente. |
|
||||
| KM21A26C a2:6c | Switch Lite 8 PoE (Administración) | 192.168.5.204 | Posible duplicidad de impresora de administración y ECOSYS MA3500ci; pendiente de conciliar. |
|
||||
| DDES1-PC | USW Pro 48 (Rack) | 192.168.5.26 | Hostname repetido en dos clientes con distinta MAC/IP; requiere identificar si son interfaces o equipos diferentes. |
|
||||
| SmartVU+ TV Box bc:1b | USW Pro 48 (Rack) | 192.168.5.123 | Sin correspondencia única con un activo existente. |
|
||||
| DDES1-PC 77:df | USW Flex Mini Presupuestos | 192.168.5.128 | Hostname repetido en dos clientes con distinta MAC/IP; requiere identificar si son interfaces o equipos diferentes. |
|
||||
| Redmi-9 9a:e3 | U6-Lite Oficina | 192.168.5.104 | Sin correspondencia única con un activo existente. |
|
||||
| ROCK-5 11:c8 | AC LR Oficina nueva | 192.168.5.141 | Sin correspondencia única con un activo existente. |
|
||||
|
||||
En particular, no se han fusionado las dos entradas DDES1-PC ni los activos que
|
||||
podrían representar la impresora de administración. Tampoco se han deducido enlaces
|
||||
entre switches y AP que no estén expresamente descritos en los registros presentes.
|
||||
|
||||
## Comprobaciones realizadas
|
||||
|
||||
- Validación previa de todos los valores mediante los contratos y reglas de dominio.
|
||||
- Escrituras mediante los casos de uso y repositorios existentes, con sus transacciones
|
||||
y sus revisiones de auditoría; sin SQL manual de modificación ni migraciones.
|
||||
- Lectura posterior de los agregados y comprobación de MAC/IP y extremos de conexión.
|
||||
- Los 53 activos previos conservan nombres, estados, características, plantillas y
|
||||
asignaciones. Solo se han ampliado las notas del UDM; no se ha reemplazado su IP local.
|
||||
- Repetición completa de la misma carga: cero activos adicionales, cero relaciones
|
||||
adicionales y exactamente las mismas revisiones de activos e infraestructura.
|
||||
- No se han modificado componentes, endpoints ni contratos de la aplicación para
|
||||
realizar esta carga. No corresponde ejecutar compilación o pruebas de código nuevas.
|
||||
|
||||
Puede consultarse en **Abeto Design → Infraestructura**, y en Información/Historial
|
||||
de cada activo. Las observaciones pendientes se encuentran en las notas del activo
|
||||
de red correspondiente.
|
||||
|
||||
Véase la [auditoría previa](auditoria-exportacion-unifi-abeto-design.md) para el análisis
|
||||
de cobertura y las limitaciones de la documentación de red.
|
||||
@ -1,10 +1,13 @@
|
||||
# Evolución de plantillas de activos
|
||||
|
||||
> Esta propuesta se ha implementado para activos y recursos. Las reglas vigentes y la migración se describen en [sincronización de plantillas](sincronizacion-de-plantillas.md); este documento conserva el análisis de diseño original.
|
||||
|
||||
|
||||
## Estado y objetivo
|
||||
|
||||
Propuesta pendiente de implementación. Define cómo deberían reflejarse las ediciones
|
||||
de una plantilla en los activos existentes de todas las compañías que la utilizan.
|
||||
No cambia el comportamiento actual ni las reglas del catálogo de recursos.
|
||||
Análisis original de la evolución de plantillas. Para el comportamiento implementado
|
||||
en activos y recursos, consultar [sincronización de plantillas](sincronizacion-de-plantillas.md).
|
||||
Los apartados siguientes describen la situación previa y las alternativas consideradas.
|
||||
|
||||
Actualmente `mapAssetInput` captura la especificación al crear el activo y reutiliza
|
||||
la anterior al editarlo. El formulario utiliza esa misma instantánea. Por tanto,
|
||||
|
||||
@ -22,9 +22,9 @@ La migración idempotente añade una columna `DATE` nullable `registeredOn` a `a
|
||||
|
||||
## Características y plantillas
|
||||
|
||||
El alta utiliza una plantilla activa global o propia de la compañía. Se captura una copia de su nombre, campos, tipos, unidades, valores de lista y obligatoriedad. La plantilla del activo queda fija. Cambiar o desactivar la definición del catálogo no cambia las fichas ni las revisiones existentes. Un activo existente sigue siendo editable aunque su plantilla se desactive.
|
||||
El alta utiliza una plantilla activa global o propia de la compañía. La referencia a la plantilla queda fija, pero su estructura se sincroniza al guardar cambios en el catálogo: se incorporan secciones y campos, se actualiza su orden y se retiran los campos vacíos. Los campos retirados que tengan valor y sus secciones se conservan en la ficha. Cada revisión mantiene su propia especificación histórica. Una plantilla desactivada permite consultar y editar los activos existentes.
|
||||
|
||||
Esta decisión evita que una edición del catálogo elimine características o convierta datos ya guardados en inválidos. Las nuevas características y opciones incorporadas posteriormente a una plantilla se aplican a los nuevos activos. La adaptación de fichas existentes a futuras versiones queda fuera de esta entrega.
|
||||
Los campos obligatorios nuevos no inventan valores ni impiden propagar la estructura. Se exigirán en la siguiente edición del activo. Véase [sincronización de plantillas](sincronizacion-de-plantillas.md).
|
||||
|
||||
Se validan texto, número, fecha real, sí/no y lista. No se aceptan campos ajenos a la especificación, tipos incorrectos ni opciones de lista desconocidas. Los valores cero y falso se conservan y satisfacen la obligatoriedad; un campo sin completar se omite. No se almacenan contraseñas mediante este catálogo.
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ El modelo utiliza columnas y relaciones para activos, recursos, configuraciones,
|
||||
| --- | --- |
|
||||
| Compañías, personas, departamentos, sedes, usuarios y marcas | Columnas y relaciones |
|
||||
| Catálogo e inventario de activos | Definiciones, campos tipados, especificaciones y revisiones en columnas y tablas |
|
||||
| Catálogo e inventario de recursos | Definiciones, opciones, secciones, especificaciones congeladas, valores tipados e integración en columnas/tablas |
|
||||
| Catálogo e inventario de recursos | Definiciones, opciones, secciones, especificaciones actuales e históricas, valores tipados e integración en columnas/tablas |
|
||||
| Puestos de trabajo | Nombre, estado y referencias en columnas; activos exclusivos/compartidos por puesto y revisión en tablas |
|
||||
| Asistencia técnica de activos | Referencia al recurso de asistencia, destino, VPN, preferencia, estado y notas en columnas actuales e históricas |
|
||||
| Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas |
|
||||
@ -30,10 +30,10 @@ Las tablas principales son `resource_definitions`, `resource_template_fields`, `
|
||||
|
||||
- `resource_field_choices`: opciones ordenadas de cada campo.
|
||||
- `resource_template_sections`: secciones ordenadas, identificadas por `(templateId, id)`; copiar una plantilla puede conservar los identificadores internos de sus secciones.
|
||||
- `resource_specification_sections`, `resource_specification_fields` y `resource_specification_field_choices`: definición congelada al crear el recurso.
|
||||
- `resource_specification_sections`, `resource_specification_fields` y `resource_specification_field_choices`: estructura actual del recurso, sincronizada con su plantilla y conservando campos retirados con valor.
|
||||
- `resource_characteristics` y `resource_revision_characteristics`: valores actuales e históricos con columnas independientes de texto, número, booleano y fecha.
|
||||
|
||||
La ausencia de una característica se representa mediante ausencia de fila. Una cadena vacía, `0` y `false` siguen siendo valores y se conservan. Las fechas se leen como fechas ISO. La especificación congelada no se reemplaza por el estado actual del catálogo.
|
||||
La ausencia de una característica se representa mediante ausencia de fila. El texto vacío o compuesto solo por espacios se normaliza como ausencia; `0` y `false` son valores y se conservan. Las fechas se leen como fechas ISO. Las revisiones conservan su propia especificación en las tablas `*_revision_specifications`, `*_revision_specification_sections`, `*_revision_specification_fields` y `*_revision_specification_choices`. La estructura actual sigue los cambios de plantilla con las reglas de [sincronización de plantillas](sincronizacion-de-plantillas.md).
|
||||
|
||||
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos conservan las integraciones y sus identidades; sus lectores y escritores también utilizan columnas y tablas normalizadas.
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
|
||||
|
||||
- Búsqueda y filtros se combinan; filtrar o cambiar tamaño de página vuelve a la primera página. Si una actualización reduce resultados, se ajusta la página para no mostrar un rango inexistente.
|
||||
- Contar los resultados filtrados antes de paginar. Cero resultados se expresa como 0-0 de 0. La ordenación actúa sobre todos los resultados filtrados antes de paginar y mantiene un orden estable para empates.
|
||||
- Columnas configurables mediante controles con etiquetas; identificación y acciones no se ocultan. No se pierde acceso a una acción por ocultar otra columna. La configuración es local a la vista y no se guarda en datos de negocio.
|
||||
- Columnas configurables mediante controles con etiquetas; identificación y acciones no se ocultan. No se pierde acceso a una acción por ocultar otra columna. La configuración es local a la vista y no se guarda en datos de negocio. El inventario de activos persiste la visibilidad en `localStorage`; los demás listados solo lo hacen cuando habilitan explícitamente una clave propia.
|
||||
- Solo ofrecer ordenación para valores con significado comparable. Todo listado ordenable comienza con una columna ordenada e indicada mediante `aria-sort` y el selector de ordenación. El criterio común es la primera columna ordenable en ascendente: Nombre en usuarios, activos y catálogos; Persona en teletrabajo y accesos remotos; Departamento en asignaciones. Cada listado puede declarar un criterio propio mediante `defaultSort`. Las cabeceras alternan ascendente/descendente y «Orden inicial» restaura el criterio del listado.
|
||||
- El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas.
|
||||
- Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual.
|
||||
@ -161,3 +161,13 @@ con cabecera accesible y tooltip con el nombre de plantilla, disponible con punt
|
||||
teclado. La columna Plantilla conserva solo el nombre. La columna visual no se ordena
|
||||
ni se oculta y no sustituye la ordenación inicial por Nombre. En `PickerList`, la
|
||||
selección permanece siempre por delante del icono.
|
||||
|
||||
### Campos adicionales disponibles en Activos
|
||||
|
||||
El control Columnas del inventario y de los selectores de activos incluye todos los campos
|
||||
activos del catálogo y los conservados en instantáneas de activos, además de los datos
|
||||
generales y de interfaces. Las columnas adicionales se ofrecen desmarcadas inicialmente;
|
||||
el usuario elige las que necesita. La identidad de cada columna de característica deriva
|
||||
del UUID de campo, nunca de su nombre. Los filtros por plantilla no reconstruyen el conjunto
|
||||
de columnas ni borran la elección local. No agregar columnas de historial completo a las
|
||||
filas: el historial sigue consultándose en la ficha correspondiente.
|
||||
|
||||
@ -39,7 +39,7 @@ Se validan tipos, fechas, listas y dirección de correo de los buzones. La direc
|
||||
|
||||
Un recurso pertenece a una compañía y tiene nombre, plantilla, estado activo, notas y características. No se pide persona, puesto ni dispositivo. Registrar un recurso no concede permisos ni configura equipos.
|
||||
|
||||
La plantilla queda fija tras el alta. Su especificación se captura al crear el recurso: nombres de campos, tipos, unidades, listas y obligatoriedad. Cambiar después el catálogo no reescribe las fichas ya registradas. Una plantilla desactivada impide nuevas altas, pero permite consultar y editar los recursos existentes con su especificación original.
|
||||
La referencia a la plantilla queda fija tras el alta. Su estructura se sincroniza al guardar cambios en el catálogo, conservando los campos retirados que tengan valor y sus secciones. Cada revisión mantiene su especificación histórica. Una plantilla desactivada impide nuevas altas, pero permite consultar y editar los recursos existentes. Véase [sincronización de plantillas](sincronizacion-de-plantillas.md).
|
||||
|
||||
El nombre es único dentro de compañía y plantilla, incluyendo recursos inactivos. Los recursos de acceso distinguen además tipo, sistema y entorno para conservar los contextos existentes. Las modificaciones conservan UUID y añaden revisión con fecha y usuario de Metis Desk. Repetir una edición equivalente no añade historial.
|
||||
|
||||
@ -96,9 +96,9 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop
|
||||
|
||||
## Persistencia relacional
|
||||
|
||||
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
||||
El catálogo, las especificaciones actuales e históricas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
||||
|
||||
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Los recursos ya creados conservan su especificación capturada, incluso si posteriormente se retiran campos de la plantilla.
|
||||
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Las ediciones de plantillas sincronizan las fichas existentes sin borrar los valores de campos retirados; cada revisión conserva su especificación original.
|
||||
|
||||
## Alta de recursos en tres pasos
|
||||
|
||||
|
||||
@ -149,3 +149,10 @@ despliegue ni añadido un fallback a Express en esta adaptación.
|
||||
## Funcionalidades archivadas
|
||||
|
||||
Tickets, intervenciones y plantillas de intervención no tienen rutas activas. Sus antiguas direcciones muestran página no encontrada. Consultar [el archivo](funcionalidades-archivadas.md).
|
||||
|
||||
### Ficha completa de un activo
|
||||
|
||||
`/companies/:companyId/assets/:assetId` muestra una página de detalle con las pestañas
|
||||
Información e Historial. El listado conserva la selección local para su panel lateral;
|
||||
los enlaces de nombre y las acciones de ver abren la página. Los parámetros `q` y
|
||||
`template` se conservan en los enlaces del listado y de retorno a este.
|
||||
|
||||
@ -27,7 +27,7 @@ Ante controles inválidos se abre la sección antes de que el navegador enfoque
|
||||
|
||||
Las secciones se guardan en asset_template_sections y resource_template_sections. Las asociaciones asset_template_fields y resource_template_fields incluyen la sección opcional de cada campo. Las instantáneas utilizan asset_specification_sections y resource_specification_sections, junto con sus tablas de campos. Las claves foráneas garantizan que cada campo se vincule a una sección de su propia plantilla o especificación. TemplateSection es una entidad interna con UniqueID y Name. Una plantilla o especificación puede no tener secciones.
|
||||
|
||||
Las especificaciones de activos y recursos capturan el orden, nombre, descripción e identidad de las secciones y la pertenencia de cada campo al crear el registro. Editar una plantilla posteriormente no modifica estas instantáneas, valores ni historial. Los registros anteriores sin secciones permanecen planos incluso al editarlos. No se realiza una migración silenciosa de sus especificaciones.
|
||||
Las especificaciones actuales de activos y recursos siguen los cambios guardados de sus plantillas. Las secciones retiradas se conservan en los registros que tengan campos con valor en ellas; las secciones nuevas y su orden se incorporan. Las revisiones históricas mantienen la estructura que tenían al registrarse. Véase [sincronización de plantillas](sincronizacion-de-plantillas.md).
|
||||
|
||||
La inicialización amplía una sola vez la presentación de las plantillas de serie existentes, conservando UUID de plantilla/campos/asociaciones, obligatoriedad, orden previo y estados globales y por compañía. La ampliación se guarda mediante la transacción del catálogo al arrancar. Repetir el arranque no cambia identidades ni configuración. Las plantillas personalizadas permanecen intactas. Los nuevos catálogos reciben la misma agrupación inicial; las plantillas pequeñas de recursos conservan una presentación plana.
|
||||
|
||||
|
||||
28
docs/sincronizacion-de-plantillas.md
Normal file
28
docs/sincronizacion-de-plantillas.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Sincronización de plantillas de activos y recursos
|
||||
|
||||
Guardar una plantilla actualiza la estructura de todos los activos o recursos que la utilizan. Una plantilla global afecta a todas sus compañías; una plantilla propia afecta a sus registros. No cambia la plantilla asignada ni se mezclan campos por nombre: se utiliza su UUID.
|
||||
|
||||
- Se incorporan secciones y campos nuevos y se aplica el orden de la plantilla.
|
||||
- Los campos retirados vacíos desaparecen de la ficha actual. Ausencia, null y texto vacío o solo espacios se consideran vacíos; cero y false son valores que se conservan.
|
||||
- Los campos retirados con valor permanecen, después de los campos actuales de la plantilla, y dejan de ser obligatorios. Sus secciones retiradas se conservan mientras contengan campos retenidos. Las secciones vigentes mantienen el orden de la plantilla y las conservadas se añaden al final.
|
||||
- Si se elimina una sección y la plantilla deja sus campos sin sección, las fichas conservan en la sección anterior los campos que tengan valor. Mover expresamente un campo a otra sección vigente sí aplica la nueva ubicación.
|
||||
- Los nombres de secciones conservadas se desambiguan cuando coinciden con una sección nueva. No se unen identidades distintas por tener el mismo nombre.
|
||||
- Los campos de integración de buzones y accesos se conservan aunque estén vacíos, para mantener válidas sus referencias funcionales.
|
||||
- Un campo obligatorio nuevo queda pendiente; la siguiente edición del registro exige completarlo. La sincronización no fabrica valores.
|
||||
- Los límites del editor de plantillas siguen vigentes; las especificaciones de fichas pueden superarlos por conservar datos de campos retirados.
|
||||
|
||||
Cada cambio efectivo crea una revisión con el usuario autenticado y fecha UTC. Repetir una modificación equivalente no añade revisiones. Las revisiones conservan valores y estructura originales, incluidos nombres, tipos, opciones y orden. La interfaz de historial utiliza esa estructura histórica.
|
||||
|
||||
La actualización del catálogo y de todas las fichas afectadas se ejecuta en una transacción, con bloqueo del catálogo y de las compañías afectadas. Se preparan las nuevas especificaciones antes de escribir. Un fallo de validación o persistencia impide guardar parcialmente el cambio. La disponibilidad de una plantilla no elimina datos ni impide sincronizar registros existentes.
|
||||
|
||||
## Persistencia y arranque
|
||||
|
||||
Las tablas de especificaciones actuales siguen siendo `asset_specification_*` y `resource_specification_*`. Cada revisión añade su estructura en cuatro tablas relacionales: `*_revision_specifications`, `*_revision_specification_sections`, `*_revision_specification_fields` y `*_revision_specification_choices`.
|
||||
|
||||
El arranque ejecuta migraciones explícitas e idempotentes antes de servir el inventario. Primero copian las especificaciones originales a las revisiones existentes; en activos, sustituyen la referencia de características históricas a la estructura actual por una referencia a los campos de su propia revisión. No se reescriben los valores históricos. Reiniciar no duplica instantáneas.
|
||||
|
||||
La inicialización de definiciones de serie no representa una edición de usuario y no propaga estructuras ni crea revisiones de negocio. Las ediciones desde HTTP pasan siempre el usuario de sesión al caso de uso.
|
||||
|
||||
## Verificación
|
||||
|
||||
Las pruebas de dominio cubren incorporación, orden, retirada con y sin valor, conservación de cero y false, campos obligatorios nuevos, limpieza posterior, identidades, límites e idempotencia. Las pruebas MariaDB comprueban ambos módulos, dos compañías, lectura histórica y rollback ante un fallo durante la segunda actualización. Utilizan tablas aisladas en `metis_desk_test`.
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
|
||||
@ -8,6 +8,7 @@ export interface IAssetCatalogRepository {
|
||||
get(scope: Maybe<UniqueID>): Promise<Result<AssetCatalog, AssetCatalogError>>;
|
||||
update(
|
||||
scope: Maybe<UniqueID>,
|
||||
action: (catalog: AssetCatalog) => Result<AssetCatalog, AssetCatalogError>
|
||||
action: (catalog: AssetCatalog) => Result<AssetCatalog, AssetCatalogError>,
|
||||
actorId?: UniqueID
|
||||
): Promise<Result<AssetCatalog, AssetCatalogError>>;
|
||||
}
|
||||
|
||||
@ -27,7 +27,17 @@ export class AssetCatalogUseCases {
|
||||
return parsed.isSuccess ? this.repository.get(parsed.data) : parsed;
|
||||
}
|
||||
|
||||
async save(scope: string, identity: string | undefined, input: AssetDefinitionSpec) {
|
||||
async save(
|
||||
scope: string,
|
||||
identity: string | undefined,
|
||||
input: AssetDefinitionSpec,
|
||||
actorId?: string
|
||||
) {
|
||||
const actor = actorId ? UniqueID.create(actorId) : undefined;
|
||||
|
||||
if (actor && !actor.isSuccess) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const parent = catalogScope(scope);
|
||||
const values = AssetDefinitionValues.create(input);
|
||||
const id = identity ? UniqueID.create(identity) : Result.ok(undefined);
|
||||
@ -42,8 +52,11 @@ export class AssetCatalogUseCases {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return this.repository.update(parent.data, (catalog) =>
|
||||
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data)
|
||||
return this.repository.update(
|
||||
parent.data,
|
||||
(catalog) =>
|
||||
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data),
|
||||
actor?.isSuccess ? actor.data : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -28,51 +28,14 @@ export function mapAssetInput(
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_ASSIGNED"));
|
||||
}
|
||||
let specification = previous?.specification;
|
||||
const captured = previous
|
||||
? Result.ok(previous.specification)
|
||||
: captureAssetSpecification(input, companyId, context);
|
||||
|
||||
if (!specification) {
|
||||
const template = context.catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
|
||||
|
||||
if (
|
||||
!template ||
|
||||
template.values.data.kind !== "template" ||
|
||||
!context.catalog.active(template, Maybe.some(companyId))
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
const fields = template.values.data.fields.map((binding) => {
|
||||
const field = context.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
|
||||
if (!field || field.values.data.kind !== "field") {
|
||||
throw new Error("Plantilla persistida inconsistente.");
|
||||
}
|
||||
|
||||
return {
|
||||
fieldId: field.id.toPrimitive(),
|
||||
name: field.values.data.name,
|
||||
type: field.values.data.type,
|
||||
unit: field.values.data.unit,
|
||||
choices: [...field.values.data.choices],
|
||||
required: binding.required,
|
||||
...(binding.sectionId ? { sectionId: binding.sectionId } : {}),
|
||||
};
|
||||
});
|
||||
const created = AssetSpecification.create({
|
||||
templateId: template.id.toPrimitive(),
|
||||
templateName: template.values.data.name,
|
||||
...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}),
|
||||
fields,
|
||||
});
|
||||
|
||||
if (!created.isSuccess) {
|
||||
return created;
|
||||
}
|
||||
specification = created.data;
|
||||
if (!captured.isSuccess) {
|
||||
return captured;
|
||||
}
|
||||
const specification = captured.data;
|
||||
const mapped = mapBrandReference(input, specification, context, previous);
|
||||
|
||||
if (!mapped.isSuccess) {
|
||||
@ -124,3 +87,48 @@ function mapBrandReference(
|
||||
|
||||
return Result.ok(normalized);
|
||||
}
|
||||
|
||||
export function captureAssetSpecification(
|
||||
input: Pick<AssetInput, "templateId">,
|
||||
companyId: UniqueID,
|
||||
context: Pick<InventoryContext, "catalog">,
|
||||
options: { existing?: boolean } = {}
|
||||
) {
|
||||
const template = context.catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
|
||||
|
||||
if (
|
||||
!template ||
|
||||
template.values.data.kind !== "template" ||
|
||||
!(options.existing || context.catalog.active(template, Maybe.some(companyId)))
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
const fields = template.values.data.fields.map((binding) => {
|
||||
const field = context.catalog.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
|
||||
if (!field || field.values.data.kind !== "field") {
|
||||
throw new Error("Plantilla persistida inconsistente.");
|
||||
}
|
||||
|
||||
return {
|
||||
fieldId: field.id.toPrimitive(),
|
||||
name: field.values.data.name,
|
||||
type: field.values.data.type,
|
||||
unit: field.values.data.unit,
|
||||
choices: [...field.values.data.choices],
|
||||
required: binding.required,
|
||||
...(binding.sectionId ? { sectionId: binding.sectionId } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return AssetSpecification.create({
|
||||
templateId: template.id.toPrimitive(),
|
||||
templateName: template.values.data.name,
|
||||
...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}),
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ export function buildAssetResponse(
|
||||
history: asset.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
...(revision.specification.equals(asset.specification)
|
||||
? {}
|
||||
: { specification: revision.specification.toPrimitive() }),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName: actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
|
||||
import type { AssetValues } from "./value-objects/asset-values.vo";
|
||||
import type { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
|
||||
|
||||
export type AssetRevisionProps = Readonly<{
|
||||
values: AssetValues;
|
||||
specification: AssetSpecification;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
@ -18,6 +19,10 @@ export class AssetRevision extends DomainEntity<AssetRevisionProps> {
|
||||
return new AssetRevision(props, id);
|
||||
}
|
||||
|
||||
get specification() {
|
||||
return this.props.specification;
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
|
||||
249
modules/assets/src/api/domain/asset-template-propagation.test.ts
Normal file
249
modules/assets/src/api/domain/asset-template-propagation.test.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { synchronizeTemplateStructure } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetPersistenceMapper } from "../infrastructure/persistence/asset-persistence.mapper";
|
||||
|
||||
import { Asset } from "./asset.aggregate";
|
||||
import type { AssetSpecificationData } from "./asset-values";
|
||||
import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
|
||||
|
||||
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
|
||||
|
||||
const identity = (n: number) => {
|
||||
const result = UniqueID.create(id(n));
|
||||
|
||||
assert.ok(result.isSuccess);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const field = (
|
||||
n: number,
|
||||
name: string,
|
||||
type: "text" | "boolean" | "number",
|
||||
sectionId = id(20),
|
||||
required = false
|
||||
) => ({ fieldId: id(n), name, type, unit: "", choices: [], required, sectionId });
|
||||
|
||||
const previous = {
|
||||
templateId: id(1),
|
||||
templateName: "Plantilla",
|
||||
sections: [{ id: id(20), name: "Original", description: "" }],
|
||||
fields: [
|
||||
field(2, "Texto", "text", id(20), true),
|
||||
field(3, "Cero", "number"),
|
||||
field(4, "No", "boolean"),
|
||||
field(5, "Vacío", "text"),
|
||||
],
|
||||
};
|
||||
const target = {
|
||||
templateId: id(1),
|
||||
templateName: "Plantilla revisada",
|
||||
sections: [{ id: id(21), name: "Nueva", description: "" }],
|
||||
fields: [
|
||||
field(6, "Nuevo obligatorio", "text", id(21), true),
|
||||
field(4, "No", "boolean", id(21)),
|
||||
field(3, "Cero", "number", id(21)),
|
||||
],
|
||||
};
|
||||
const input = {
|
||||
name: "Registro",
|
||||
templateId: id(1),
|
||||
siteId: null,
|
||||
status: "operational" as const,
|
||||
notes: "",
|
||||
characteristics: { [id(2)]: "Valor que se conserva", [id(3)]: 0, [id(4)]: false },
|
||||
};
|
||||
|
||||
test("Asset: propaga altas, orden y retiradas conservando cero/falso, valores e historial; repetir no genera revisiones", () => {
|
||||
const specification = AssetSpecification.create(previous);
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
const values = AssetValues.create(input, specification.data);
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const created = Asset.create(
|
||||
identity(10),
|
||||
specification.data,
|
||||
values.data,
|
||||
identity(11),
|
||||
"2026-10-18T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(created.isSuccess);
|
||||
const structure = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: target,
|
||||
values: input.characteristics,
|
||||
});
|
||||
const next = AssetSpecification.create({ ...target, ...structure });
|
||||
|
||||
assert.ok(next.isSuccess);
|
||||
const changed = created.data.synchronizeSpecification(
|
||||
next.data,
|
||||
identity(11),
|
||||
"2026-10-19T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(changed.isSuccess);
|
||||
const item = changed.data;
|
||||
|
||||
assert.deepEqual(
|
||||
item.specification.toPrimitive().fields.map((field) => field.fieldId),
|
||||
[id(6), id(4), id(3), id(2)]
|
||||
);
|
||||
assert.deepEqual(
|
||||
item.specification.toPrimitive().sections?.map((section) => section.id),
|
||||
[id(21), id(20)]
|
||||
);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(3)], 0);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(4)], false);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(6)], undefined);
|
||||
assert.equal(
|
||||
item.specification.toPrimitive().fields.find((field) => field.fieldId === id(2))?.required,
|
||||
false
|
||||
);
|
||||
assert.equal(item.history.length, 2);
|
||||
assert.deepEqual(item.history[0]?.specification.toPrimitive(), specification.data.toPrimitive());
|
||||
const repeated = item.synchronizeSpecification(
|
||||
next.data,
|
||||
identity(11),
|
||||
"2026-10-19T11:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(repeated.isSuccess);
|
||||
assert.equal(repeated.data, item);
|
||||
const mapper = new AssetPersistenceMapper();
|
||||
const stored = mapper.toPersistence(item);
|
||||
const restored = mapper.toDomain(stored.asset, stored.revisions);
|
||||
|
||||
assert.equal(restored.history.length, 2);
|
||||
assert.equal(restored.history[0]?.specification.toPrimitive().fields.length, 4);
|
||||
assert.equal(
|
||||
restored.history[0]?.values.toPrimitive().characteristics[id(2)],
|
||||
"Valor que se conserva"
|
||||
);
|
||||
assert.equal(AssetValues.create(item.values.toPrimitive(), item.specification).isSuccess, false);
|
||||
const completed = AssetValues.create(
|
||||
{
|
||||
...item.values.toPrimitive(),
|
||||
characteristics: {
|
||||
...item.values.toPrimitive().characteristics,
|
||||
[id(6)]: "Completado",
|
||||
[id(2)]: "",
|
||||
},
|
||||
},
|
||||
item.specification
|
||||
);
|
||||
|
||||
assert.ok(completed.isSuccess);
|
||||
const edited = item.update(completed.data, identity(11), "2026-10-19T12:00:00.000Z");
|
||||
|
||||
assert.ok(edited.isSuccess);
|
||||
const cleaned = synchronizeTemplateStructure({
|
||||
previous: edited.data.specification.toPrimitive(),
|
||||
next: target,
|
||||
values: edited.data.values.toPrimitive().characteristics,
|
||||
});
|
||||
const finalSpecification = AssetSpecification.create({ ...target, ...cleaned });
|
||||
|
||||
assert.ok(finalSpecification.isSuccess);
|
||||
const final = edited.data.synchronizeSpecification(
|
||||
finalSpecification.data,
|
||||
identity(11),
|
||||
"2026-10-19T13:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(final.isSuccess);
|
||||
assert.equal(
|
||||
final.data.specification.toPrimitive().fields.some((field) => field.fieldId === id(2)),
|
||||
false
|
||||
);
|
||||
assert.deepEqual(
|
||||
final.data.specification.toPrimitive().sections?.map((section) => section.id),
|
||||
[id(21)]
|
||||
);
|
||||
const finalRows = mapper.toPersistence(final.data);
|
||||
const roundTrip = mapper.toDomain(finalRows.asset, finalRows.revisions);
|
||||
|
||||
assert.equal(
|
||||
roundTrip.history[0]?.values.toPrimitive().characteristics[id(2)],
|
||||
"Valor que se conserva"
|
||||
);
|
||||
assert.equal(roundTrip.history[0]?.specification.toPrimitive().fields[0]?.name, "Texto");
|
||||
});
|
||||
|
||||
test("Asset: los campos retirados no impiden añadir una plantilla completa y no se fusionan por nombre", () => {
|
||||
const fields = Array.from({ length: 100 }, (_, n) => field(100 + n, "Campo", "text", id(21)));
|
||||
const next = { ...target, fields };
|
||||
const merged = synchronizeTemplateStructure({ previous, next, values: input.characteristics });
|
||||
const specification = AssetSpecification.create({ ...next, ...merged });
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
assert.equal(specification.data.toPrimitive().fields.length, 103);
|
||||
assert.equal(
|
||||
new Set(specification.data.toPrimitive().fields.map((field) => field.fieldId)).size,
|
||||
103
|
||||
);
|
||||
});
|
||||
|
||||
test("Asset: retirar secciones conserva solo las necesarias y mover o reincorporar campos aplica el destino", () => {
|
||||
const section = previous.sections[0];
|
||||
|
||||
assert.ok(section);
|
||||
const next: AssetSpecificationData = {
|
||||
...previous,
|
||||
sections: [],
|
||||
fields: previous.fields.map(({ sectionId: _sectionId, ...field }) => field),
|
||||
};
|
||||
const result = synchronizeTemplateStructure({ previous, next, values: input.characteristics });
|
||||
|
||||
assert.deepEqual(result.sections, previous.sections);
|
||||
assert.equal(result.fields.find((field) => field.fieldId === id(2))?.sectionId, section.id);
|
||||
assert.equal(result.fields.find((field) => field.fieldId === id(5))?.sectionId, undefined);
|
||||
const moved = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: target,
|
||||
values: input.characteristics,
|
||||
});
|
||||
|
||||
assert.equal(moved.fields.find((field) => field.fieldId === id(4))?.sectionId, id(21));
|
||||
const returned = synchronizeTemplateStructure({
|
||||
previous: { ...target, ...moved },
|
||||
next: previous,
|
||||
values: input.characteristics,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
returned.fields.map((field) => field.fieldId),
|
||||
previous.fields.map((field) => field.fieldId)
|
||||
);
|
||||
assert.equal(returned.fields.find((field) => field.fieldId === id(2))?.required, true);
|
||||
const empty = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: { ...next, fields: [] },
|
||||
values: { [id(2)]: " " },
|
||||
});
|
||||
|
||||
assert.equal(empty.fields.length, 0);
|
||||
assert.equal(empty.sections.length, 0);
|
||||
});
|
||||
|
||||
test("las secciones conservadas desambiguan el nombre respetando el límite de 100 caracteres", () => {
|
||||
const name = "A".repeat(100);
|
||||
const old = { ...previous, sections: [{ id: id(20), name, description: "" }] };
|
||||
const next = { ...target, sections: [{ id: id(21), name, description: "" }] };
|
||||
const merged = synchronizeTemplateStructure({
|
||||
previous: old,
|
||||
next,
|
||||
values: input.characteristics,
|
||||
});
|
||||
const specification = AssetSpecification.create({ ...next, ...merged });
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
assert.equal(merged.sections[1]?.name.length, 100);
|
||||
assert.equal(merged.sections[1]?.name.endsWith(" (2)"), true);
|
||||
});
|
||||
@ -37,8 +37,8 @@ export type AssetRecordedInput = z.infer<typeof AssetRecordedInputSchema>;
|
||||
export const AssetSpecificationSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
templateName: z.string().min(1).max(100),
|
||||
sections: z.array(TemplateSectionSchema).max(30).optional(),
|
||||
fields: z.array(AssetCharacteristicSchema).min(1).max(100),
|
||||
sections: z.array(TemplateSectionSchema).optional(),
|
||||
fields: z.array(AssetCharacteristicSchema).min(1),
|
||||
});
|
||||
|
||||
export type AssetInput = z.infer<typeof AssetInputSchema>;
|
||||
|
||||
@ -4,7 +4,8 @@ import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "./asset-definition";
|
||||
import { AssetRevision } from "./asset-revision.entity";
|
||||
import type { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
|
||||
import type { AssetSpecification } from "./value-objects/asset-values.vo";
|
||||
import { AssetValues } from "./value-objects/asset-values.vo";
|
||||
|
||||
type AssetProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
@ -30,7 +31,7 @@ export class Asset extends AggregateRoot<AssetProps> {
|
||||
companyId,
|
||||
specification,
|
||||
values,
|
||||
history: [AssetRevision.create({ values, actorId, recordedAt })],
|
||||
history: [AssetRevision.create({ values, specification, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -55,6 +56,47 @@ export class Asset extends AggregateRoot<AssetProps> {
|
||||
return this.props.history;
|
||||
}
|
||||
|
||||
synchronizeSpecification(
|
||||
specification: AssetSpecification,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
) {
|
||||
const allowed = new Set(specification.toPrimitive().fields.map((field) => field.fieldId));
|
||||
const values = AssetValues.reconstruct(
|
||||
{
|
||||
...this.values.toPrimitive(),
|
||||
characteristics: Object.fromEntries(
|
||||
Object.entries(this.values.toPrimitive().characteristics).filter(([id]) =>
|
||||
allowed.has(id)
|
||||
)
|
||||
),
|
||||
},
|
||||
specification
|
||||
);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
if (this.specification.equals(specification) && this.values.equals(values.data)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
new Asset(
|
||||
{
|
||||
...this.props,
|
||||
specification,
|
||||
values: values.data,
|
||||
history: [
|
||||
...this.history,
|
||||
AssetRevision.create({ specification, values: values.data, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
values: AssetValues,
|
||||
actorId: UniqueID,
|
||||
@ -71,7 +113,15 @@ export class Asset extends AggregateRoot<AssetProps> {
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [...this.history, AssetRevision.create({ values, actorId, recordedAt })],
|
||||
history: [
|
||||
...this.history,
|
||||
AssetRevision.create({
|
||||
values,
|
||||
specification: this.specification,
|
||||
actorId,
|
||||
recordedAt,
|
||||
}),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
|
||||
@ -139,9 +139,19 @@ export class AssetValues {
|
||||
return assetTechnicalStatus(this.data.status);
|
||||
}
|
||||
|
||||
static create(
|
||||
static create(input: AssetRecordedInput, specification: AssetSpecification) {
|
||||
return AssetValues.build(input, specification, { requiredValues: true });
|
||||
}
|
||||
|
||||
/** Rehidrata registros que pueden estar pendientes de completar campos añadidos a su plantilla. */
|
||||
static reconstruct(input: AssetRecordedInput, specification: AssetSpecification) {
|
||||
return AssetValues.build(input, specification, { requiredValues: false });
|
||||
}
|
||||
|
||||
private static build(
|
||||
input: AssetRecordedInput,
|
||||
specification: AssetSpecification
|
||||
specification: AssetSpecification,
|
||||
options: { requiredValues: boolean }
|
||||
): Result<AssetValues, AssetCatalogError> {
|
||||
const parsed = AssetRecordedInputSchema.safeParse(input);
|
||||
const name = Name.create(input.name, 100);
|
||||
@ -154,7 +164,11 @@ export class AssetValues {
|
||||
}
|
||||
const data = parsed.data;
|
||||
const site = optionalIdentity(data.siteId);
|
||||
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
|
||||
const parsedCharacteristics = normalizeCharacteristics(
|
||||
data.characteristics,
|
||||
specification,
|
||||
options
|
||||
);
|
||||
|
||||
if (!parsedCharacteristics.isSuccess) {
|
||||
return parsedCharacteristics;
|
||||
@ -205,7 +219,8 @@ export class AssetValues {
|
||||
|
||||
function normalizeCharacteristics(
|
||||
input: AssetInput["characteristics"],
|
||||
specification: AssetSpecification
|
||||
specification: AssetSpecification,
|
||||
options: { requiredValues: boolean }
|
||||
): Result<AssetInput["characteristics"], AssetCatalogError> {
|
||||
const characteristics: AssetInput["characteristics"] = {};
|
||||
|
||||
@ -223,6 +238,7 @@ function normalizeCharacteristics(
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.requiredValues &&
|
||||
specification
|
||||
.toPrimitive()
|
||||
.fields.some((field) => field.required && !(field.fieldId in characteristics))
|
||||
|
||||
@ -87,8 +87,11 @@ export function registerAssetCatalogRoutes(
|
||||
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
|
||||
const params = schema.safeParse(request.params);
|
||||
const body = SaveDefinitionRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
|
||||
if (!(params.success && body.success)) {
|
||||
if (!(params.success && body.success && actor.success)) {
|
||||
sendError(response, "INVALID_REQUEST");
|
||||
|
||||
return;
|
||||
@ -97,7 +100,7 @@ export function registerAssetCatalogRoutes(
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
|
||||
useCases
|
||||
.save(params.data.scope, identity, body.data.values)
|
||||
.save(params.data.scope, identity, body.data.values, actor.data.userId)
|
||||
.then((result) =>
|
||||
sendResult(response, result, params.data.scope, identity ? 200 : 201, {
|
||||
id: identity,
|
||||
|
||||
@ -6,6 +6,10 @@ import { AssetRecordedInputSchema, AssetSpecificationSchema } from "../../domain
|
||||
|
||||
import type { AssetPersistenceMapper } from "./asset-persistence.mapper";
|
||||
import { readAssetRows, sqlIdentifier, writeAssetRow } from "./asset-relational-sql";
|
||||
import {
|
||||
readAssetRevisionSpecifications,
|
||||
writeAssetRevisionSpecification,
|
||||
} from "./asset-revision-specifications";
|
||||
|
||||
export type InventoryRow = {
|
||||
id: string;
|
||||
@ -25,6 +29,7 @@ export type RevisionRow = {
|
||||
position: number;
|
||||
siteId: string | null;
|
||||
brandId: string | null;
|
||||
specification?: unknown;
|
||||
values: unknown;
|
||||
};
|
||||
|
||||
@ -38,7 +43,7 @@ type AssetRow = Omit<InventoryRow, "values" | "specification"> & {
|
||||
sectionsDefined: boolean;
|
||||
};
|
||||
|
||||
type RevisionColumns = Omit<RevisionRow, "values"> & {
|
||||
type RevisionColumns = Omit<RevisionRow, "values" | "specification"> & {
|
||||
name: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
@ -231,13 +236,23 @@ export async function readInventory(
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
const revisions: RevisionRow[] = histories.map((row) => ({
|
||||
...row,
|
||||
values: values(
|
||||
row,
|
||||
historical.filter((entry) => entry.revisionId === row.id)
|
||||
),
|
||||
}));
|
||||
const revisionSpecifications = await readAssetRevisionSpecifications(database, ids, transaction);
|
||||
const revisions: RevisionRow[] = histories.map((row) => {
|
||||
const specification = revisionSpecifications.get(row.id);
|
||||
|
||||
if (!specification) {
|
||||
throw new Error("Revisión sin especificación histórica persistida.");
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
specification,
|
||||
values: values(
|
||||
row,
|
||||
historical.filter((entry) => entry.revisionId === row.id)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { rows, revisions };
|
||||
}
|
||||
@ -277,7 +292,17 @@ export async function writeInventory(
|
||||
},
|
||||
transaction
|
||||
);
|
||||
// La especificación permanece congelada durante toda la vida del activo.
|
||||
for (const table of [
|
||||
"asset_characteristics",
|
||||
"asset_specification_field_choices",
|
||||
"asset_specification_fields",
|
||||
"asset_specification_sections",
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE assetId=:id`, {
|
||||
replacements: { id: asset.id },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const [position, section] of (specification.sections ?? []).entries()) {
|
||||
await writeAssetRow(
|
||||
database,
|
||||
@ -316,10 +341,7 @@ export async function writeInventory(
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
@ -342,7 +364,14 @@ export async function writeInventory(
|
||||
},
|
||||
transaction
|
||||
);
|
||||
for (const row of characteristicRows(asset.id, previous, specification, revision.id)) {
|
||||
await writeAssetRevisionSpecification(
|
||||
database,
|
||||
asset.id,
|
||||
revision.id,
|
||||
revision.specification,
|
||||
transaction
|
||||
);
|
||||
for (const row of characteristicRows(asset.id, previous, revision.specification, revision.id)) {
|
||||
await writeAssetRow(database, "asset_revision_characteristics", row, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export class AssetPersistenceMapper {
|
||||
if (!specification.isSuccess) {
|
||||
throw new Error("Especificación de activo persistida inconsistente.");
|
||||
}
|
||||
const values = AssetValues.create(
|
||||
const values = AssetValues.reconstruct(
|
||||
AssetRecordedInputSchema.parse(row.values),
|
||||
specification.data
|
||||
);
|
||||
@ -47,9 +47,16 @@ export class AssetPersistenceMapper {
|
||||
const history = [...revisions]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((revision, position) => {
|
||||
const previous = AssetValues.create(
|
||||
const revisionSpecification = AssetSpecification.create(
|
||||
AssetSpecificationSchema.parse(revision.specification ?? row.specification)
|
||||
);
|
||||
|
||||
if (!revisionSpecification.isSuccess) {
|
||||
throw new Error("Especificación histórica inconsistente.");
|
||||
}
|
||||
const previous = AssetValues.reconstruct(
|
||||
AssetRecordedInputSchema.parse(revision.values),
|
||||
specification.data
|
||||
revisionSpecification.data
|
||||
);
|
||||
|
||||
if (
|
||||
@ -69,6 +76,7 @@ export class AssetPersistenceMapper {
|
||||
return AssetRevision.rehydrate(
|
||||
{
|
||||
values: previous.data,
|
||||
specification: revisionSpecification.data,
|
||||
actorId: inventoryIdentity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
@ -112,6 +120,7 @@ export class AssetPersistenceMapper {
|
||||
brandId: revision.values.toPrimitive().brandId ?? null,
|
||||
recordedAt: revision.recordedAt,
|
||||
values: revision.values.toPrimitive(),
|
||||
specification: revision.specification.toPrimitive(),
|
||||
position,
|
||||
})),
|
||||
};
|
||||
|
||||
@ -56,11 +56,7 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
|
||||
"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",
|
||||
]) {
|
||||
for (const table of ["asset_specification_field_choices", "asset_characteristics"]) {
|
||||
await add(
|
||||
table,
|
||||
`${table}_snapshot`,
|
||||
|
||||
@ -0,0 +1,306 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AssetSpecificationData } from "../../domain/asset-values";
|
||||
import { AssetSpecificationSchema } from "../../domain/asset-values";
|
||||
|
||||
import { readAssetRows, writeAssetRow } from "./asset-relational-sql";
|
||||
|
||||
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
|
||||
const identity = z.object({ revisionId: z.uuid(), assetId: z.uuid() });
|
||||
const headerSchema = identity.extend({
|
||||
templateId: z.uuid(),
|
||||
templateName: z.string(),
|
||||
sectionsDefined: bool,
|
||||
});
|
||||
const fieldSchema = identity.extend({
|
||||
fieldId: z.uuid(),
|
||||
name: z.string(),
|
||||
fieldType: z.enum(["text", "multiline", "number", "date", "boolean", "choice"]),
|
||||
unit: z.string(),
|
||||
required: bool,
|
||||
sectionId: z.uuid().nullable(),
|
||||
position: z.number().int(),
|
||||
});
|
||||
const sectionSchema = identity.extend({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
position: z.number().int(),
|
||||
});
|
||||
const choiceSchema = identity.extend({
|
||||
fieldId: z.uuid(),
|
||||
position: z.number().int(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
/** Se ejecuta antes de que una estructura viva pueda cambiar; las revisiones anteriores reciben su instantánea original. */
|
||||
export async function migrateAssetRevisionSpecifications(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 revision = {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
references: { model: "asset_revisions", key: "id" },
|
||||
onDelete: "CASCADE",
|
||||
};
|
||||
const parent = {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "assets", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
};
|
||||
const definitions = [
|
||||
[
|
||||
"asset_revision_specifications",
|
||||
{
|
||||
revisionId: revision,
|
||||
assetId: parent,
|
||||
templateId: { type: DataTypes.UUID, allowNull: false },
|
||||
templateName: { type: DataTypes.STRING(100), allowNull: false },
|
||||
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_revision_specification_sections",
|
||||
{
|
||||
revisionId: revision,
|
||||
assetId: parent,
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_revision_specification_fields",
|
||||
{
|
||||
revisionId: revision,
|
||||
assetId: parent,
|
||||
fieldId: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
references: { model: "asset_definitions", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
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: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_revision_specification_choices",
|
||||
{
|
||||
revisionId: revision,
|
||||
assetId: parent,
|
||||
fieldId: { type: DataTypes.UUID, primaryKey: true },
|
||||
position: { type: DataTypes.INTEGER, primaryKey: true },
|
||||
value: { type: DataTypes.STRING(100), allowNull: false },
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
for (const [table, columns] of definitions) {
|
||||
if (!tables.some((item) => item.name === table)) {
|
||||
await query.createTable(table, columns);
|
||||
}
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
const missing = await database.query<{ id: string }>(
|
||||
"SELECT r.id FROM asset_revisions r LEFT JOIN asset_revision_specifications s ON s.revisionId=r.id WHERE s.revisionId IS NULL",
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < missing.length; offset += 500) {
|
||||
const ids = missing.slice(offset, offset + 500).map((item) => item.id);
|
||||
const options = { replacements: { ids }, transaction };
|
||||
|
||||
await database.query(
|
||||
"INSERT INTO asset_revision_specifications (revisionId,assetId,templateId,templateName,sectionsDefined) SELECT r.id,a.id,a.templateId,a.templateName,a.sectionsDefined FROM asset_revisions r JOIN assets a ON a.id=r.assetId WHERE r.id IN (:ids)",
|
||||
options
|
||||
);
|
||||
for (const [suffix, columns] of [
|
||||
["sections", "id,name,description,position"],
|
||||
["fields", "fieldId,name,fieldType,unit,required,sectionId,position"],
|
||||
["choices", "fieldId,position,value"],
|
||||
] as const) {
|
||||
const source =
|
||||
suffix === "choices"
|
||||
? "asset_specification_field_choices"
|
||||
: `asset_specification_${suffix}`;
|
||||
const selected = columns
|
||||
.split(",")
|
||||
.map((column) => `s.\`${column}\``)
|
||||
.join(",");
|
||||
|
||||
await database.query(
|
||||
`INSERT INTO asset_revision_specification_${suffix} (revisionId,assetId,${columns
|
||||
.split(",")
|
||||
.map((column) => `\`${column}\``)
|
||||
.join(
|
||||
","
|
||||
)}) SELECT r.id,r.assetId,${selected} FROM asset_revisions r JOIN ${source} s ON s.assetId=r.assetId WHERE r.id IN (:ids)`,
|
||||
options
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_revision_characteristics'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (constraints.some((item) => item.name === "asset_revision_characteristics_snapshot")) {
|
||||
await query.removeConstraint(
|
||||
"asset_revision_characteristics",
|
||||
"asset_revision_characteristics_snapshot"
|
||||
);
|
||||
}
|
||||
if (!constraints.some((item) => item.name === "asset_historical_field_snapshot")) {
|
||||
await database.query(
|
||||
"ALTER TABLE asset_revision_characteristics ADD CONSTRAINT asset_historical_field_snapshot FOREIGN KEY (revisionId,fieldId) REFERENCES asset_revision_specification_fields(revisionId,fieldId)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAssetRevisionSpecifications(
|
||||
database: Sequelize,
|
||||
ids: readonly string[],
|
||||
transaction: Transaction
|
||||
) {
|
||||
const headers = z
|
||||
.array(headerSchema)
|
||||
.parse(
|
||||
await readAssetRows(database, "asset_revision_specifications", "assetId", ids, transaction)
|
||||
);
|
||||
const fields = z
|
||||
.array(fieldSchema)
|
||||
.parse(
|
||||
await readAssetRows(
|
||||
database,
|
||||
"asset_revision_specification_fields",
|
||||
"assetId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
const sections = z
|
||||
.array(sectionSchema)
|
||||
.parse(
|
||||
await readAssetRows(
|
||||
database,
|
||||
"asset_revision_specification_sections",
|
||||
"assetId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
const choices = z
|
||||
.array(choiceSchema)
|
||||
.parse(
|
||||
await readAssetRows(
|
||||
database,
|
||||
"asset_revision_specification_choices",
|
||||
"assetId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
return new Map(
|
||||
headers.map((header) => [
|
||||
header.revisionId,
|
||||
AssetSpecificationSchema.parse({
|
||||
templateId: header.templateId,
|
||||
templateName: header.templateName,
|
||||
...(header.sectionsDefined
|
||||
? {
|
||||
sections: sections
|
||||
.filter((item) => item.revisionId === header.revisionId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ id, name, description }) => ({ id, name, description })),
|
||||
}
|
||||
: {}),
|
||||
fields: fields
|
||||
.filter((item) => item.revisionId === header.revisionId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((field) => ({
|
||||
fieldId: field.fieldId,
|
||||
name: field.name,
|
||||
type: field.fieldType,
|
||||
unit: field.unit,
|
||||
required: field.required,
|
||||
...(field.sectionId ? { sectionId: field.sectionId } : {}),
|
||||
choices: choices
|
||||
.filter(
|
||||
(item) => item.revisionId === header.revisionId && item.fieldId === field.fieldId
|
||||
)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => item.value),
|
||||
})),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeAssetRevisionSpecification(
|
||||
database: Sequelize,
|
||||
assetId: string,
|
||||
revisionId: string,
|
||||
specification: AssetSpecificationData,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const identity = { assetId, revisionId };
|
||||
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_revision_specifications",
|
||||
{
|
||||
...identity,
|
||||
templateId: specification.templateId,
|
||||
templateName: specification.templateName,
|
||||
sectionsDefined: specification.sections !== undefined,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
for (const [position, section] of (specification.sections ?? []).entries()) {
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_revision_specification_sections",
|
||||
{ ...identity, ...section, position },
|
||||
transaction
|
||||
);
|
||||
}
|
||||
for (const [position, field] of specification.fields.entries()) {
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_revision_specification_fields",
|
||||
{
|
||||
...identity,
|
||||
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_revision_specification_choices",
|
||||
{ ...identity, fieldId: field.fieldId, position: choicePosition, value },
|
||||
transaction
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result, synchronizeTemplateStructure } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import { captureAssetSpecification } from "../../application/asset-input.mapper";
|
||||
import type { Asset } from "../../domain/asset.aggregate";
|
||||
import type { AssetCatalog } from "../../domain/asset-catalog.aggregate";
|
||||
import { AssetSpecification } from "../../domain/value-objects/asset-values.vo";
|
||||
|
||||
import { readInventory } from "./asset-inventory-relational";
|
||||
import { AssetPersistenceMapper } from "./asset-persistence.mapper";
|
||||
|
||||
/** Prepara todas las compañías antes de escribir: un fallo de dominio no deja el catálogo a medio propagar. */
|
||||
export async function prepareAssetTemplateUpdates(
|
||||
database: Sequelize,
|
||||
previous: AssetCatalog,
|
||||
next: AssetCatalog,
|
||||
actorId: UniqueID | undefined,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const updates: { previous: Asset; next: Asset }[] = [];
|
||||
|
||||
if (!actorId) {
|
||||
return Result.ok(updates);
|
||||
}
|
||||
const templateIds = next.definitions
|
||||
.filter((definition) => {
|
||||
if (definition.values.data.kind !== "template") {
|
||||
return false;
|
||||
}
|
||||
const old = previous.definitions.find((item) => item.id.equals(definition.id));
|
||||
|
||||
return (
|
||||
old &&
|
||||
(!old.values.equals(definition.values) ||
|
||||
definition.values.data.fields.some((binding) => {
|
||||
const field = next.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
const oldField = previous.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
|
||||
return field && oldField && !field.values.equals(oldField.values);
|
||||
}))
|
||||
);
|
||||
})
|
||||
.map((item) => item.id.toPrimitive());
|
||||
|
||||
if (!templateIds.length) {
|
||||
return Result.ok(updates);
|
||||
}
|
||||
const companies = await database.query<{ companyId: string }>(
|
||||
"SELECT DISTINCT companyId FROM assets WHERE templateId IN (:templateIds) ORDER BY companyId",
|
||||
{ replacements: { templateIds }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const mapper = new AssetPersistenceMapper();
|
||||
|
||||
for (const company of companies) {
|
||||
await database.query("SELECT id FROM companies WHERE id=:id FOR UPDATE", {
|
||||
replacements: { id: company.companyId },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
const loaded = await readInventory(database, company.companyId, transaction);
|
||||
|
||||
for (const row of loaded.rows.filter((item) => templateIds.includes(item.templateId))) {
|
||||
const item = mapper.toDomain(
|
||||
row,
|
||||
loaded.revisions.filter((revision) => revision.assetId === row.id)
|
||||
);
|
||||
const changed = synchronizeAssetTemplate(item, next, actorId, now);
|
||||
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
if (changed.data !== item) {
|
||||
updates.push({ previous: item, next: changed.data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(updates);
|
||||
}
|
||||
|
||||
function synchronizeAssetTemplate(item: Asset, next: AssetCatalog, actorId: UniqueID, now: string) {
|
||||
const captured = captureAssetSpecification(
|
||||
{ templateId: item.values.toPrimitive().templateId },
|
||||
item.companyId,
|
||||
{ catalog: next },
|
||||
{ existing: true }
|
||||
);
|
||||
|
||||
if (!captured.isSuccess) {
|
||||
return Result.fail(captured.error);
|
||||
}
|
||||
const structure = synchronizeTemplateStructure({
|
||||
previous: item.specification.toPrimitive(),
|
||||
next: captured.data.toPrimitive(),
|
||||
values: item.values.toPrimitive().characteristics,
|
||||
});
|
||||
const specification = AssetSpecification.create({
|
||||
...captured.data.toPrimitive(),
|
||||
...structure,
|
||||
});
|
||||
|
||||
if (!specification.isSuccess) {
|
||||
return Result.fail(specification.error);
|
||||
}
|
||||
|
||||
return item.synchronizeSpecification(specification.data, actorId, now);
|
||||
}
|
||||
@ -11,7 +11,10 @@ import { AssetCatalogError } from "../../domain/asset-definition";
|
||||
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 { writeInventory } from "./asset-inventory-relational";
|
||||
import { AssetPersistenceMapper } from "./asset-persistence.mapper";
|
||||
import { writeAssetRow } from "./asset-relational-sql";
|
||||
import { prepareAssetTemplateUpdates } from "./prepare-asset-template-updates";
|
||||
|
||||
export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository {
|
||||
constructor(
|
||||
@ -63,7 +66,7 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
|
||||
private async definitionsInUse(ids: readonly string[], transaction: Transaction) {
|
||||
for (const id of ids) {
|
||||
const references = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM assets WHERE templateId=:id UNION ALL SELECT id FROM asset_revisions WHERE templateId=:id UNION ALL SELECT id FROM asset_relationships WHERE typeId=:id UNION ALL SELECT assetId AS id FROM asset_specification_fields WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_characteristics WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_revision_characteristics WHERE fieldId=:id LIMIT 1",
|
||||
"SELECT id FROM assets WHERE templateId=:id UNION ALL SELECT id FROM asset_revisions WHERE templateId=:id UNION ALL SELECT id FROM asset_relationships WHERE typeId=:id UNION ALL SELECT assetId AS id FROM asset_specification_fields WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_characteristics WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_revision_characteristics WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_revision_specification_fields WHERE fieldId=:id LIMIT 1",
|
||||
{ replacements: { id }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
@ -100,7 +103,60 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
|
||||
}
|
||||
}
|
||||
|
||||
update(scope: Maybe<UniqueID>, action: Parameters<IAssetCatalogRepository["update"]>[1]) {
|
||||
private async persistCatalog(
|
||||
previous: AssetCatalog,
|
||||
next: AssetCatalog,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const rows = this.mapper.toPersistence(next);
|
||||
const removedDefinitions = previous.definitions.filter(
|
||||
(item) => !next.definitions.some((candidate) => candidate.id.equals(item.id))
|
||||
);
|
||||
|
||||
const removedIds = removedDefinitions.map((item) => item.id.toPrimitive());
|
||||
|
||||
if (await this.definitionsInUse(removedIds, transaction)) {
|
||||
return Result.fail(new AssetCatalogError("DEFINITION_IN_USE"));
|
||||
}
|
||||
|
||||
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
|
||||
for (const row of rows.definitions) {
|
||||
await writeDefinition(this.database, row, transaction);
|
||||
}
|
||||
const previousBindings = previous.definitions.flatMap((item) => item.bindings);
|
||||
const retained = new Set(rows.bindings.map((row) => row.id));
|
||||
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
|
||||
|
||||
if (removed.length) {
|
||||
await BindingModel.destroy({
|
||||
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const row of rows.bindings) {
|
||||
await writeAssetRow(
|
||||
this.database,
|
||||
"asset_template_fields",
|
||||
{
|
||||
...row,
|
||||
sectionId: sectionForBinding(next, row.templateId, row.fieldId),
|
||||
},
|
||||
transaction
|
||||
);
|
||||
}
|
||||
for (const row of rows.availability) {
|
||||
await AvailabilityModel.upsert(row, { transaction });
|
||||
}
|
||||
await this.removeDefinitions(removedIds, transaction);
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
update(
|
||||
scope: Maybe<UniqueID>,
|
||||
action: Parameters<IAssetCatalogRepository["update"]>[1],
|
||||
actorId?: UniqueID
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(scope, transaction, true);
|
||||
|
||||
@ -112,46 +168,33 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
|
||||
if (!changed.isSuccess || changed.data === loaded.data) {
|
||||
return changed;
|
||||
}
|
||||
const rows = this.mapper.toPersistence(changed.data);
|
||||
const removedDefinitions = loaded.data.definitions.filter(
|
||||
(item) => !changed.data.definitions.some((candidate) => candidate.id.equals(item.id))
|
||||
const propagated = await prepareAssetTemplateUpdates(
|
||||
this.database,
|
||||
loaded.data,
|
||||
changed.data,
|
||||
actorId,
|
||||
transaction
|
||||
);
|
||||
|
||||
const removedIds = removedDefinitions.map((item) => item.id.toPrimitive());
|
||||
if (!propagated.isSuccess) {
|
||||
return Result.fail(propagated.error);
|
||||
}
|
||||
const persisted = await this.persistCatalog(loaded.data, changed.data, transaction);
|
||||
|
||||
if (await this.definitionsInUse(removedIds, transaction)) {
|
||||
return Result.fail(new AssetCatalogError("DEFINITION_IN_USE"));
|
||||
if (!persisted.isSuccess) {
|
||||
return Result.fail(persisted.error);
|
||||
}
|
||||
|
||||
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
|
||||
for (const row of rows.definitions) {
|
||||
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));
|
||||
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
|
||||
const inventoryMapper = new AssetPersistenceMapper();
|
||||
|
||||
if (removed.length) {
|
||||
await BindingModel.destroy({
|
||||
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const row of rows.bindings) {
|
||||
await writeAssetRow(
|
||||
for (const update of propagated.data) {
|
||||
await writeInventory(
|
||||
this.database,
|
||||
"asset_template_fields",
|
||||
{
|
||||
...row,
|
||||
sectionId: sectionForBinding(changed.data, row.templateId, row.fieldId),
|
||||
},
|
||||
inventoryMapper.toPersistence(update.next),
|
||||
new Set(update.previous.history.map((revision) => revision.id.toPrimitive())),
|
||||
transaction
|
||||
);
|
||||
}
|
||||
for (const row of rows.availability) {
|
||||
await AvailabilityModel.upsert(row, { transaction });
|
||||
}
|
||||
await this.removeDefinitions(removedIds, transaction);
|
||||
|
||||
return changed;
|
||||
});
|
||||
|
||||
@ -248,6 +248,10 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
"asset_infrastructure",
|
||||
"asset_characteristics",
|
||||
"asset_revision_characteristics",
|
||||
"asset_revision_specification_choices",
|
||||
"asset_revision_specification_fields",
|
||||
"asset_revision_specification_sections",
|
||||
"asset_revision_specifications",
|
||||
"asset_revisions",
|
||||
"asset_specification_field_choices",
|
||||
"asset_specification_fields",
|
||||
@ -275,6 +279,10 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
await removeAssetInfrastructure(this.database, companyId, assetId, transaction);
|
||||
for (const table of [
|
||||
"asset_revision_characteristics",
|
||||
"asset_revision_specification_choices",
|
||||
"asset_revision_specification_fields",
|
||||
"asset_revision_specification_sections",
|
||||
"asset_revision_specifications",
|
||||
"asset_characteristics",
|
||||
"asset_specification_field_choices",
|
||||
"asset_specification_fields",
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
} from "./infrastructure/persistence/asset-relational-constraints";
|
||||
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { migrateAssetRevisionSpecifications } from "./infrastructure/persistence/asset-revision-specifications";
|
||||
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
|
||||
import { migrateInfrastructure } from "./infrastructure/persistence/infrastructure-migration";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
@ -67,6 +68,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
await migrateRelationships(database);
|
||||
await migrateWorkstations(database);
|
||||
await initializeAssetInventorySchema(database);
|
||||
await migrateAssetRevisionSpecifications(database);
|
||||
await enforceAssetRelationalConstraints(database);
|
||||
await migrateSupport(database);
|
||||
await migrateInfrastructure(database);
|
||||
|
||||
@ -24,6 +24,7 @@ export const SaveAssetRequestSchema = z.strictObject({ values: AssetInputSchema
|
||||
export const AssetRevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: AssetRecordedInputSchema,
|
||||
specification: AssetSpecificationSchema.optional(),
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
"characteristics": "Characteristics",
|
||||
"history": "History",
|
||||
"recordedBy": "Recorded by {{name}}",
|
||||
"snapshotNotice": "The template is fixed when the asset is created. Its characteristics remain unchanged when the catalog changes.",
|
||||
"snapshotNotice": "Template changes update this record. Removed fields with values are retained, and history keeps its original structure.",
|
||||
"newSite": "New site",
|
||||
"newPerson": "New person",
|
||||
"assetDescription": "An asset is a specific IT item belonging to this company. Its template defines the characteristics you can record.",
|
||||
@ -515,5 +515,30 @@
|
||||
"examplesLabel": "Example",
|
||||
"examples": "Printer with Ethernet 192.168.5.201 and Wi-Fi 192.168.5.202.",
|
||||
"guidance": "Record device connections in Relationships. Port details remain in UniFi. This does not change characteristics or workstation assignments."
|
||||
},
|
||||
"assetDetail": {
|
||||
"information": "Information",
|
||||
"sections": "Asset sections",
|
||||
"back": "Back to assets",
|
||||
"created": "Asset created",
|
||||
"updated": "Asset updated",
|
||||
"relationshipCreated": "Relationship created",
|
||||
"relationshipEnded": "Relationship ended",
|
||||
"historyHint": "Asset, interface and connection changes, from newest to oldest.",
|
||||
"noHistory": "This asset has no history yet."
|
||||
},
|
||||
"assetColumns": {
|
||||
"technicalStatus": "Technical status",
|
||||
"assigned": "Assigned to a workstation",
|
||||
"createdAt": "Created on",
|
||||
"createdBy": "Created by",
|
||||
"updatedAt": "Last modified",
|
||||
"updatedBy": "Modified by",
|
||||
"roles": "Infrastructure roles",
|
||||
"interfaces": "Interfaces",
|
||||
"ipAddresses": "Interface IP addresses",
|
||||
"macAddresses": "Interface MAC addresses",
|
||||
"interfaceNotes": "Interface notes",
|
||||
"infrastructureError": "Infrastructure data for the optional columns could not be loaded."
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
"characteristics": "Características",
|
||||
"history": "Historial",
|
||||
"recordedBy": "Registrado por {{name}}",
|
||||
"snapshotNotice": "La plantilla queda fijada al crear el activo. Sus características se conservan aunque después cambie el catálogo.",
|
||||
"snapshotNotice": "Los cambios de la plantilla actualizan esta ficha. Los campos eliminados que tienen valor se conservan y el historial mantiene su estructura original.",
|
||||
"newSite": "Nueva sede",
|
||||
"newPerson": "Nueva persona",
|
||||
"assetDescription": "Un activo es un elemento informático concreto de esta compañía. La plantilla indica qué características puedes registrar.",
|
||||
@ -515,5 +515,30 @@
|
||||
"examplesLabel": "Ejemplo",
|
||||
"examples": "Impresora con Ethernet 192.168.5.201 y Wi-Fi 192.168.5.202.",
|
||||
"guidance": "Las conexiones entre equipos se registran en Relaciones. El detalle de puertos permanece en UniFi. No se modifican características ni asignaciones del puesto."
|
||||
},
|
||||
"assetDetail": {
|
||||
"information": "Información",
|
||||
"sections": "Secciones del activo",
|
||||
"back": "Volver a activos",
|
||||
"created": "Activo creado",
|
||||
"updated": "Activo actualizado",
|
||||
"relationshipCreated": "Relación creada",
|
||||
"relationshipEnded": "Relación finalizada",
|
||||
"historyHint": "Cambios del activo, interfaces y conexiones, de más reciente a más antiguo.",
|
||||
"noHistory": "Este activo todavía no tiene historial."
|
||||
},
|
||||
"assetColumns": {
|
||||
"technicalStatus": "Estado técnico",
|
||||
"assigned": "Asignado a un puesto",
|
||||
"createdAt": "Creado el",
|
||||
"createdBy": "Creado por",
|
||||
"updatedAt": "Última modificación",
|
||||
"updatedBy": "Modificado por",
|
||||
"roles": "Funciones de infraestructura",
|
||||
"interfaces": "Interfaces",
|
||||
"ipAddresses": "Direcciones IP de interfaces",
|
||||
"macAddresses": "Direcciones MAC de interfaces",
|
||||
"interfaceNotes": "Notas de interfaces",
|
||||
"infrastructureError": "No se pudieron cargar los datos de infraestructura de las columnas opcionales."
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,5 +13,5 @@ export function useAssetDetailController({ asset }: AssetDetailProps) {
|
||||
(item) => item.values.active && item.values.assetIds.includes(asset.id)
|
||||
);
|
||||
|
||||
return { workstations };
|
||||
return { workstations, inventory };
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import { navigateTo, useListQuery } from "@metis/core/client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import { getAssetInventory } from "../../list/api";
|
||||
import { removeAsset } from "../../shared/api";
|
||||
|
||||
export function useAssetDetailPageController(companyId: string, assetId: string, backHref: string) {
|
||||
const client = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const inventory = useListQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => getAssetInventory(companyId),
|
||||
});
|
||||
const catalog = useListQuery({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => removeAsset(companyId, assetId),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
navigateTo(backHref, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
catalog,
|
||||
editing,
|
||||
setEditing,
|
||||
deleting,
|
||||
setDeleting,
|
||||
remove,
|
||||
asset: inventory.data?.assets.find((item) => item.id === assetId),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
||||
import { ClockIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO, RelationshipDTO } from "../../../../common";
|
||||
import { AssetRelationships } from "../../../asset-relationships";
|
||||
import { AssetSupport } from "../../../support-connections";
|
||||
import type { AssetDetailProps } from "../types";
|
||||
|
||||
import { AssetDetail } from "./asset-detail";
|
||||
import { AssetHistory } from "./asset-history";
|
||||
|
||||
export function AssetDetailContent({
|
||||
asset,
|
||||
lookups,
|
||||
assets,
|
||||
relationships,
|
||||
children,
|
||||
}: AssetDetailProps & {
|
||||
assets: readonly AssetDTO[];
|
||||
relationships: readonly RelationshipDTO[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const [tab, setTab] = useState("information");
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className="min-w-0 gap-5"
|
||||
onValueChange={(value) => {
|
||||
if (typeof value === "string") {
|
||||
setTab(value);
|
||||
}
|
||||
}}
|
||||
value={tab}
|
||||
>
|
||||
<ScrollableTabsList
|
||||
items={[
|
||||
{ value: "information", label: t("assetDetail.information") },
|
||||
{ value: "history", label: t("history") },
|
||||
]}
|
||||
label={t("assetDetail.sections")}
|
||||
locale={i18n.language}
|
||||
value={tab}
|
||||
/>
|
||||
<TabsContent className="min-w-0 space-y-5" keepMounted value="information">
|
||||
<AssetDetail asset={asset} lookups={lookups}>
|
||||
<AssetRelationships
|
||||
assetId={asset.id}
|
||||
companyId={asset.companyId}
|
||||
sites={lookups.sites}
|
||||
/>
|
||||
<AssetSupport
|
||||
assetId={asset.id}
|
||||
companyId={asset.companyId}
|
||||
retired={asset.values.status === "retired"}
|
||||
/>
|
||||
</AssetDetail>
|
||||
{children}
|
||||
</TabsContent>
|
||||
<TabsContent className="min-w-0" value="history">
|
||||
<DetailPanel
|
||||
description={t("assetDetail.historyHint")}
|
||||
icon={<ClockIcon />}
|
||||
title={t("history")}
|
||||
>
|
||||
<AssetHistory
|
||||
asset={asset}
|
||||
assetName={(id) => assets.find((item) => item.id === id)?.values.name ?? "—"}
|
||||
lookups={lookups}
|
||||
relationships={relationships}
|
||||
/>
|
||||
</DetailPanel>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@ -1,28 +1,27 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AssetRelationships } from "../../../asset-relationships";
|
||||
import { AssetSupport } from "../../../support-connections";
|
||||
import { useAssetDetailController } from "../controllers";
|
||||
import type { AssetDetailProps } from "../types";
|
||||
|
||||
import { AssetDetail } from "./asset-detail";
|
||||
import { AssetDetailContent } from "./asset-detail-content";
|
||||
|
||||
export function AssetDetailDialog({
|
||||
asset,
|
||||
lookups,
|
||||
companyId,
|
||||
onClose,
|
||||
}: {
|
||||
asset: ComponentProps<typeof AssetDetail>["asset"];
|
||||
lookups: ComponentProps<typeof AssetDetail>["lookups"];
|
||||
companyId: string;
|
||||
asset: AssetDetailProps["asset"];
|
||||
lookups: AssetDetailProps["lookups"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const { inventory } = useAssetDetailController({ asset, lookups });
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
layout="stable"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
@ -32,14 +31,12 @@ export function AssetDetailDialog({
|
||||
title={asset.values.name}
|
||||
wide
|
||||
>
|
||||
<AssetDetail asset={asset} lookups={lookups}>
|
||||
<AssetRelationships assetId={asset.id} companyId={companyId} sites={lookups.sites} />
|
||||
<AssetSupport
|
||||
assetId={asset.id}
|
||||
companyId={companyId}
|
||||
retired={asset.values.status === "retired"}
|
||||
/>
|
||||
</AssetDetail>
|
||||
<AssetDetailContent
|
||||
asset={asset}
|
||||
assets={inventory.data?.assets ?? []}
|
||||
lookups={lookups}
|
||||
relationships={inventory.data?.relationships ?? []}
|
||||
/>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
124
modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx
Normal file
124
modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { BrowserLink, EntityNotFound, useBrowserSearchParams } from "@metis/core/client";
|
||||
import { EntityAction, EntityRowActions } from "@repo/rdx-ui/components/entity-list";
|
||||
import { ChevronLeftIcon, PencilIcon, Trash2Icon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO } from "../../../../common";
|
||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
||||
import type { InventoryLookups } from "../../shared/types";
|
||||
import { useAssetDetailPageController } from "../controllers/use-asset-detail-page-controller";
|
||||
|
||||
import { AssetDetailContent } from "./asset-detail-content";
|
||||
|
||||
export function AssetDetailPage({
|
||||
companyId,
|
||||
assetId,
|
||||
lookups,
|
||||
relatedContent,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
lookups: InventoryLookups;
|
||||
relatedContent?: (asset: AssetDTO) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const { searchParams } = useBrowserSearchParams();
|
||||
const search = searchParams.toString();
|
||||
const backHref = `/companies/${companyId}/assets${search ? `?${search}` : ""}`;
|
||||
const c = useAssetDetailPageController(companyId, assetId, backHref);
|
||||
const { asset } = c;
|
||||
const template = c.catalog.data?.items.find((item) => item.id === asset?.values.templateId);
|
||||
|
||||
if (c.inventory.isSuccess && !asset) {
|
||||
return <EntityNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container min-w-0 space-y-5">
|
||||
<BrowserLink className="inline-flex items-center gap-1 text-sm" href={backHref}>
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||
{t("assetDetail.back")}
|
||||
</BrowserLink>
|
||||
{c.inventory.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={c.inventory.error ?? c.catalog.error} />
|
||||
{(c.inventory.isError || c.catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([c.inventory.refetch(), c.catalog.refetch()]).catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{asset && (
|
||||
<>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-border/70 bg-card p-5 sm:p-6">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<AssetTemplateIcon
|
||||
className="size-6"
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h1 className="break-words text-2xl font-semibold">{asset.values.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{asset.specification.templateName} · {t(asset.displayStatus)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EntityRowActions
|
||||
label={t("actions")}
|
||||
primaryAction={{
|
||||
label: t("editAsset"),
|
||||
icon: <PencilIcon aria-hidden="true" />,
|
||||
onClick: () => c.setEditing(true),
|
||||
disabled: !c.catalog.isSuccess,
|
||||
}}
|
||||
>
|
||||
<EntityAction
|
||||
className="text-destructive data-highlighted:text-destructive"
|
||||
onClick={() => c.setDeleting(true)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
{t("deleteAsset")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
</header>
|
||||
<AssetDetailContent
|
||||
asset={asset}
|
||||
assets={c.inventory.data?.assets ?? []}
|
||||
lookups={lookups}
|
||||
relationships={c.inventory.data?.relationships ?? []}
|
||||
>
|
||||
{relatedContent?.(asset)}
|
||||
</AssetDetailContent>
|
||||
{c.editing && c.catalog.data && (
|
||||
<AssetEditorDialog
|
||||
asset={asset}
|
||||
companyId={companyId}
|
||||
items={c.catalog.data.items}
|
||||
lookups={lookups}
|
||||
onClose={() => c.setEditing(false)}
|
||||
onSaved={() => c.setEditing(false)}
|
||||
/>
|
||||
)}
|
||||
<DeleteAssetDialog
|
||||
asset={asset}
|
||||
errorCode={c.remove.error instanceof Error ? c.remove.error.message : undefined}
|
||||
onClose={() => c.setDeleting(false)}
|
||||
onConfirm={async () => {
|
||||
await c.remove.mutateAsync();
|
||||
}}
|
||||
open={c.deleting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx
Normal file
232
modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { InfrastructureSchema } from "../../../../common/infrastructure";
|
||||
import { AssetSchema } from "../../../../common/inventory";
|
||||
import { RelationshipSchema } from "../../../../common/relationships";
|
||||
import { catalogKeys } from "../../../catalog/shared/api";
|
||||
import { infrastructureKeys } from "../../../infrastructure/shared/api/infrastructure-api";
|
||||
import { assetsEnglish, assetsSpanish } from "../../../shared";
|
||||
import { inventoryKeys, supportKeys } from "../../../shared/queries";
|
||||
|
||||
import { AssetDetailPage } from "./asset-detail-page";
|
||||
import { AssetHistory } from "./asset-history";
|
||||
|
||||
const id = (value: number) => `0199b200-0000-7000-8000-${String(value).padStart(12, "0")}`;
|
||||
|
||||
const companyId = id(1);
|
||||
const values = {
|
||||
name: "Impresora principal",
|
||||
templateId: id(3),
|
||||
siteId: id(4),
|
||||
registeredOn: "2026-01-10",
|
||||
status: "operational",
|
||||
characteristics: { [id(5)]: "Modelo actual" },
|
||||
notes: "Notas actuales",
|
||||
};
|
||||
const asset = AssetSchema.parse({
|
||||
id: id(2),
|
||||
companyId,
|
||||
values,
|
||||
assigned: false,
|
||||
displayStatus: "available",
|
||||
specification: {
|
||||
templateId: id(3),
|
||||
templateName: "Impresora",
|
||||
fields: [
|
||||
{ fieldId: id(5), name: "Modelo", type: "text", unit: "", choices: [], required: false },
|
||||
],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
id: id(6),
|
||||
values: {
|
||||
...values,
|
||||
name: "Nombre anterior",
|
||||
notes: "Notas antiguas",
|
||||
characteristics: { [id(5)]: "Modelo anterior" },
|
||||
},
|
||||
actorId: id(7),
|
||||
actorName: "Ana",
|
||||
recordedAt: "2026-01-10T10:00:00.000Z",
|
||||
},
|
||||
{ id: id(8), values, actorId: id(7), actorName: "Ana", recordedAt: "2026-10-18T10:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
const lookups = {
|
||||
sites: [{ id: id(4), name: "Fábrica", active: true }],
|
||||
people: [],
|
||||
onCreateSite: () => undefined,
|
||||
onCreatePerson: () => undefined,
|
||||
};
|
||||
const config = InfrastructureSchema.parse({
|
||||
id: id(9),
|
||||
companyId,
|
||||
assetId: asset.id,
|
||||
values: { roles: ["shared_peripheral"], interfaces: [] },
|
||||
history: [
|
||||
{
|
||||
id: id(10),
|
||||
actorId: id(7),
|
||||
actorName: "Ana",
|
||||
recordedAt: "2026-10-17T10:00:00.000Z",
|
||||
values: {
|
||||
roles: ["shared_peripheral"],
|
||||
interfaces: [
|
||||
{
|
||||
id: id(11),
|
||||
name: "Ethernet",
|
||||
kind: "ethernet",
|
||||
mac: "",
|
||||
notes: "Interfaz retirada",
|
||||
addresses: [{ id: id(12), address: "192.168.5.201" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const relationship = RelationshipSchema.parse({
|
||||
id: id(13),
|
||||
companyId,
|
||||
values: {
|
||||
sourceId: id(14),
|
||||
targetId: asset.id,
|
||||
typeId: id(15),
|
||||
name: "Conectado a",
|
||||
inverseName: "Conectado a",
|
||||
symmetric: true,
|
||||
notes: "Conexión antigua",
|
||||
},
|
||||
createdBy: id(7),
|
||||
createdByName: "Ana",
|
||||
createdAt: "2026-10-15T10:00:00.000Z",
|
||||
endedBy: id(7),
|
||||
endedByName: "Ana",
|
||||
endedAt: "2026-10-16T10:00:00.000Z",
|
||||
});
|
||||
|
||||
async function render(
|
||||
language: string,
|
||||
node: ReactNode,
|
||||
mode: "ready" | "missing" | "loading" | "error" = "ready"
|
||||
) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
|
||||
if (mode === "ready" || mode === "missing") {
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: mode === "ready" ? [asset] : [],
|
||||
workstations: [],
|
||||
relationships: [relationship],
|
||||
});
|
||||
}
|
||||
if (mode === "error") {
|
||||
await client.prefetchQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => Promise.reject(new Error("UNAVAILABLE")),
|
||||
});
|
||||
}
|
||||
client.setQueryData(catalogKeys.scope(companyId), { items: [] });
|
||||
client.setQueryData(infrastructureKeys.company(companyId), { items: [config] });
|
||||
client.setQueryData(supportKeys.asset(companyId, asset.id), { connections: [], tools: [] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nextProvider i18n={i18n}>{node}</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
|
||||
test("ficha de activo es/en: página con dos pestañas, información actual, relaciones y contenido de compañía", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
const html = await render(
|
||||
language,
|
||||
<AssetDetailPage
|
||||
assetId={asset.id}
|
||||
companyId={companyId}
|
||||
lookups={lookups}
|
||||
relatedContent={() => <p>Contenido relacionado</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
html.includes('<h1 class="break-words text-2xl font-semibold">Impresora principal</h1>')
|
||||
);
|
||||
assert.equal((html.match(/role="tab"/g) ?? []).length, 2);
|
||||
assert.ok(html.includes(labels.assetDetail.information));
|
||||
assert.ok(html.includes(labels.history));
|
||||
assert.ok(html.includes(`href="/companies/${companyId}/assets"`));
|
||||
assert.ok(html.includes(labels.editAsset));
|
||||
assert.ok(html.includes("Modelo actual"));
|
||||
assert.ok(html.includes("Fábrica"));
|
||||
assert.ok(html.includes("Contenido relacionado"));
|
||||
assert.equal(html.includes("Notas antiguas"), false);
|
||||
assert.equal(html.includes('role="dialog"'), false);
|
||||
assert.equal(html.includes("assetDetail."), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("historial del activo conserva instantáneas antiguas, interfaces retiradas y conexiones en ambos sentidos, ordenadas por fecha", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
const html = await render(
|
||||
language,
|
||||
<AssetHistory
|
||||
asset={asset}
|
||||
assetName={() => "Switch principal"}
|
||||
lookups={lookups}
|
||||
relationships={[relationship]}
|
||||
/>
|
||||
);
|
||||
const dates = [...html.matchAll(/<time[^>]*dateTime="([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
assert.deepEqual(dates, [
|
||||
"2026-10-18T10:00:00.000Z",
|
||||
"2026-10-17T10:00:00.000Z",
|
||||
"2026-10-16T10:00:00.000Z",
|
||||
"2026-10-15T10:00:00.000Z",
|
||||
"2026-01-10T10:00:00.000Z",
|
||||
]);
|
||||
assert.ok(html.includes("Nombre anterior"));
|
||||
assert.ok(html.includes("Modelo anterior"));
|
||||
assert.ok(html.includes("Notas antiguas"));
|
||||
assert.ok(html.includes("Interfaz retirada"));
|
||||
assert.ok(html.includes("192.168.5.201"));
|
||||
assert.ok(html.includes(labels.assetDetail.relationshipEnded));
|
||||
assert.ok(html.includes(`href="/companies/${companyId}/assets/${id(14)}"`));
|
||||
assert.ok(html.includes("Switch principal"));
|
||||
}
|
||||
});
|
||||
|
||||
test("ficha de activo distingue carga, error y activo inexistente", async () => {
|
||||
const node = <AssetDetailPage assetId={asset.id} companyId={companyId} lookups={lookups} />;
|
||||
const loading = await render("es", node, "loading");
|
||||
const missing = await render("es", node, "missing");
|
||||
const failed = await render("es", node, "error");
|
||||
|
||||
assert.ok(loading.includes(assetsSpanish.loading));
|
||||
assert.equal(loading.includes('role="tab"'), false);
|
||||
assert.equal(missing.includes('role="tab"'), false);
|
||||
assert.ok(failed.includes('role="alert"'));
|
||||
assert.ok(failed.includes(assetsSpanish.retry));
|
||||
});
|
||||
@ -129,58 +129,6 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
renderUsage={lookups.renderUsage}
|
||||
/>
|
||||
{children}
|
||||
{!compact && (
|
||||
<>
|
||||
<FormSectionTitle>
|
||||
{t("history")} ({asset.history.length})
|
||||
</FormSectionTitle>
|
||||
<ol className="space-y-4">
|
||||
{[...asset.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {t("recordedBy", { name: revision.actorName })}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.name} · {t(revision.values.status)}
|
||||
</p>
|
||||
<p>
|
||||
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)}
|
||||
</p>
|
||||
<p>
|
||||
{t("registeredOn")}:{" "}
|
||||
{formatAssetRegistrationDate(revision.values.registeredOn, i18n.language) ??
|
||||
t("noValue")}
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">
|
||||
<FormSectionTitle as="span">{t("characteristics")}</FormSectionTitle>
|
||||
</summary>
|
||||
<FieldSections
|
||||
items={asset.specification.fields}
|
||||
layout="rows"
|
||||
renderItem={(field) => (
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-muted-foreground">{field.name}</dt>
|
||||
<dd>{display(revision.values.characteristics[field.fieldId])}</dd>
|
||||
</div>
|
||||
)}
|
||||
sections={asset.specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
{revision.values.notes && (
|
||||
<p className="mt-2 whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
||||
)}
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{!compact && <p className="break-all text-xs text-muted-foreground">ID: {asset.id}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
128
modules/assets/src/web/assets/detail/ui/asset-history.tsx
Normal file
128
modules/assets/src/web/assets/detail/ui/asset-history.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useListQuery } from "@metis/core/client";
|
||||
import type { TimeLineItem } from "@repo/rdx-ui/components/time-line";
|
||||
import { TimeLine } from "@repo/rdx-ui/components/time-line";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { RelationshipDTO } from "../../../../common";
|
||||
import {
|
||||
infrastructureApi,
|
||||
infrastructureKeys,
|
||||
} from "../../../infrastructure/shared/api/infrastructure-api";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { supportKeys } from "../../../shared/queries";
|
||||
import { SupportSummary } from "../../../support-connections/detail";
|
||||
import { getSupportConnections } from "../../../support-connections/list/api";
|
||||
import type { AssetDetailProps } from "../types";
|
||||
|
||||
import { assetRelationshipEvents } from "./asset-relationship-events";
|
||||
import { AssetRevisionDetails } from "./asset-revision-details";
|
||||
|
||||
export function AssetHistory({
|
||||
asset,
|
||||
lookups,
|
||||
relationships = [],
|
||||
assetName,
|
||||
}: AssetDetailProps & {
|
||||
relationships?: readonly RelationshipDTO[];
|
||||
assetName: (id: string) => string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const infrastructure = useListQuery({
|
||||
queryKey: infrastructureKeys.company(asset.companyId),
|
||||
queryFn: () => infrastructureApi.get(asset.companyId),
|
||||
});
|
||||
const support = useListQuery({
|
||||
queryKey: supportKeys.asset(asset.companyId, asset.id),
|
||||
queryFn: () => getSupportConnections(asset.companyId, asset.id),
|
||||
});
|
||||
const dateFormat = new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
const items: TimeLineItem[] = asset.history.map((revision, index) => ({
|
||||
id: revision.id,
|
||||
title: t(index === 0 ? "assetDetail.created" : "assetDetail.updated"),
|
||||
dateTime: revision.recordedAt,
|
||||
dateLabel: dateFormat.format(new Date(revision.recordedAt)),
|
||||
meta: t("recordedBy", { name: revision.actorName }),
|
||||
content: (
|
||||
<AssetRevisionDetails
|
||||
asset={asset}
|
||||
lookups={lookups}
|
||||
specification={revision.specification}
|
||||
values={revision.values}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
for (const revision of infrastructure.data?.items.find((item) => item.assetId === asset.id)
|
||||
?.history ?? []) {
|
||||
items.push({
|
||||
id: revision.id,
|
||||
title: t("infrastructure.interfaces"),
|
||||
dateTime: revision.recordedAt,
|
||||
dateLabel: dateFormat.format(new Date(revision.recordedAt)),
|
||||
meta: t("recordedBy", { name: revision.actorName }),
|
||||
content: (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
{revision.values.roles.map((role) => t(`infrastructure.roles.${role}`)).join(" · ") ||
|
||||
"—"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{revision.values.interfaces.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<p className="font-medium">
|
||||
{entry.name} · {t(`infrastructure.kinds.${entry.kind}`)}
|
||||
</p>
|
||||
<p>{entry.addresses.map((address) => address.address).join(" · ") || "—"}</p>
|
||||
<p>
|
||||
{t("infrastructure.mac")}: {entry.mac || "—"}
|
||||
</p>
|
||||
{entry.notes && <p className="whitespace-pre-wrap break-words">{entry.notes}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
items.push(...assetRelationshipEvents({ asset, relationships, assetName, dateFormat, t }));
|
||||
|
||||
for (const connection of support.data?.connections ?? []) {
|
||||
for (const revision of connection.history) {
|
||||
items.push({
|
||||
id: revision.id,
|
||||
title: t("support.history"),
|
||||
dateTime: revision.recordedAt,
|
||||
dateLabel: dateFormat.format(new Date(revision.recordedAt)),
|
||||
meta: t("recordedBy", { name: revision.actorName }),
|
||||
content: <SupportSummary tools={support.data?.tools ?? []} values={revision.values} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(infrastructure.isPending || support.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={infrastructure.error ?? support.error} />
|
||||
{(infrastructure.isError || support.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([infrastructure.refetch(), support.refetch()]).catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<TimeLine
|
||||
emptyMessage={t("assetDetail.noHistory")}
|
||||
items={items.sort((a, b) => b.dateTime.localeCompare(a.dateTime))}
|
||||
label={t("history")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import type { TimeLineItem } from "@repo/rdx-ui/components/time-line";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type { AssetDTO, RelationshipDTO } from "../../../../common";
|
||||
|
||||
/** Los dos extremos consultan los mismos eventos, con la etiqueta de relación que corresponde a cada uno. */
|
||||
export function assetRelationshipEvents({
|
||||
asset,
|
||||
relationships,
|
||||
assetName,
|
||||
dateFormat,
|
||||
t,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
relationships: readonly RelationshipDTO[];
|
||||
assetName: (id: string) => string;
|
||||
dateFormat: Intl.DateTimeFormat;
|
||||
t: TFunction<"assets">;
|
||||
}): TimeLineItem[] {
|
||||
const items: TimeLineItem[] = [];
|
||||
|
||||
for (const link of relationships.filter(
|
||||
(item) => item.values.sourceId === asset.id || item.values.targetId === asset.id
|
||||
)) {
|
||||
const source = link.values.sourceId === asset.id;
|
||||
const targetId = source ? link.values.targetId : link.values.sourceId;
|
||||
const content = (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
{source ? link.values.name : link.values.inverseName} ·{" "}
|
||||
<BrowserLink href={`/companies/${asset.companyId}/assets/${targetId}`}>
|
||||
{assetName(targetId)}
|
||||
</BrowserLink>
|
||||
</p>
|
||||
{link.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{link.values.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
items.push({
|
||||
id: `${link.id}:created`,
|
||||
title: t("assetDetail.relationshipCreated"),
|
||||
dateTime: link.createdAt,
|
||||
dateLabel: dateFormat.format(new Date(link.createdAt)),
|
||||
meta: t("recordedBy", { name: link.createdByName }),
|
||||
content,
|
||||
});
|
||||
if (link.endedAt) {
|
||||
items.push({
|
||||
id: `${link.id}:ended`,
|
||||
title: t("assetDetail.relationshipEnded"),
|
||||
dateTime: link.endedAt,
|
||||
dateLabel: dateFormat.format(new Date(link.endedAt)),
|
||||
meta: t("recordedBy", { name: link.endedByName ?? "—" }),
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { DetailField } from "@repo/rdx-ui/components/detail-field";
|
||||
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
||||
import type { InventoryLookups } from "../../shared/types";
|
||||
|
||||
/** Presenta los valores registrados en la revisión, incluidos los que ya no son actuales. */
|
||||
export function AssetRevisionDetails({
|
||||
asset,
|
||||
values,
|
||||
lookups,
|
||||
specification = asset.specification,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
specification?: AssetDTO["specification"] | undefined;
|
||||
values: AssetDTO["history"][number]["values"];
|
||||
lookups: InventoryLookups;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<dl>
|
||||
<DetailField label={t("name")}>{values.name}</DetailField>
|
||||
<DetailField label={t("status")}>{t(values.status)}</DetailField>
|
||||
<DetailField label={t("site")}>
|
||||
{lookups.sites.find((site) => site.id === values.siteId)?.name ?? "—"}
|
||||
</DetailField>
|
||||
<DetailField label={t("registeredOn")}>
|
||||
{formatAssetRegistrationDate(values.registeredOn, i18n.language) ?? "—"}
|
||||
</DetailField>
|
||||
</dl>
|
||||
<details>
|
||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{t("characteristics")}
|
||||
</summary>
|
||||
<FieldSections
|
||||
items={specification.fields}
|
||||
layout="rows"
|
||||
renderItem={(field) => {
|
||||
const value = values.characteristics[field.fieldId];
|
||||
|
||||
return (
|
||||
<DetailField
|
||||
key={field.fieldId}
|
||||
label={`${field.name}${field.unit ? ` (${field.unit})` : ""}`}
|
||||
>
|
||||
{value === undefined
|
||||
? "—"
|
||||
: typeof value === "boolean"
|
||||
? t(value ? "yes" : "no")
|
||||
: String(value)}
|
||||
</DetailField>
|
||||
);
|
||||
}}
|
||||
sections={specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
</details>
|
||||
{values.notes && <p className="whitespace-pre-wrap break-words">{values.notes}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,13 @@
|
||||
import {
|
||||
navigateTo,
|
||||
useBrowserSearchParams,
|
||||
useEntityNavigation,
|
||||
useListQuery,
|
||||
} from "@metis/core/client";
|
||||
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
|
||||
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import {
|
||||
infrastructureApi,
|
||||
infrastructureKeys,
|
||||
} from "../../../infrastructure/shared/api/infrastructure-api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import { removeAsset } from "../../shared/api";
|
||||
import { getAssetInventory } from "../api";
|
||||
@ -28,6 +27,10 @@ export function useAssetInventoryPageController({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const infrastructure = useListQuery({
|
||||
queryKey: infrastructureKeys.company(companyId),
|
||||
queryFn: () => infrastructureApi.get(companyId),
|
||||
});
|
||||
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||
const search = searchParams.get("q") ?? "";
|
||||
const templates = (catalog.data?.items ?? []).filter((item) => item.values.kind === "template");
|
||||
@ -73,11 +76,14 @@ export function useAssetInventoryPageController({
|
||||
const compact = useCompactDetail();
|
||||
const [localViewId, setLocalViewId] = useState<string | undefined>();
|
||||
const {
|
||||
selectedId,
|
||||
selectEntity: setSelectedId,
|
||||
selectedId: navigationId,
|
||||
selectEntity,
|
||||
entityHref: baseEntityHref,
|
||||
routed,
|
||||
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
|
||||
const [panelId, setPanelId] = useState(initialId);
|
||||
const selectedId = routed ? panelId : navigationId;
|
||||
const setSelectedId = routed ? setPanelId : selectEntity;
|
||||
const [detailOpen, setDetailOpen] = useState(true);
|
||||
const viewId = compact && routed ? selectedId : localViewId;
|
||||
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
|
||||
@ -90,13 +96,9 @@ export function useAssetInventoryPageController({
|
||||
|
||||
const selectAsset = (id: string) => {
|
||||
setDetailOpen(true);
|
||||
if (routed) {
|
||||
navigateTo(entityHref(id));
|
||||
} else {
|
||||
setSelectedId(id);
|
||||
if (compact) {
|
||||
setLocalViewId(id);
|
||||
}
|
||||
setSelectedId(id);
|
||||
if (compact && !routed) {
|
||||
setLocalViewId(id);
|
||||
}
|
||||
};
|
||||
|
||||
@ -107,7 +109,10 @@ export function useAssetInventoryPageController({
|
||||
const deleted = assets.find((asset) => asset.id === deleteId);
|
||||
|
||||
return {
|
||||
loading: query.isPending || catalog.isPending || infrastructure.isPending,
|
||||
hasLoadError: query.isError || catalog.isError || infrastructure.isError,
|
||||
query,
|
||||
infrastructure,
|
||||
entityHref,
|
||||
selectAsset,
|
||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||
|
||||
@ -30,6 +30,9 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
const {
|
||||
query,
|
||||
catalog,
|
||||
infrastructure,
|
||||
loading,
|
||||
hasLoadError,
|
||||
templateId,
|
||||
setTemplateId,
|
||||
clearFilters,
|
||||
@ -93,13 +96,20 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
title={t("inventory")}
|
||||
/>
|
||||
|
||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||
{loading && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error} />
|
||||
<CatalogError error={catalog.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
{infrastructure.isError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("assetColumns.infrastructureError")}
|
||||
</p>
|
||||
)}
|
||||
{hasLoadError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
|
||||
Promise.all([query.refetch(), catalog.refetch(), infrastructure.refetch()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
@ -110,6 +120,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
<AssetList
|
||||
assets={query.data.assets}
|
||||
catalog={catalog.data?.items ?? []}
|
||||
columnVisibilityStorageKey="asset-inventory-columns"
|
||||
extraColumns={[
|
||||
{
|
||||
id: "workstation",
|
||||
@ -142,10 +153,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction
|
||||
className="inline-flex inventory:hidden"
|
||||
onClick={() => setViewId(asset.id)}
|
||||
>
|
||||
<EntityAction onClick={() => navigateTo(entityHref(asset.id))}>
|
||||
{t("viewAsset")}
|
||||
</EntityAction>
|
||||
|
||||
@ -160,6 +168,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
),
|
||||
},
|
||||
]}
|
||||
infrastructure={infrastructure.data?.items ?? []}
|
||||
onClearFilters={clearFilters}
|
||||
onRowClick={(asset) => selectAsset(asset.id)}
|
||||
onSearchChange={setSearch}
|
||||
@ -189,7 +198,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onDelete={setDeleteId}
|
||||
onEdit={setEditor}
|
||||
onView={setViewId}
|
||||
onView={(id) => navigateTo(entityHref(id))}
|
||||
open={detailOpen}
|
||||
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
|
||||
/>
|
||||
@ -218,12 +227,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
/>
|
||||
)}
|
||||
{viewed && (
|
||||
<AssetDetailDialog
|
||||
asset={viewed}
|
||||
companyId={companyId}
|
||||
lookups={lookups}
|
||||
onClose={() => setViewId(undefined)}
|
||||
/>
|
||||
<AssetDetailDialog asset={viewed} lookups={lookups} onClose={() => setViewId(undefined)} />
|
||||
)}
|
||||
<DeleteAssetDialog
|
||||
asset={deleted}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useListQuery } from "@metis/core/client";
|
||||
import { useState } from "react";
|
||||
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import {
|
||||
infrastructureApi,
|
||||
infrastructureKeys,
|
||||
} from "../../../infrastructure/shared/api/infrastructure-api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import { getAssetInventory } from "../../list/api";
|
||||
import type { AssetSelectProps } from "../types/asset-select-props";
|
||||
@ -12,14 +16,18 @@ export function useAssetPickerController(props: AssetSelectProps, onClose: () =>
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [templateId, setTemplateId] = useState<string | null>(null);
|
||||
const inventory = useQuery({
|
||||
const inventory = useListQuery({
|
||||
queryKey: inventoryKeys.company(props.companyId),
|
||||
queryFn: () => getAssetInventory(props.companyId),
|
||||
});
|
||||
const catalog = useQuery({
|
||||
const catalog = useListQuery({
|
||||
queryKey: catalogKeys.scope(props.companyId),
|
||||
queryFn: () => catalogApi.get(props.companyId),
|
||||
});
|
||||
const infrastructure = useListQuery({
|
||||
queryKey: infrastructureKeys.company(props.companyId),
|
||||
queryFn: () => infrastructureApi.get(props.companyId),
|
||||
});
|
||||
const options = new Map(props.options.map((option) => [option.value, option]));
|
||||
const assets = (inventory.data?.assets ?? []).filter(
|
||||
(asset) => asset.companyId === props.companyId && options.has(asset.id)
|
||||
@ -68,11 +76,13 @@ export function useAssetPickerController(props: AssetSelectProps, onClose: () =>
|
||||
onClose();
|
||||
};
|
||||
|
||||
const retry = () => Promise.all([inventory.refetch(), catalog.refetch()]);
|
||||
const retry = () =>
|
||||
Promise.all([inventory.refetch(), catalog.refetch(), infrastructure.refetch()]);
|
||||
|
||||
return {
|
||||
inventory,
|
||||
catalog,
|
||||
infrastructure,
|
||||
assets,
|
||||
options,
|
||||
selectedIds,
|
||||
|
||||
@ -34,9 +34,16 @@ export function AssetPickerDialog({
|
||||
>
|
||||
<FormBody>
|
||||
<p className="text-sm text-muted-foreground">{selection.label}</p>
|
||||
{(c.inventory.isPending || c.catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||
{(c.inventory.isPending || c.catalog.isPending || c.infrastructure.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
<InventoryFeedback error={c.inventory.error ?? c.catalog.error} />
|
||||
{(c.inventory.isError || c.catalog.isError) && (
|
||||
{c.infrastructure.isError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("assetColumns.infrastructureError")}
|
||||
</p>
|
||||
)}
|
||||
{(c.inventory.isError || c.catalog.isError || c.infrastructure.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
c.retry().catch(() => undefined);
|
||||
@ -51,6 +58,7 @@ export function AssetPickerDialog({
|
||||
assets={c.assets}
|
||||
catalog={c.catalog.data?.items ?? []}
|
||||
disabled={c.disabled}
|
||||
infrastructure={c.infrastructure.data?.items ?? []}
|
||||
loading={c.inventory.isPending || c.catalog.isPending}
|
||||
mode={selection.mode ?? "single"}
|
||||
onClearFilters={() => {
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type { InfrastructureDTO } from "../../../../common/infrastructure";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
|
||||
/** Datos generales y de red opcionales; no confunde estado técnico con reserva en un puesto. */
|
||||
export function assetAdditionalColumns({
|
||||
infrastructure,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
infrastructure: readonly InfrastructureDTO[];
|
||||
locale: string;
|
||||
t: TFunction<"assets">;
|
||||
}): EntityColumn<AssetDTO>[] {
|
||||
const byAsset = new Map(infrastructure.map((item) => [item.assetId, item.values]));
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
const columns: EntityColumn<AssetDTO>[] = [
|
||||
{
|
||||
id: "technicalStatus",
|
||||
label: t("assetColumns.technicalStatus"),
|
||||
value: (asset) => t(asset.values.status),
|
||||
render: (asset) => t(asset.values.status),
|
||||
},
|
||||
{
|
||||
id: "assigned",
|
||||
label: t("assetColumns.assigned"),
|
||||
value: (asset) => asset.assigned,
|
||||
render: (asset) => t(asset.assigned ? "yes" : "no"),
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
label: t("notes"),
|
||||
value: (asset) => asset.values.notes,
|
||||
render: (asset) =>
|
||||
asset.values.notes.trim() ? (
|
||||
<p className="max-w-80 whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
|
||||
) : null,
|
||||
},
|
||||
...(["created", "updated"] as const).flatMap((event): EntityColumn<AssetDTO>[] => {
|
||||
const revisionOf = (asset: AssetDTO) =>
|
||||
event === "created" ? asset.history[0] : asset.history.at(-1);
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${event}At`,
|
||||
label: t(`assetColumns.${event}At`),
|
||||
value: (asset) => revisionOf(asset)?.recordedAt,
|
||||
render: (asset) => {
|
||||
const revision = revisionOf(asset);
|
||||
|
||||
return revision ? dateFormat.format(new Date(revision.recordedAt)) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${event}By`,
|
||||
label: t(`assetColumns.${event}By`),
|
||||
value: (asset) => revisionOf(asset)?.actorName,
|
||||
render: (asset) => revisionOf(asset)?.actorName,
|
||||
},
|
||||
];
|
||||
}),
|
||||
{
|
||||
id: "infrastructureRoles",
|
||||
label: t("assetColumns.roles"),
|
||||
value: (asset) =>
|
||||
byAsset
|
||||
.get(asset.id)
|
||||
?.roles.map((role) => t(`infrastructure.roles.${role}`))
|
||||
.join(" · "),
|
||||
render: (asset) =>
|
||||
byAsset
|
||||
.get(asset.id)
|
||||
?.roles.map((role) => t(`infrastructure.roles.${role}`))
|
||||
.join(" · "),
|
||||
},
|
||||
...(["interfaces", "ipAddresses", "macAddresses", "interfaceNotes"] as const).map(
|
||||
(kind): EntityColumn<AssetDTO> => {
|
||||
const lines = (asset: AssetDTO) =>
|
||||
(byAsset.get(asset.id)?.interfaces ?? []).flatMap((entry) => {
|
||||
switch (kind) {
|
||||
case "interfaces":
|
||||
return [`${entry.name} · ${t(`infrastructure.kinds.${entry.kind}`)}`];
|
||||
case "ipAddresses":
|
||||
return entry.addresses.map((address) => `${entry.name}: ${address.address}`);
|
||||
case "macAddresses":
|
||||
return entry.mac ? [`${entry.name}: ${entry.mac}`] : [];
|
||||
case "interfaceNotes":
|
||||
return entry.notes.trim() ? [`${entry.name}: ${entry.notes}`] : [];
|
||||
}
|
||||
const unreachable: never = kind;
|
||||
|
||||
return unreachable;
|
||||
});
|
||||
|
||||
return {
|
||||
id: kind,
|
||||
label: t(`assetColumns.${kind}`),
|
||||
value: (asset) => lines(asset).join(" · "),
|
||||
render: (asset) => {
|
||||
const values = lines(asset);
|
||||
|
||||
return values.length ? (
|
||||
<ul className="max-w-80 space-y-1 whitespace-pre-wrap break-words text-sm">
|
||||
{values.map((value) => (
|
||||
<li key={value}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
return columns.map((column) => ({ ...column, defaultVisible: false }));
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { AssetCharacteristic, AssetDTO } from "../../../../common/inventory";
|
||||
|
||||
import { formatAssetRegistrationDate } from "./format-asset-registration-date";
|
||||
|
||||
/** Une por identidad, conservando también campos de instantáneas que ya no están disponibles en el catálogo. */
|
||||
export function assetCharacteristicColumns({
|
||||
assets,
|
||||
catalog,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
assets: readonly AssetDTO[];
|
||||
catalog: readonly CatalogItemDTO[];
|
||||
locale: string;
|
||||
t: TFunction<"assets">;
|
||||
}): EntityColumn<AssetDTO>[] {
|
||||
const fields = new Map<string, Pick<AssetCharacteristic, "fieldId" | "name" | "type" | "unit">>();
|
||||
|
||||
for (const asset of assets) {
|
||||
for (const field of asset.specification.fields) {
|
||||
if (!fields.has(field.fieldId)) {
|
||||
fields.set(field.fieldId, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of catalog) {
|
||||
if (item.values.kind === "field" && item.effectiveActive) {
|
||||
fields.set(item.id, { fieldId: item.id, ...item.values });
|
||||
}
|
||||
}
|
||||
const collator = new Intl.Collator(locale, { sensitivity: "base", numeric: true });
|
||||
const sorted = [...fields.values()].sort(
|
||||
(a, b) => collator.compare(a.name, b.name) || a.fieldId.localeCompare(b.fieldId)
|
||||
);
|
||||
|
||||
const labelOf = (field: (typeof sorted)[number]) => field.name;
|
||||
|
||||
const labels = sorted.map(labelOf);
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return sorted.map((field) => {
|
||||
const label = labelOf(field);
|
||||
const ordinal = (occurrences.get(label) ?? 0) + 1;
|
||||
|
||||
occurrences.set(label, ordinal);
|
||||
|
||||
return {
|
||||
id: `characteristic-${field.fieldId}`,
|
||||
label: labels.filter((item) => item === label).length > 1 ? `${label} (${ordinal})` : label,
|
||||
defaultVisible: false,
|
||||
value: (asset) =>
|
||||
asset.specification.fields.some((item) => item.fieldId === field.fieldId)
|
||||
? asset.values.characteristics[field.fieldId]
|
||||
: undefined,
|
||||
render: (asset) => {
|
||||
const snapshot = asset.specification.fields.find((item) => item.fieldId === field.fieldId);
|
||||
const value = asset.values.characteristics[field.fieldId];
|
||||
|
||||
return formatCharacteristic(snapshot, value, locale, t);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatCharacteristic(
|
||||
field: AssetCharacteristic | undefined,
|
||||
value: AssetDTO["values"]["characteristics"][string] | undefined,
|
||||
locale: string,
|
||||
t: TFunction<"assets">
|
||||
) {
|
||||
if (!field || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return t(value ? "yes" : "no");
|
||||
}
|
||||
if (field.type === "date" && typeof value === "string") {
|
||||
return formatAssetRegistrationDate(value, locale);
|
||||
}
|
||||
const formatted = typeof value === "number" ? new Intl.NumberFormat(locale).format(value) : value;
|
||||
|
||||
return field.unit ? `${formatted} ${field.unit}` : formatted;
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CatalogItemSchema } from "../../../../common";
|
||||
import { InfrastructureSchema } from "../../../../common/infrastructure";
|
||||
import { AssetSchema } from "../../../../common/inventory";
|
||||
import { assetsEnglish, assetsSpanish } from "../../../shared";
|
||||
|
||||
import { assetAdditionalColumns } from "./asset-additional-columns";
|
||||
import { assetCharacteristicColumns } from "./asset-characteristic-columns";
|
||||
|
||||
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
|
||||
|
||||
const fields = [
|
||||
{ fieldId: id(10), name: "RAM", type: "number", unit: "GB", choices: [], required: false },
|
||||
{ fieldId: id(11), name: "Webcam", type: "boolean", unit: "", choices: [], required: false },
|
||||
{ fieldId: id(12), name: "Garantía", type: "date", unit: "", choices: [], required: false },
|
||||
{ fieldId: id(13), name: "Serie", type: "text", unit: "", choices: [], required: false },
|
||||
{ fieldId: id(14), name: "Serie", type: "text", unit: "", choices: [], required: false },
|
||||
];
|
||||
const values = {
|
||||
name: "Equipo principal",
|
||||
templateId: id(2),
|
||||
siteId: null,
|
||||
status: "operational",
|
||||
notes: "Notas del equipo",
|
||||
characteristics: {
|
||||
[id(10)]: 0,
|
||||
[id(11)]: false,
|
||||
[id(12)]: "2024-02-29",
|
||||
[id(13)]: "SERIE-A",
|
||||
[id(14)]: "SERIE-B",
|
||||
},
|
||||
};
|
||||
const asset = AssetSchema.parse({
|
||||
id: id(3),
|
||||
companyId: id(1),
|
||||
values,
|
||||
assigned: false,
|
||||
displayStatus: "available",
|
||||
specification: { templateId: id(2), templateName: "Portátil", fields },
|
||||
history: [
|
||||
{ id: id(4), actorId: id(5), actorName: "Ana", recordedAt: "2026-01-10T10:00:00.000Z", values },
|
||||
{
|
||||
id: id(6),
|
||||
actorId: id(5),
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-18T10:00:00.000Z",
|
||||
values,
|
||||
},
|
||||
],
|
||||
});
|
||||
const networkValues = {
|
||||
roles: ["server"],
|
||||
interfaces: [
|
||||
{
|
||||
id: id(20),
|
||||
name: "LAN",
|
||||
kind: "ethernet",
|
||||
mac: "AA:BB:CC:DD:EE:01",
|
||||
notes: "Interfaz local",
|
||||
addresses: [{ id: id(22), address: "192.168.5.30" }],
|
||||
},
|
||||
{
|
||||
id: id(21),
|
||||
name: "Radio",
|
||||
kind: "wifi",
|
||||
mac: "AA:BB:CC:DD:EE:02",
|
||||
notes: "",
|
||||
addresses: [
|
||||
{ id: id(23), address: "192.168.5.31" },
|
||||
{ id: id(24), address: "2001:db8::1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const infrastructure = InfrastructureSchema.parse({
|
||||
id: id(25),
|
||||
companyId: id(1),
|
||||
assetId: asset.id,
|
||||
values: networkValues,
|
||||
history: [
|
||||
{
|
||||
id: id(26),
|
||||
actorId: id(5),
|
||||
actorName: "Ana",
|
||||
recordedAt: "2026-10-18T10:00:00.000Z",
|
||||
values: networkValues,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
async function translator(language: string) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
|
||||
return i18n.getFixedT(language, "assets");
|
||||
}
|
||||
|
||||
test("columnas de características: identidad, campos nuevos de catálogo, instantáneas y nombres repetidos", async () => {
|
||||
const catalog = [id(12), id(30)].map((identity) =>
|
||||
CatalogItemSchema.parse({
|
||||
id: identity,
|
||||
companyId: null,
|
||||
builtin: false,
|
||||
active: true,
|
||||
effectiveActive: true,
|
||||
overridden: false,
|
||||
bindings: [],
|
||||
values: {
|
||||
kind: "field",
|
||||
name: identity === id(12) ? "Garantía renombrada" : "MPN",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
const columns = assetCharacteristicColumns({
|
||||
assets: [asset, asset],
|
||||
catalog,
|
||||
locale: "es",
|
||||
t: await translator("es"),
|
||||
});
|
||||
|
||||
assert.equal(columns.length, 6);
|
||||
assert.equal(new Set(columns.map((column) => column.id)).size, 6);
|
||||
assert.ok(columns.every((column) => column.defaultVisible === false));
|
||||
assert.equal(
|
||||
columns.find((column) => column.id === `characteristic-${id(12)}`)?.label,
|
||||
"Garantía renombrada"
|
||||
);
|
||||
assert.equal(
|
||||
columns.find((column) => column.id === `characteristic-${id(30)}`)?.render(asset),
|
||||
null
|
||||
);
|
||||
assert.deepEqual(
|
||||
columns.filter((column) => column.label.startsWith("Serie")).map((column) => column.label),
|
||||
["Serie (1)", "Serie (2)"]
|
||||
);
|
||||
assert.ok(
|
||||
String(
|
||||
columns.find((column) => column.id === `characteristic-${id(12)}`)?.render(asset)
|
||||
).includes("2024")
|
||||
);
|
||||
});
|
||||
|
||||
test("columnas elegidas es/en: cero, falso, fecha localizada, unidades y guion para campos ajenos", async () => {
|
||||
for (const locale of ["es", "en"]) {
|
||||
const t = await translator(locale);
|
||||
const columns = assetCharacteristicColumns({ assets: [asset], catalog: [], locale, t }).map(
|
||||
(column) => ({ ...column, defaultVisible: true })
|
||||
);
|
||||
const other = {
|
||||
...asset,
|
||||
id: id(31),
|
||||
values: { ...asset.values, characteristics: {} },
|
||||
specification: { ...asset.specification, fields: [] },
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<EntityTable columns={columns} items={[asset, other]} locale={locale} title="Activos" />
|
||||
);
|
||||
|
||||
assert.ok(html.includes("0 GB"));
|
||||
assert.ok(html.includes(t("no")));
|
||||
assert.ok(html.includes("SERIE-A"));
|
||||
assert.ok(html.includes("SERIE-B"));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(
|
||||
new Date("2024-02-29T00:00:00Z")
|
||||
)
|
||||
)
|
||||
);
|
||||
assert.equal((html.match(/>—<\/td>/g) ?? []).length, 5);
|
||||
assert.equal(
|
||||
columns.find((column) => column.id === `characteristic-${id(10)}`)?.value?.(asset),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
columns.find((column) => column.id === `characteristic-${id(11)}`)?.value?.(asset),
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("columnas generales y de red es/en: fechas y autores propios, IP/MAC por interfaz y ausencia real", async () => {
|
||||
for (const locale of ["es", "en"]) {
|
||||
const t = await translator(locale);
|
||||
const columns = assetAdditionalColumns({ infrastructure: [infrastructure], locale, t });
|
||||
const html = renderToStaticMarkup(
|
||||
<EntityTable
|
||||
columns={columns.map((column) => ({ ...column, defaultVisible: true }))}
|
||||
items={[asset]}
|
||||
locale={locale}
|
||||
title="Activos"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(columns.every((column) => column.defaultVisible === false));
|
||||
assert.ok(html.includes("Ana"));
|
||||
assert.ok(html.includes("David"));
|
||||
assert.ok(html.includes("LAN: 192.168.5.30"));
|
||||
assert.ok(html.includes("Radio: 192.168.5.31"));
|
||||
assert.ok(html.includes("Radio: 2001:db8::1"));
|
||||
assert.ok(html.includes("LAN: AA:BB:CC:DD:EE:01"));
|
||||
assert.ok(html.includes("Radio: AA:BB:CC:DD:EE:02"));
|
||||
assert.ok(html.includes(t("operational")));
|
||||
assert.ok(html.includes(t("infrastructure.roles.server")));
|
||||
assert.equal(html.includes("assetColumns."), false);
|
||||
assert.equal(
|
||||
columns.find((column) => column.id === "ipAddresses")?.render({ ...asset, id: id(32) }),
|
||||
null
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -48,7 +48,7 @@ export function AssetIdentityFields({
|
||||
variant="plain"
|
||||
>
|
||||
<div
|
||||
className={`grid gap-x-5 gap-y-4 ${mode === "template" ? "grid-cols-1" : "@min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4"}`}
|
||||
className={`grid gap-x-5 gap-y-4 ${mode === "template" ? "grid-cols-1" : "@min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-3"}`}
|
||||
>
|
||||
{mode !== "template" && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -6,7 +6,10 @@ import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { InfrastructureDTO } from "../../../../common/infrastructure";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { assetAdditionalColumns } from "../helpers/asset-additional-columns";
|
||||
import { assetCharacteristicColumns } from "../helpers/asset-characteristic-columns";
|
||||
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
|
||||
import { formatAssetRegistrationDate } from "../helpers/format-asset-registration-date";
|
||||
|
||||
@ -16,6 +19,8 @@ import { AssetTemplateIndicator } from "./asset-template-indicator";
|
||||
export function AssetList({
|
||||
assets,
|
||||
catalog,
|
||||
infrastructure = [],
|
||||
columnVisibilityStorageKey,
|
||||
sites = [],
|
||||
templateId,
|
||||
onTemplateChange,
|
||||
@ -33,6 +38,8 @@ export function AssetList({
|
||||
}: {
|
||||
assets: readonly AssetDTO[];
|
||||
catalog: readonly CatalogItemDTO[];
|
||||
infrastructure?: readonly InfrastructureDTO[];
|
||||
columnVisibilityStorageKey?: string | undefined;
|
||||
sites?: readonly { id: string; name: string }[] | undefined;
|
||||
templateId: string | null;
|
||||
onTemplateChange: (id: string) => void;
|
||||
@ -177,8 +184,11 @@ export function AssetList({
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
...assetAdditionalColumns({ infrastructure, locale: i18n.language, t }),
|
||||
...assetCharacteristicColumns({ assets, catalog, locale: i18n.language, t }),
|
||||
...extraColumns.filter((column) => column.id === "actions"),
|
||||
]}
|
||||
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
||||
emptyMessage={t("noAssets")}
|
||||
filterKey={templateId ?? "all"}
|
||||
filtersApplied={templateId !== null}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { AssetDefinitionSpec, SaveDefinitionResponseDTO } from "../../../../common";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import { catalogApi, catalogKeys } from "../api";
|
||||
|
||||
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
|
||||
@ -21,7 +22,12 @@ export function useCatalogSave(scope: string, onSaved: CatalogSaved, id?: string
|
||||
return useMutation({
|
||||
mutationFn: (values: AssetDefinitionSpec) => catalogApi.save(scope, values, id),
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ["asset-catalog"] }),
|
||||
client.invalidateQueries({
|
||||
queryKey: scope === "global" ? ["inventory"] : inventoryKeys.company(scope),
|
||||
}),
|
||||
]);
|
||||
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
|
||||
if (mounted.current) {
|
||||
onSaved(response.savedId, response);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { AssetForm, AssetInventoryPage } from "./assets";
|
||||
export { AssetDetailPage } from "./assets/detail/ui/asset-detail-page";
|
||||
export { AssetSelect } from "./assets/select/ui/asset-select";
|
||||
export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card";
|
||||
export { BrandCatalogPage } from "./brands";
|
||||
|
||||
@ -15,11 +15,13 @@ export function AssetInfrastructurePanel({
|
||||
assetId,
|
||||
assetName,
|
||||
templateId,
|
||||
showHistory = true,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
assetName: string;
|
||||
templateId: string;
|
||||
showHistory?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const [editing, setEditing] = useState(false);
|
||||
@ -82,7 +84,7 @@ export function AssetInfrastructurePanel({
|
||||
>
|
||||
{t("infrastructure.edit")}
|
||||
</Button>
|
||||
{current && (
|
||||
{showHistory && current && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t("history")} ({current.history.length})
|
||||
|
||||
@ -27,6 +27,7 @@ export function AssetInfrastructureSections({
|
||||
assetId={asset.id}
|
||||
assetName={asset.values.name}
|
||||
companyId={asset.companyId}
|
||||
showHistory={compact}
|
||||
templateId={asset.values.templateId}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
@ -834,6 +834,10 @@
|
||||
"review": "Configuration needs review.",
|
||||
"history": "Historical configurations ({{count}})",
|
||||
"notice": "Usage documented through profiles; this is not real-time detected activity."
|
||||
},
|
||||
"assetInformation": {
|
||||
"profilesError": "The profiles for this asset could not be loaded.",
|
||||
"noProfiles": "This asset has no user profiles yet."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -834,6 +834,10 @@
|
||||
"review": "Configuración pendiente de revisión.",
|
||||
"history": "Configuraciones históricas ({{count}})",
|
||||
"notice": "Uso documentado a través de perfiles; no representa actividad detectada en tiempo real."
|
||||
},
|
||||
"assetInformation": {
|
||||
"profilesError": "No se pudieron consultar los perfiles de este activo.",
|
||||
"noProfiles": "Este activo todavía no tiene perfiles de usuario."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import { BrowserLink, useListQuery } from "@metis/core/client";
|
||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
import { SoftwareInstallationsPanel } from "../../../software-installations/list/ui/software-installations-panel";
|
||||
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
|
||||
|
||||
/** Companies aporta las relaciones del activo con perfiles y software sin acoplar Assets a sus consultas. */
|
||||
export function AssetRelatedInformation({
|
||||
asset,
|
||||
company,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
company: CompanyDTO;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const profiles = useListQuery({
|
||||
queryKey: profileKeys.company(company.id),
|
||||
queryFn: () => profileApi.get(company.id),
|
||||
});
|
||||
const related =
|
||||
profiles.data?.profiles.filter((profile) => profile.values.assetId === asset.id) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailPanel
|
||||
count={profiles.isSuccess ? related.length : undefined}
|
||||
icon={<WorkProfileIcon />}
|
||||
title={t("profiles.title")}
|
||||
>
|
||||
{profiles.isError && (
|
||||
<div className="space-y-2" role="alert">
|
||||
<p>{t("assetInformation.profilesError")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
profiles.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("profiles.name"),
|
||||
value: (profile) => profile.values.name,
|
||||
render: (profile) => (
|
||||
<BrowserLink href={`/companies/${company.id}/work-profiles/${profile.id}`}>
|
||||
{profile.values.name}
|
||||
</BrowserLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("profiles.person"),
|
||||
value: (profile) =>
|
||||
company.people.find((person) => person.id === profile.values.personId)?.name,
|
||||
render: (profile) => {
|
||||
const person = company.people.find((item) => item.id === profile.values.personId);
|
||||
|
||||
return person ? (
|
||||
<BrowserLink href={`/companies/${company.id}/people/${person.id}`}>
|
||||
{person.name}
|
||||
</BrowserLink>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: t("status"),
|
||||
value: (profile) =>
|
||||
t(profile.values.active ? "profiles.active" : "profiles.inactive"),
|
||||
render: (profile) => (
|
||||
<EntityStatus active={profile.values.active}>
|
||||
{t(profile.values.active ? "profiles.active" : "profiles.inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("assetInformation.noProfiles")}
|
||||
items={related}
|
||||
loading={profiles.isPending}
|
||||
locale={i18n.language}
|
||||
searchPlaceholder={t("profiles.search")}
|
||||
title={t("profiles.title")}
|
||||
unavailable={profiles.isError}
|
||||
/>
|
||||
</DetailPanel>
|
||||
<SoftwareInstallationsPanel
|
||||
assetId={asset.id}
|
||||
canCreate={asset.values.status !== "retired"}
|
||||
companyId={company.id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
|
||||
import { AssetDetailPage, AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
import { AssetRelatedInformation } from "../../../assets/detail/ui/asset-related-information";
|
||||
import { AssetPrinterUsage } from "../../../infrastructure/shared/ui/asset-printer-usage";
|
||||
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
|
||||
|
||||
@ -42,6 +43,14 @@ export function CompanyInventory({
|
||||
lookups={lookups}
|
||||
workstationId={initialId}
|
||||
/>
|
||||
) : !workstations && initialId ? (
|
||||
<AssetDetailPage
|
||||
assetId={initialId}
|
||||
companyId={company.id}
|
||||
key={initialId}
|
||||
lookups={lookups}
|
||||
relatedContent={(asset) => <AssetRelatedInformation asset={asset} company={company} />}
|
||||
/>
|
||||
) : (
|
||||
<Page
|
||||
{...(workstations ? { initialPersonId, initialCreate } : { initialId, initialCreate })}
|
||||
|
||||
@ -21,6 +21,7 @@ import type { CompanyDTO, PersonDTO } from "../../../../common";
|
||||
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
||||
import { WorkProfileSchema } from "../../../../common/work-profiles";
|
||||
import { CompanyDetail } from "../../../companies/detail/ui/company-detail";
|
||||
import { CompanyInventory } from "../../../companies/detail/ui/company-inventory";
|
||||
import { companyKeys } from "../../../companies/shared/queries";
|
||||
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
|
||||
import { PeopleSection } from "../../../people/list/ui/people-section";
|
||||
@ -624,3 +625,30 @@ test("menú de compañía es/en: totales en badges alineados y cero real en secc
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ruta de activo de compañía abre la página con información, historial, software y solo sus perfiles", async () => {
|
||||
const computer = asset(201, "Equipo de diseño");
|
||||
const own = userProfile(202, computer.id, companyId, true);
|
||||
const unrelated = userProfile(203, companyId, companyId, true);
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
const companies = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
const html = await render(language, false, {
|
||||
assets: [computer],
|
||||
profiles: [own, unrelated],
|
||||
node: <CompanyInventory company={company} initialId={computer.id} />,
|
||||
});
|
||||
|
||||
assert.ok(html.includes("Equipo de diseño"));
|
||||
assert.ok(html.includes(labels.assetDetail.information));
|
||||
assert.ok(html.includes(labels.history));
|
||||
assert.ok(html.includes(companies.software.title));
|
||||
assert.ok(html.includes(`href="/companies/${companyId}/work-profiles/${own.id}"`));
|
||||
assert.equal(
|
||||
html.includes(`href="/companies/${companyId}/work-profiles/${unrelated.id}"`),
|
||||
false
|
||||
);
|
||||
assert.equal(html.includes('role="dialog"'), false);
|
||||
}
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@metis/core": "workspace:*",
|
||||
|
||||
@ -8,6 +8,7 @@ export interface IResourceCatalogRepository {
|
||||
get(scope: Maybe<UniqueID>): Promise<Result<ResourceCatalog, ResourceCatalogError>>;
|
||||
update(
|
||||
scope: Maybe<UniqueID>,
|
||||
action: (catalog: ResourceCatalog) => Result<ResourceCatalog, ResourceCatalogError>
|
||||
action: (catalog: ResourceCatalog) => Result<ResourceCatalog, ResourceCatalogError>,
|
||||
actorId?: UniqueID
|
||||
): Promise<Result<ResourceCatalog, ResourceCatalogError>>;
|
||||
}
|
||||
|
||||
@ -27,7 +27,17 @@ export class ResourceCatalogUseCases {
|
||||
return parsed.isSuccess ? this.repository.get(parsed.data) : parsed;
|
||||
}
|
||||
|
||||
async save(scope: string, identity: string | undefined, input: ResourceDefinitionSpec) {
|
||||
async save(
|
||||
scope: string,
|
||||
identity: string | undefined,
|
||||
input: ResourceDefinitionSpec,
|
||||
actorId?: string
|
||||
) {
|
||||
const actor = actorId ? UniqueID.create(actorId) : undefined;
|
||||
|
||||
if (actor && !actor.isSuccess) {
|
||||
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const parent = catalogScope(scope);
|
||||
const values = ResourceDefinitionValues.create(input);
|
||||
const id = identity ? UniqueID.create(identity) : Result.ok(undefined);
|
||||
@ -42,8 +52,11 @@ export class ResourceCatalogUseCases {
|
||||
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return this.repository.update(parent.data, (catalog) =>
|
||||
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data)
|
||||
return this.repository.update(
|
||||
parent.data,
|
||||
(catalog) =>
|
||||
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data),
|
||||
actor?.isSuccess ? actor.data : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ export function mapResourceInput(
|
||||
}
|
||||
const captured = previous
|
||||
? Result.ok(previous.specification)
|
||||
: captureSpecification(input, companyId, context);
|
||||
: captureResourceSpecification(input, companyId, context);
|
||||
|
||||
if (!captured.isSuccess) {
|
||||
return captured;
|
||||
@ -43,10 +43,11 @@ export function mapResourceInput(
|
||||
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
|
||||
}
|
||||
|
||||
function captureSpecification(
|
||||
input: ResourceInput,
|
||||
export function captureResourceSpecification(
|
||||
input: Pick<ResourceInput, "templateId">,
|
||||
companyId: UniqueID,
|
||||
context: InventoryContext
|
||||
context: Pick<InventoryContext, "catalog">,
|
||||
options: { existing?: boolean } = {}
|
||||
) {
|
||||
const template = context.catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
@ -55,7 +56,7 @@ function captureSpecification(
|
||||
if (
|
||||
!template ||
|
||||
template.values.data.kind !== "template" ||
|
||||
!context.catalog.active(template, Maybe.some(companyId))
|
||||
!(options.existing || context.catalog.active(template, Maybe.some(companyId)))
|
||||
) {
|
||||
return Result.fail(new ResourceCatalogError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@ export function buildResourceResponse(
|
||||
history: resource.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
...(revision.specification.equals(resource.specification)
|
||||
? {}
|
||||
: { specification: revision.specification.toPrimitive() }),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName: actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
|
||||
import type { ResourceValues } from "./value-objects/resource-values.vo";
|
||||
import type { ResourceSpecification, ResourceValues } from "./value-objects/resource-values.vo";
|
||||
|
||||
export type ResourceRevisionProps = Readonly<{
|
||||
values: ResourceValues;
|
||||
specification: ResourceSpecification;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
@ -18,6 +19,10 @@ export class ResourceRevision extends DomainEntity<ResourceRevisionProps> {
|
||||
return new ResourceRevision(props, id);
|
||||
}
|
||||
|
||||
get specification() {
|
||||
return this.props.specification;
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { synchronizeTemplateStructure } from "@repo/rdx-utils";
|
||||
|
||||
import { ResourcePersistenceMapper } from "../infrastructure/persistence/resource-persistence.mapper";
|
||||
|
||||
import { Resource } from "./resource.aggregate";
|
||||
import type { ResourceSpecificationData } from "./resource-values";
|
||||
import { ResourceSpecification, ResourceValues } from "./value-objects/resource-values.vo";
|
||||
|
||||
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
|
||||
|
||||
const identity = (n: number) => {
|
||||
const result = UniqueID.create(id(n));
|
||||
|
||||
assert.ok(result.isSuccess);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const field = (
|
||||
n: number,
|
||||
name: string,
|
||||
type: "text" | "boolean" | "number",
|
||||
sectionId = id(20),
|
||||
required = false
|
||||
) => ({ fieldId: id(n), name, type, unit: "", choices: [], required, sectionId });
|
||||
|
||||
const previous = {
|
||||
templateId: id(1),
|
||||
templateName: "Plantilla",
|
||||
sections: [{ id: id(20), name: "Original", description: "" }],
|
||||
fields: [
|
||||
field(2, "Texto", "text", id(20), true),
|
||||
field(3, "Cero", "number"),
|
||||
field(4, "No", "boolean"),
|
||||
field(5, "Vacío", "text"),
|
||||
],
|
||||
};
|
||||
const target = {
|
||||
templateId: id(1),
|
||||
templateName: "Plantilla revisada",
|
||||
sections: [{ id: id(21), name: "Nueva", description: "" }],
|
||||
fields: [
|
||||
field(6, "Nuevo obligatorio", "text", id(21), true),
|
||||
field(4, "No", "boolean", id(21)),
|
||||
field(3, "Cero", "number", id(21)),
|
||||
],
|
||||
};
|
||||
const input = {
|
||||
name: "Registro",
|
||||
templateId: id(1),
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: { [id(2)]: "Valor que se conserva", [id(3)]: 0, [id(4)]: false },
|
||||
};
|
||||
|
||||
test("Resource: propaga altas, orden y retiradas conservando cero/falso, valores e historial; repetir no genera revisiones", () => {
|
||||
const specification = ResourceSpecification.create(previous);
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
const values = ResourceValues.create(input, specification.data);
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const created = Resource.create(
|
||||
identity(10),
|
||||
specification.data,
|
||||
values.data,
|
||||
identity(11),
|
||||
"2026-10-18T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(created.isSuccess);
|
||||
const structure = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: target,
|
||||
values: input.characteristics,
|
||||
});
|
||||
const next = ResourceSpecification.create({ ...target, ...structure });
|
||||
|
||||
assert.ok(next.isSuccess);
|
||||
const changed = created.data.synchronizeSpecification(
|
||||
next.data,
|
||||
identity(11),
|
||||
"2026-10-19T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(changed.isSuccess);
|
||||
const item = changed.data;
|
||||
|
||||
assert.deepEqual(
|
||||
item.specification.toPrimitive().fields.map((field) => field.fieldId),
|
||||
[id(6), id(4), id(3), id(2)]
|
||||
);
|
||||
assert.deepEqual(
|
||||
item.specification.toPrimitive().sections?.map((section) => section.id),
|
||||
[id(21), id(20)]
|
||||
);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(3)], 0);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(4)], false);
|
||||
assert.equal(item.values.toPrimitive().characteristics[id(6)], undefined);
|
||||
assert.equal(
|
||||
item.specification.toPrimitive().fields.find((field) => field.fieldId === id(2))?.required,
|
||||
false
|
||||
);
|
||||
assert.equal(item.history.length, 2);
|
||||
assert.deepEqual(item.history[0]?.specification.toPrimitive(), specification.data.toPrimitive());
|
||||
const repeated = item.synchronizeSpecification(
|
||||
next.data,
|
||||
identity(11),
|
||||
"2026-10-19T11:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(repeated.isSuccess);
|
||||
assert.equal(repeated.data, item);
|
||||
const mapper = new ResourcePersistenceMapper();
|
||||
const stored = mapper.toPersistence(item);
|
||||
const restored = mapper.toDomain(stored.resource, stored.revisions);
|
||||
|
||||
assert.equal(restored.history.length, 2);
|
||||
assert.equal(restored.history[0]?.specification.toPrimitive().fields.length, 4);
|
||||
assert.equal(
|
||||
restored.history[0]?.values.toPrimitive().characteristics[id(2)],
|
||||
"Valor que se conserva"
|
||||
);
|
||||
assert.equal(
|
||||
ResourceValues.create(item.values.toPrimitive(), item.specification).isSuccess,
|
||||
false
|
||||
);
|
||||
const completed = ResourceValues.create(
|
||||
{
|
||||
...item.values.toPrimitive(),
|
||||
characteristics: {
|
||||
...item.values.toPrimitive().characteristics,
|
||||
[id(6)]: "Completado",
|
||||
[id(2)]: "",
|
||||
},
|
||||
},
|
||||
item.specification
|
||||
);
|
||||
|
||||
assert.ok(completed.isSuccess);
|
||||
const edited = item.update(completed.data, identity(11), "2026-10-19T12:00:00.000Z");
|
||||
|
||||
assert.ok(edited.isSuccess);
|
||||
const cleaned = synchronizeTemplateStructure({
|
||||
previous: edited.data.specification.toPrimitive(),
|
||||
next: target,
|
||||
values: edited.data.values.toPrimitive().characteristics,
|
||||
});
|
||||
const finalSpecification = ResourceSpecification.create({ ...target, ...cleaned });
|
||||
|
||||
assert.ok(finalSpecification.isSuccess);
|
||||
const final = edited.data.synchronizeSpecification(
|
||||
finalSpecification.data,
|
||||
identity(11),
|
||||
"2026-10-19T13:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(final.isSuccess);
|
||||
assert.equal(
|
||||
final.data.specification.toPrimitive().fields.some((field) => field.fieldId === id(2)),
|
||||
false
|
||||
);
|
||||
assert.deepEqual(
|
||||
final.data.specification.toPrimitive().sections?.map((section) => section.id),
|
||||
[id(21)]
|
||||
);
|
||||
const finalRows = mapper.toPersistence(final.data);
|
||||
const roundTrip = mapper.toDomain(finalRows.resource, finalRows.revisions);
|
||||
|
||||
assert.equal(
|
||||
roundTrip.history[0]?.values.toPrimitive().characteristics[id(2)],
|
||||
"Valor que se conserva"
|
||||
);
|
||||
assert.equal(roundTrip.history[0]?.specification.toPrimitive().fields[0]?.name, "Texto");
|
||||
});
|
||||
|
||||
test("Resource: los campos retirados no impiden añadir una plantilla completa y no se fusionan por nombre", () => {
|
||||
const fields = Array.from({ length: 100 }, (_, n) => field(100 + n, "Campo", "text", id(21)));
|
||||
const next = { ...target, fields };
|
||||
const merged = synchronizeTemplateStructure({ previous, next, values: input.characteristics });
|
||||
const specification = ResourceSpecification.create({ ...next, ...merged });
|
||||
|
||||
assert.ok(specification.isSuccess);
|
||||
assert.equal(specification.data.toPrimitive().fields.length, 103);
|
||||
assert.equal(
|
||||
new Set(specification.data.toPrimitive().fields.map((field) => field.fieldId)).size,
|
||||
103
|
||||
);
|
||||
});
|
||||
|
||||
test("Resource: retirar secciones conserva solo las necesarias y mover o reincorporar campos aplica el destino", () => {
|
||||
const section = previous.sections[0];
|
||||
|
||||
assert.ok(section);
|
||||
const next: ResourceSpecificationData = {
|
||||
...previous,
|
||||
sections: [],
|
||||
fields: previous.fields.map(({ sectionId: _sectionId, ...field }) => field),
|
||||
};
|
||||
const result = synchronizeTemplateStructure({ previous, next, values: input.characteristics });
|
||||
|
||||
assert.deepEqual(result.sections, previous.sections);
|
||||
assert.equal(result.fields.find((field) => field.fieldId === id(2))?.sectionId, section.id);
|
||||
assert.equal(result.fields.find((field) => field.fieldId === id(5))?.sectionId, undefined);
|
||||
const moved = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: target,
|
||||
values: input.characteristics,
|
||||
});
|
||||
|
||||
assert.equal(moved.fields.find((field) => field.fieldId === id(4))?.sectionId, id(21));
|
||||
const returned = synchronizeTemplateStructure({
|
||||
previous: { ...target, ...moved },
|
||||
next: previous,
|
||||
values: input.characteristics,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
returned.fields.map((field) => field.fieldId),
|
||||
previous.fields.map((field) => field.fieldId)
|
||||
);
|
||||
assert.equal(returned.fields.find((field) => field.fieldId === id(2))?.required, true);
|
||||
const empty = synchronizeTemplateStructure({
|
||||
previous,
|
||||
next: { ...next, fields: [] },
|
||||
values: { [id(2)]: " " },
|
||||
});
|
||||
|
||||
assert.equal(empty.fields.length, 0);
|
||||
assert.equal(empty.sections.length, 0);
|
||||
});
|
||||
@ -49,8 +49,8 @@ export const ResourceInputSchema = z.strictObject({
|
||||
export const ResourceSpecificationSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
templateName: z.string().min(1).max(100),
|
||||
sections: z.array(TemplateSectionSchema).max(30).optional(),
|
||||
fields: z.array(ResourceCharacteristicSchema).min(1).max(100),
|
||||
sections: z.array(TemplateSectionSchema).optional(),
|
||||
fields: z.array(ResourceCharacteristicSchema).min(1),
|
||||
});
|
||||
|
||||
export type ResourceInput = z.infer<typeof ResourceInputSchema>;
|
||||
|
||||
@ -4,7 +4,8 @@ import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { ResourceCatalogError } from "./resource-definition";
|
||||
import { ResourceRevision } from "./resource-revision.entity";
|
||||
import type { ResourceSpecification, ResourceValues } from "./value-objects/resource-values.vo";
|
||||
import type { ResourceSpecification } from "./value-objects/resource-values.vo";
|
||||
import { ResourceValues } from "./value-objects/resource-values.vo";
|
||||
|
||||
type ResourceProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
@ -30,7 +31,7 @@ export class Resource extends AggregateRoot<ResourceProps> {
|
||||
companyId,
|
||||
specification,
|
||||
values,
|
||||
history: [ResourceRevision.create({ values, actorId, recordedAt })],
|
||||
history: [ResourceRevision.create({ values, specification, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -55,6 +56,47 @@ export class Resource extends AggregateRoot<ResourceProps> {
|
||||
return this.props.history;
|
||||
}
|
||||
|
||||
synchronizeSpecification(
|
||||
specification: ResourceSpecification,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
) {
|
||||
const allowed = new Set(specification.toPrimitive().fields.map((field) => field.fieldId));
|
||||
const values = ResourceValues.reconstruct(
|
||||
{
|
||||
...this.values.toPrimitive(),
|
||||
characteristics: Object.fromEntries(
|
||||
Object.entries(this.values.toPrimitive().characteristics).filter(([id]) =>
|
||||
allowed.has(id)
|
||||
)
|
||||
),
|
||||
},
|
||||
specification
|
||||
);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
if (this.specification.equals(specification) && this.values.equals(values.data)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
new Resource(
|
||||
{
|
||||
...this.props,
|
||||
specification,
|
||||
values: values.data,
|
||||
history: [
|
||||
...this.history,
|
||||
ResourceRevision.create({ specification, values: values.data, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
values: ResourceValues,
|
||||
actorId: UniqueID,
|
||||
@ -90,7 +132,15 @@ export class Resource extends AggregateRoot<ResourceProps> {
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [...this.history, ResourceRevision.create({ values, actorId, recordedAt })],
|
||||
history: [
|
||||
...this.history,
|
||||
ResourceRevision.create({
|
||||
values,
|
||||
specification: this.specification,
|
||||
actorId,
|
||||
recordedAt,
|
||||
}),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
|
||||
@ -132,9 +132,19 @@ export class ResourceValues {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
static create(
|
||||
static create(input: ResourceInput, specification: ResourceSpecification) {
|
||||
return ResourceValues.build(input, specification, { requiredValues: true });
|
||||
}
|
||||
|
||||
/** Rehidrata registros que pueden estar pendientes de completar campos añadidos a su plantilla. */
|
||||
static reconstruct(input: ResourceInput, specification: ResourceSpecification) {
|
||||
return ResourceValues.build(input, specification, { requiredValues: false });
|
||||
}
|
||||
|
||||
private static build(
|
||||
input: ResourceInput,
|
||||
specification: ResourceSpecification
|
||||
specification: ResourceSpecification,
|
||||
options: { requiredValues: boolean }
|
||||
): Result<ResourceValues, ResourceCatalogError> {
|
||||
const parsed = ResourceInputSchema.safeParse(input);
|
||||
const name = Name.create(input.name, 100);
|
||||
@ -146,7 +156,11 @@ export class ResourceValues {
|
||||
return Result.fail(new ResourceCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const data = parsed.data;
|
||||
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
|
||||
const parsedCharacteristics = normalizeCharacteristics(
|
||||
data.characteristics,
|
||||
specification,
|
||||
options
|
||||
);
|
||||
|
||||
if (!parsedCharacteristics.isSuccess) {
|
||||
return parsedCharacteristics;
|
||||
@ -197,7 +211,8 @@ export class ResourceValues {
|
||||
|
||||
function normalizeCharacteristics(
|
||||
input: ResourceInput["characteristics"],
|
||||
specification: ResourceSpecification
|
||||
specification: ResourceSpecification,
|
||||
options: { requiredValues: boolean }
|
||||
): Result<ResourceInput["characteristics"], ResourceCatalogError> {
|
||||
const characteristics: ResourceInput["characteristics"] = {};
|
||||
|
||||
@ -215,6 +230,7 @@ function normalizeCharacteristics(
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.requiredValues &&
|
||||
specification
|
||||
.toPrimitive()
|
||||
.fields.some((field) => field.required && !(field.fieldId in characteristics))
|
||||
|
||||
@ -87,8 +87,11 @@ export function registerResourceCatalogRoutes(
|
||||
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
|
||||
const params = schema.safeParse(request.params);
|
||||
const body = SaveDefinitionRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
|
||||
if (!(params.success && body.success)) {
|
||||
if (!(params.success && body.success && actor.success)) {
|
||||
sendError(response, "INVALID_REQUEST");
|
||||
|
||||
return;
|
||||
@ -97,7 +100,7 @@ export function registerResourceCatalogRoutes(
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
|
||||
useCases
|
||||
.save(params.data.scope, identity, body.data.values)
|
||||
.save(params.data.scope, identity, body.data.values, actor.data.userId)
|
||||
.then((result) =>
|
||||
sendResult(response, result, params.data.scope, identity ? 200 : 201, {
|
||||
id: identity,
|
||||
|
||||
@ -192,6 +192,7 @@ async function importResource(
|
||||
return ResourceRevision.rehydrate(
|
||||
{
|
||||
values: previous.data,
|
||||
specification,
|
||||
actorId: identity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result, synchronizeTemplateStructure } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import { captureResourceSpecification } from "../../application/resource-input.mapper";
|
||||
import type { Resource } from "../../domain/resource.aggregate";
|
||||
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
|
||||
import { ResourceSpecification } from "../../domain/value-objects/resource-values.vo";
|
||||
|
||||
import { readInventory } from "./resource-inventory-relational";
|
||||
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||
|
||||
/** Prepara todas las compañías antes de escribir: un fallo de dominio no deja el catálogo a medio propagar. */
|
||||
export async function prepareResourceTemplateUpdates(
|
||||
database: Sequelize,
|
||||
previous: ResourceCatalog,
|
||||
next: ResourceCatalog,
|
||||
actorId: UniqueID | undefined,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const updates: { previous: Resource; next: Resource }[] = [];
|
||||
|
||||
if (!actorId) {
|
||||
return Result.ok(updates);
|
||||
}
|
||||
const templateIds = next.definitions
|
||||
.filter((definition) => {
|
||||
if (definition.values.data.kind !== "template") {
|
||||
return false;
|
||||
}
|
||||
const old = previous.definitions.find((item) => item.id.equals(definition.id));
|
||||
|
||||
return (
|
||||
old &&
|
||||
(!old.values.equals(definition.values) ||
|
||||
definition.values.data.fields.some((binding) => {
|
||||
const field = next.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
const oldField = previous.definitions.find(
|
||||
(item) => item.id.toPrimitive() === binding.fieldId
|
||||
);
|
||||
|
||||
return field && oldField && !field.values.equals(oldField.values);
|
||||
}))
|
||||
);
|
||||
})
|
||||
.map((item) => item.id.toPrimitive());
|
||||
|
||||
if (!templateIds.length) {
|
||||
return Result.ok(updates);
|
||||
}
|
||||
const companies = await database.query<{ companyId: string }>(
|
||||
"SELECT DISTINCT companyId FROM resources WHERE templateId IN (:templateIds) ORDER BY companyId",
|
||||
{ replacements: { templateIds }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const mapper = new ResourcePersistenceMapper();
|
||||
|
||||
for (const company of companies) {
|
||||
await database.query("SELECT id FROM companies WHERE id=:id FOR UPDATE", {
|
||||
replacements: { id: company.companyId },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
const loaded = await readInventory(database, company.companyId, transaction);
|
||||
|
||||
for (const row of loaded.rows.filter((item) => templateIds.includes(item.templateId))) {
|
||||
const item = mapper.toDomain(
|
||||
row,
|
||||
loaded.revisions.filter((revision) => revision.resourceId === row.id)
|
||||
);
|
||||
const changed = synchronizeResourceTemplate(item, next, actorId, now);
|
||||
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
if (changed.data !== item) {
|
||||
updates.push({ previous: item, next: changed.data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(updates);
|
||||
}
|
||||
|
||||
function synchronizeResourceTemplate(
|
||||
item: Resource,
|
||||
next: ResourceCatalog,
|
||||
actorId: UniqueID,
|
||||
now: string
|
||||
) {
|
||||
const captured = captureResourceSpecification(
|
||||
{ templateId: item.values.toPrimitive().templateId },
|
||||
item.companyId,
|
||||
{ catalog: next },
|
||||
{ existing: true }
|
||||
);
|
||||
|
||||
if (!captured.isSuccess) {
|
||||
return Result.fail(captured.error);
|
||||
}
|
||||
const integration = item.values.toPrimitive().integration;
|
||||
const protectedFieldIds =
|
||||
integration?.kind === "mailbox"
|
||||
? [integration.addressFieldId, integration.contextFieldId]
|
||||
: integration?.kind === "access"
|
||||
? [integration.referenceFieldId]
|
||||
: [];
|
||||
const structure = synchronizeTemplateStructure({
|
||||
previous: item.specification.toPrimitive(),
|
||||
next: captured.data.toPrimitive(),
|
||||
values: item.values.toPrimitive().characteristics,
|
||||
protectedFieldIds,
|
||||
});
|
||||
const specification = ResourceSpecification.create({
|
||||
...captured.data.toPrimitive(),
|
||||
...structure,
|
||||
});
|
||||
|
||||
if (!specification.isSuccess) {
|
||||
return Result.fail(specification.error);
|
||||
}
|
||||
|
||||
return item.synchronizeSpecification(specification.data, actorId, now);
|
||||
}
|
||||
@ -9,6 +9,10 @@ import { sqlIdentifier } from "./resource-relational-sql";
|
||||
|
||||
const OWNED_TABLES = [
|
||||
"resource_revision_characteristics",
|
||||
"resource_revision_specification_choices",
|
||||
"resource_revision_specification_fields",
|
||||
"resource_revision_specification_sections",
|
||||
"resource_revision_specifications",
|
||||
"resource_characteristics",
|
||||
"resource_specification_field_choices",
|
||||
"resource_specification_fields",
|
||||
|
||||
@ -6,6 +6,10 @@ import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/r
|
||||
|
||||
import type { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||
import { readResourceRows, sqlIdentifier, writeResourceRow } from "./resource-relational-sql";
|
||||
import {
|
||||
readResourceRevisionSpecifications,
|
||||
writeResourceRevisionSpecification,
|
||||
} from "./resource-revision-specifications";
|
||||
|
||||
export type InventoryRow = {
|
||||
id: string;
|
||||
@ -21,6 +25,7 @@ export type RevisionRow = {
|
||||
actorId: string;
|
||||
recordedAt: string;
|
||||
position: number;
|
||||
specification?: unknown;
|
||||
values: unknown;
|
||||
};
|
||||
|
||||
@ -90,7 +95,7 @@ type ResourceRow = Omit<InventoryRow, "values" | "specification"> &
|
||||
sectionsDefined: boolean;
|
||||
};
|
||||
|
||||
type RevisionColumns = Omit<RevisionRow, "values"> &
|
||||
type RevisionColumns = Omit<RevisionRow, "values" | "specification"> &
|
||||
IntegrationColumns & {
|
||||
name: string;
|
||||
active: boolean;
|
||||
@ -282,13 +287,27 @@ export async function readInventory(
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
const revisions: RevisionRow[] = histories.map((row) => ({
|
||||
...row,
|
||||
values: values(
|
||||
row,
|
||||
historical.filter((entry) => entry.revisionId === row.id)
|
||||
),
|
||||
}));
|
||||
const revisionSpecifications = await readResourceRevisionSpecifications(
|
||||
database,
|
||||
ids,
|
||||
transaction
|
||||
);
|
||||
const revisions: RevisionRow[] = histories.map((row) => {
|
||||
const specification = revisionSpecifications.get(row.id);
|
||||
|
||||
if (!specification) {
|
||||
throw new Error("Revisión sin especificación histórica persistida.");
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
specification,
|
||||
values: values(
|
||||
row,
|
||||
historical.filter((entry) => entry.revisionId === row.id)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { rows, revisions };
|
||||
}
|
||||
@ -319,7 +338,17 @@ export async function writeInventory(
|
||||
},
|
||||
transaction
|
||||
);
|
||||
// La especificación permanece congelada durante toda la vida del recurso.
|
||||
for (const table of [
|
||||
"resource_characteristics",
|
||||
"resource_specification_field_choices",
|
||||
"resource_specification_fields",
|
||||
"resource_specification_sections",
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE resourceId=:id`, {
|
||||
replacements: { id: resource.id },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const [position, section] of (specification.sections ?? []).entries()) {
|
||||
await writeResourceRow(
|
||||
database,
|
||||
@ -358,10 +387,7 @@ export async function writeInventory(
|
||||
);
|
||||
}
|
||||
}
|
||||
await database.query("DELETE FROM resource_characteristics WHERE resourceId=:id", {
|
||||
replacements: { id: resource.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const row of characteristicRows(resource.id, v, specification)) {
|
||||
await writeResourceRow(database, "resource_characteristics", row, transaction);
|
||||
}
|
||||
@ -388,7 +414,19 @@ export async function writeInventory(
|
||||
},
|
||||
transaction
|
||||
);
|
||||
for (const row of characteristicRows(resource.id, previous, specification, revision.id)) {
|
||||
await writeResourceRevisionSpecification(
|
||||
database,
|
||||
resource.id,
|
||||
revision.id,
|
||||
revision.specification,
|
||||
transaction
|
||||
);
|
||||
for (const row of characteristicRows(
|
||||
resource.id,
|
||||
previous,
|
||||
revision.specification,
|
||||
revision.id
|
||||
)) {
|
||||
await writeResourceRow(database, "resource_revision_characteristics", row, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,10 @@ export class ResourcePersistenceMapper {
|
||||
if (!specification.isSuccess) {
|
||||
throw new Error("Especificación de recurso persistida inconsistente.");
|
||||
}
|
||||
const values = ResourceValues.create(ResourceInputSchema.parse(row.values), specification.data);
|
||||
const values = ResourceValues.reconstruct(
|
||||
ResourceInputSchema.parse(row.values),
|
||||
specification.data
|
||||
);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
throw new Error("Valores de recurso persistidos inconsistentes.");
|
||||
@ -42,9 +45,16 @@ export class ResourcePersistenceMapper {
|
||||
}
|
||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||
const history = ordered.map((revision, position) => {
|
||||
const previous = ResourceValues.create(
|
||||
const revisionSpecification = ResourceSpecification.create(
|
||||
ResourceSpecificationSchema.parse(revision.specification ?? row.specification)
|
||||
);
|
||||
|
||||
if (!revisionSpecification.isSuccess) {
|
||||
throw new Error("Especificación histórica inconsistente.");
|
||||
}
|
||||
const previous = ResourceValues.reconstruct(
|
||||
ResourceInputSchema.parse(revision.values),
|
||||
specification.data
|
||||
revisionSpecification.data
|
||||
);
|
||||
|
||||
if (
|
||||
@ -62,6 +72,7 @@ export class ResourcePersistenceMapper {
|
||||
return ResourceRevision.rehydrate(
|
||||
{
|
||||
values: previous.data,
|
||||
specification: revisionSpecification.data,
|
||||
actorId: inventoryIdentity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
@ -101,6 +112,7 @@ export class ResourcePersistenceMapper {
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
values: revision.values.toPrimitive(),
|
||||
specification: revision.specification.toPrimitive(),
|
||||
position,
|
||||
})),
|
||||
};
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { ResourceSpecificationData } from "../../domain/resource-values";
|
||||
import { ResourceSpecificationSchema } from "../../domain/resource-values";
|
||||
|
||||
import { readResourceRows, writeResourceRow } from "./resource-relational-sql";
|
||||
|
||||
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
|
||||
const identity = z.object({ revisionId: z.uuid(), resourceId: z.uuid() });
|
||||
const headerSchema = identity.extend({
|
||||
templateId: z.uuid(),
|
||||
templateName: z.string(),
|
||||
sectionsDefined: bool,
|
||||
});
|
||||
const fieldSchema = identity.extend({
|
||||
fieldId: z.uuid(),
|
||||
name: z.string(),
|
||||
fieldType: z.enum(["text", "multiline", "number", "date", "boolean", "choice"]),
|
||||
unit: z.string(),
|
||||
required: bool,
|
||||
sectionId: z.uuid().nullable(),
|
||||
position: z.number().int(),
|
||||
});
|
||||
const sectionSchema = identity.extend({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
position: z.number().int(),
|
||||
});
|
||||
const choiceSchema = identity.extend({
|
||||
fieldId: z.uuid(),
|
||||
position: z.number().int(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
/** Se ejecuta antes de que una estructura viva pueda cambiar; las revisiones anteriores reciben su instantánea original. */
|
||||
export async function migrateResourceRevisionSpecifications(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 revision = {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
references: { model: "resource_revisions", key: "id" },
|
||||
onDelete: "CASCADE",
|
||||
};
|
||||
const parent = {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "resources", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
};
|
||||
const definitions = [
|
||||
[
|
||||
"resource_revision_specifications",
|
||||
{
|
||||
revisionId: revision,
|
||||
resourceId: parent,
|
||||
templateId: { type: DataTypes.UUID, allowNull: false },
|
||||
templateName: { type: DataTypes.STRING(100), allowNull: false },
|
||||
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"resource_revision_specification_sections",
|
||||
{
|
||||
revisionId: revision,
|
||||
resourceId: parent,
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"resource_revision_specification_fields",
|
||||
{
|
||||
revisionId: revision,
|
||||
resourceId: parent,
|
||||
fieldId: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
references: { model: "resource_definitions", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
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: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"resource_revision_specification_choices",
|
||||
{
|
||||
revisionId: revision,
|
||||
resourceId: parent,
|
||||
fieldId: { type: DataTypes.UUID, primaryKey: true },
|
||||
position: { type: DataTypes.INTEGER, primaryKey: true },
|
||||
value: { type: DataTypes.STRING(100), allowNull: false },
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
for (const [table, columns] of definitions) {
|
||||
if (!tables.some((item) => item.name === table)) {
|
||||
await query.createTable(table, columns);
|
||||
}
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
const missing = await database.query<{ id: string }>(
|
||||
"SELECT r.id FROM resource_revisions r LEFT JOIN resource_revision_specifications s ON s.revisionId=r.id WHERE s.revisionId IS NULL",
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < missing.length; offset += 500) {
|
||||
const ids = missing.slice(offset, offset + 500).map((item) => item.id);
|
||||
const options = { replacements: { ids }, transaction };
|
||||
|
||||
await database.query(
|
||||
"INSERT INTO resource_revision_specifications (revisionId,resourceId,templateId,templateName,sectionsDefined) SELECT r.id,a.id,a.templateId,a.templateName,a.sectionsDefined FROM resource_revisions r JOIN resources a ON a.id=r.resourceId WHERE r.id IN (:ids)",
|
||||
options
|
||||
);
|
||||
for (const [suffix, columns] of [
|
||||
["sections", "id,name,description,position"],
|
||||
["fields", "fieldId,name,fieldType,unit,required,sectionId,position"],
|
||||
["choices", "fieldId,position,value"],
|
||||
] as const) {
|
||||
const source =
|
||||
suffix === "choices"
|
||||
? "resource_specification_field_choices"
|
||||
: `resource_specification_${suffix}`;
|
||||
const selected = columns
|
||||
.split(",")
|
||||
.map((column) => `s.\`${column}\``)
|
||||
.join(",");
|
||||
|
||||
await database.query(
|
||||
`INSERT INTO resource_revision_specification_${suffix} (revisionId,resourceId,${columns
|
||||
.split(",")
|
||||
.map((column) => `\`${column}\``)
|
||||
.join(
|
||||
","
|
||||
)}) SELECT r.id,r.resourceId,${selected} FROM resource_revisions r JOIN ${source} s ON s.resourceId=r.resourceId WHERE r.id IN (:ids)`,
|
||||
options
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function readResourceRevisionSpecifications(
|
||||
database: Sequelize,
|
||||
ids: readonly string[],
|
||||
transaction: Transaction
|
||||
) {
|
||||
const headers = z
|
||||
.array(headerSchema)
|
||||
.parse(
|
||||
await readResourceRows(
|
||||
database,
|
||||
"resource_revision_specifications",
|
||||
"resourceId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
const fields = z
|
||||
.array(fieldSchema)
|
||||
.parse(
|
||||
await readResourceRows(
|
||||
database,
|
||||
"resource_revision_specification_fields",
|
||||
"resourceId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
const sections = z
|
||||
.array(sectionSchema)
|
||||
.parse(
|
||||
await readResourceRows(
|
||||
database,
|
||||
"resource_revision_specification_sections",
|
||||
"resourceId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
const choices = z
|
||||
.array(choiceSchema)
|
||||
.parse(
|
||||
await readResourceRows(
|
||||
database,
|
||||
"resource_revision_specification_choices",
|
||||
"resourceId",
|
||||
ids,
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
return new Map(
|
||||
headers.map((header) => [
|
||||
header.revisionId,
|
||||
ResourceSpecificationSchema.parse({
|
||||
templateId: header.templateId,
|
||||
templateName: header.templateName,
|
||||
...(header.sectionsDefined
|
||||
? {
|
||||
sections: sections
|
||||
.filter((item) => item.revisionId === header.revisionId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ id, name, description }) => ({ id, name, description })),
|
||||
}
|
||||
: {}),
|
||||
fields: fields
|
||||
.filter((item) => item.revisionId === header.revisionId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((field) => ({
|
||||
fieldId: field.fieldId,
|
||||
name: field.name,
|
||||
type: field.fieldType,
|
||||
unit: field.unit,
|
||||
required: field.required,
|
||||
...(field.sectionId ? { sectionId: field.sectionId } : {}),
|
||||
choices: choices
|
||||
.filter(
|
||||
(item) => item.revisionId === header.revisionId && item.fieldId === field.fieldId
|
||||
)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => item.value),
|
||||
})),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeResourceRevisionSpecification(
|
||||
database: Sequelize,
|
||||
resourceId: string,
|
||||
revisionId: string,
|
||||
specification: ResourceSpecificationData,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const identity = { resourceId, revisionId };
|
||||
|
||||
await writeResourceRow(
|
||||
database,
|
||||
"resource_revision_specifications",
|
||||
{
|
||||
...identity,
|
||||
templateId: specification.templateId,
|
||||
templateName: specification.templateName,
|
||||
sectionsDefined: specification.sections !== undefined,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
for (const [position, section] of (specification.sections ?? []).entries()) {
|
||||
await writeResourceRow(
|
||||
database,
|
||||
"resource_revision_specification_sections",
|
||||
{ ...identity, ...section, position },
|
||||
transaction
|
||||
);
|
||||
}
|
||||
for (const [position, field] of specification.fields.entries()) {
|
||||
await writeResourceRow(
|
||||
database,
|
||||
"resource_revision_specification_fields",
|
||||
{
|
||||
...identity,
|
||||
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 writeResourceRow(
|
||||
database,
|
||||
"resource_revision_specification_choices",
|
||||
{ ...identity, fieldId: field.fieldId, position: choicePosition, value },
|
||||
transaction
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,10 @@ import type { Sequelize, Transaction } from "sequelize";
|
||||
import { Op, QueryTypes } from "sequelize";
|
||||
|
||||
import type { IResourceCatalogRepository } from "../../application/resource-catalog.repository";
|
||||
import type { ResourceCatalog } from "../../domain/resource-catalog.aggregate";
|
||||
import { ResourceCatalogError } from "../../domain/resource-definition";
|
||||
|
||||
import { prepareResourceTemplateUpdates } from "./prepare-resource-template-updates";
|
||||
import {
|
||||
ResourceAvailabilityModel,
|
||||
ResourceBindingModel,
|
||||
@ -14,6 +16,8 @@ import {
|
||||
} from "./resource-catalog.model";
|
||||
import type { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence.mapper";
|
||||
import { readDefinitions, writeDefinition } from "./resource-catalog-relational";
|
||||
import { writeInventory } from "./resource-inventory-relational";
|
||||
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
|
||||
|
||||
export class SequelizeResourceCatalogRepository implements IResourceCatalogRepository {
|
||||
constructor(
|
||||
@ -63,7 +67,40 @@ export class SequelizeResourceCatalogRepository implements IResourceCatalogRepos
|
||||
return this.database.transaction((transaction) => this.load(scope, transaction, false));
|
||||
}
|
||||
|
||||
update(scope: Maybe<UniqueID>, action: Parameters<IResourceCatalogRepository["update"]>[1]) {
|
||||
private async persistCatalog(
|
||||
previous: ResourceCatalog,
|
||||
next: ResourceCatalog,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const rows = this.mapper.toPersistence(next);
|
||||
|
||||
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
|
||||
for (const row of rows.definitions) {
|
||||
await writeDefinition(this.database, row, transaction);
|
||||
}
|
||||
const previousBindings = previous.definitions.flatMap((item) => item.bindings);
|
||||
const retained = new Set(rows.bindings.map((row) => row.id));
|
||||
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
|
||||
|
||||
if (removed.length) {
|
||||
await ResourceBindingModel.destroy({
|
||||
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const row of rows.bindings) {
|
||||
await ResourceBindingModel.upsert(row, { transaction });
|
||||
}
|
||||
for (const row of rows.availability) {
|
||||
await ResourceAvailabilityModel.upsert(row, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
update(
|
||||
scope: Maybe<UniqueID>,
|
||||
action: Parameters<IResourceCatalogRepository["update"]>[1],
|
||||
actorId?: UniqueID
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(scope, transaction, true);
|
||||
|
||||
@ -75,27 +112,28 @@ export class SequelizeResourceCatalogRepository implements IResourceCatalogRepos
|
||||
if (!changed.isSuccess || changed.data === loaded.data) {
|
||||
return changed;
|
||||
}
|
||||
const rows = this.mapper.toPersistence(changed.data);
|
||||
const propagated = await prepareResourceTemplateUpdates(
|
||||
this.database,
|
||||
loaded.data,
|
||||
changed.data,
|
||||
actorId,
|
||||
transaction
|
||||
);
|
||||
|
||||
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
|
||||
for (const row of rows.definitions) {
|
||||
await writeDefinition(this.database, row, transaction);
|
||||
if (!propagated.isSuccess) {
|
||||
return Result.fail(propagated.error);
|
||||
}
|
||||
const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings);
|
||||
const retained = new Set(rows.bindings.map((row) => row.id));
|
||||
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
|
||||
await this.persistCatalog(loaded.data, changed.data, transaction);
|
||||
|
||||
if (removed.length) {
|
||||
await ResourceBindingModel.destroy({
|
||||
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const row of rows.bindings) {
|
||||
await ResourceBindingModel.upsert(row, { transaction });
|
||||
}
|
||||
for (const row of rows.availability) {
|
||||
await ResourceAvailabilityModel.upsert(row, { transaction });
|
||||
const inventoryMapper = new ResourcePersistenceMapper();
|
||||
|
||||
for (const update of propagated.data) {
|
||||
await writeInventory(
|
||||
this.database,
|
||||
inventoryMapper.toPersistence(update.next),
|
||||
new Set(update.previous.history.map((revision) => revision.id.toPrimitive())),
|
||||
transaction
|
||||
);
|
||||
}
|
||||
|
||||
return changed;
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { ResourceCatalogPersistenceMapper } from "./infrastructure/persistence/resource-catalog-persistence.mapper";
|
||||
import { ResourceDashboardReader } from "./infrastructure/persistence/resource-dashboard-reader";
|
||||
import { enforceResourceConstraints } from "./infrastructure/persistence/resource-relational-schema";
|
||||
import { migrateResourceRevisionSpecifications } from "./infrastructure/persistence/resource-revision-specifications";
|
||||
import { SequelizeResourceRepository } from "./infrastructure/persistence/sequelize-resource.repository";
|
||||
import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence/sequelize-resource-catalog.repository";
|
||||
|
||||
@ -40,6 +41,7 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
||||
throw new Error("No se pudo inicializar el catálogo de recursos.");
|
||||
}
|
||||
await migrateInventory(database);
|
||||
await migrateResourceRevisionSpecifications(database);
|
||||
await enforceResourceConstraints(database);
|
||||
await migrateSupportResources(database);
|
||||
const catalog = new ResourceCatalogUseCases(repository);
|
||||
|
||||
@ -23,6 +23,7 @@ export const DeleteResourceResponseSchema = z.strictObject({ deleted: z.literal(
|
||||
export const ResourceRevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: ResourceInputSchema,
|
||||
specification: ResourceSpecificationSchema.optional(),
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"noValue": "—",
|
||||
"history": "History",
|
||||
"recordedBy": "Recorded by {{name}}",
|
||||
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
|
||||
"snapshotNotice": "Template changes update this record. Removed fields with values are retained, and history keeps its original structure.",
|
||||
"resourceDescription": "Register a mailbox, shared folder, application, certificate, software license or assistance tool for this company.",
|
||||
"resourceExamples": "Examples: admin@company.com, Administration folder, corporate Zoom, signing certificate or Microsoft 365 subscription.",
|
||||
"resourceGuidance": "Characteristics are optional unless the template requires them. Never store passwords or private keys.",
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"noValue": "—",
|
||||
"history": "Historial",
|
||||
"recordedBy": "Registrado por {{name}}",
|
||||
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
|
||||
"snapshotNotice": "Los cambios de la plantilla actualizan esta ficha. Los campos eliminados que tienen valor se conservan y el historial mantiene su estructura original.",
|
||||
"resourceDescription": "Registra un buzón, carpeta compartida, aplicación, certificado, licencia de software o herramienta de asistencia de esta compañía.",
|
||||
"resourceExamples": "Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo, certificado de firma o suscripción Microsoft 365.",
|
||||
"resourceGuidance": "Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.",
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { ResourceDefinitionSpec, SaveDefinitionResponseDTO } from "../../../../common";
|
||||
import { inventoryKeys } from "../../../resources/shared/api";
|
||||
import { catalogApi, catalogKeys } from "../api";
|
||||
|
||||
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
|
||||
@ -23,6 +24,9 @@ export function useCatalogSave(scope: string, onSaved: CatalogSaved, id?: string
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ["resource-catalog"] }),
|
||||
client.invalidateQueries({
|
||||
queryKey: scope === "global" ? ["resource-inventory"] : inventoryKeys.company(scope),
|
||||
}),
|
||||
client.invalidateQueries({ queryKey: ["asset-support"] }),
|
||||
]);
|
||||
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
|
||||
|
||||
@ -83,7 +83,7 @@ export function ResourceDetail({
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<FieldSections
|
||||
items={resource.specification.fields}
|
||||
items={(revision.specification ?? resource.specification).fields}
|
||||
renderItem={(field) => (
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-muted-foreground">{field.name}</dt>
|
||||
@ -92,7 +92,7 @@ export function ResourceDetail({
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
sections={resource.specification.sections}
|
||||
sections={(revision.specification ?? resource.specification).sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/entity-column-visibility.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
|
||||
@ -56,6 +56,7 @@ export type EntityColumn<T> = {
|
||||
value?: (item: T) => string | number | boolean | null | undefined;
|
||||
defaultSort?: "asc" | "desc";
|
||||
hideable?: boolean;
|
||||
defaultVisible?: boolean;
|
||||
};
|
||||
|
||||
export function EntityTable<T extends { id: string }>({
|
||||
@ -71,6 +72,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
filtersApplied = false,
|
||||
onClearFilters,
|
||||
filterKey = "",
|
||||
columnVisibilityStorageKey,
|
||||
loading = false,
|
||||
unavailable = false,
|
||||
emptyMessage,
|
||||
@ -90,6 +92,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
filtersApplied?: boolean;
|
||||
onClearFilters?: () => void;
|
||||
filterKey?: string;
|
||||
columnVisibilityStorageKey?: string | undefined;
|
||||
loading?: boolean;
|
||||
unavailable?: boolean;
|
||||
emptyMessage?: string;
|
||||
@ -100,7 +103,14 @@ export function EntityTable<T extends { id: string }>({
|
||||
const id = useId();
|
||||
const labels = entityListLabels(locale);
|
||||
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
|
||||
useEntityTable({ items, columns, search, onSearchChange, filterKey });
|
||||
useEntityTable({
|
||||
items,
|
||||
columns,
|
||||
search,
|
||||
onSearchChange,
|
||||
filterKey,
|
||||
columnVisibilityStorageKey,
|
||||
});
|
||||
const info = entityTableInfo(
|
||||
loading,
|
||||
unavailable,
|
||||
|
||||
153
packages/rdx-ui/src/entity-column-visibility.test.tsx
Normal file
153
packages/rdx-ui/src/entity-column-visibility.test.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { EntityTable } from "./components/entity-list";
|
||||
import {
|
||||
readColumnVisibility,
|
||||
writeColumnVisibility,
|
||||
} from "./lib/entity-column-visibility-storage";
|
||||
|
||||
function withStorage(run: (values: Map<string, string>) => void) {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const values = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
run(values);
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, "window", original);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("las columnas elegidas se restauran al volver a crear la tabla, incluidas las que llegan después", () => {
|
||||
withStorage((storage) => {
|
||||
writeColumnVisibility("assets", {
|
||||
detail: true,
|
||||
later: true,
|
||||
optional: false,
|
||||
name: false,
|
||||
actions: false,
|
||||
});
|
||||
|
||||
const render = (includeLater: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
<EntityTable
|
||||
columns={[
|
||||
{ id: "name", label: "Nombre", hideable: false, render: () => "Equipo" },
|
||||
{ id: "detail", label: "Detalle", defaultVisible: false, render: () => "Dato elegido" },
|
||||
{ id: "optional", label: "Optativa", render: () => "Dato oculto" },
|
||||
...(includeLater
|
||||
? [
|
||||
{
|
||||
id: "later",
|
||||
label: "Campo posterior",
|
||||
defaultVisible: false,
|
||||
render: () => "Columna asíncrona",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "actions",
|
||||
label: "Acciones",
|
||||
render: () => <button type="button">Editar</button>,
|
||||
},
|
||||
]}
|
||||
columnVisibilityStorageKey="assets"
|
||||
items={[{ id: "one" }]}
|
||||
title="Activos"
|
||||
/>
|
||||
);
|
||||
|
||||
const initial = render(false);
|
||||
const restored = render(true);
|
||||
|
||||
assert.ok(initial.includes("Dato elegido"));
|
||||
assert.ok(restored.includes("Dato elegido"));
|
||||
assert.ok(restored.includes("Columna asíncrona"));
|
||||
assert.ok(restored.includes("Equipo"));
|
||||
assert.ok(restored.includes("Editar"));
|
||||
assert.equal(restored.includes("Dato oculto"), false);
|
||||
assert.equal(readColumnVisibility("assets").later, true);
|
||||
assert.equal(storage.has("entity-page-size"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("las preferencias quedan aisladas por lista y no se aplican cuando no se solicita persistencia", () => {
|
||||
withStorage(() => {
|
||||
writeColumnVisibility("assets", { detail: true });
|
||||
writeColumnVisibility("picker", { detail: false });
|
||||
|
||||
assert.deepEqual(readColumnVisibility("assets"), { detail: true });
|
||||
assert.deepEqual(readColumnVisibility("picker"), { detail: false });
|
||||
assert.deepEqual(readColumnVisibility(undefined), {});
|
||||
const html = renderToStaticMarkup(
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "detail",
|
||||
label: "Detalle",
|
||||
defaultVisible: false,
|
||||
render: () => "Preferencia ajena",
|
||||
},
|
||||
]}
|
||||
items={[{ id: "one" }]}
|
||||
title="Selector"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.equal(html.includes("Preferencia ajena"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("datos locales inválidos no rompen el listado y solo se aceptan valores booleanos", () => {
|
||||
withStorage((storage) => {
|
||||
for (const serialized of ["{invalid", "[]", "null", '"text"', "42"]) {
|
||||
storage.set("assets", serialized);
|
||||
assert.deepEqual(readColumnVisibility("assets"), {});
|
||||
}
|
||||
storage.set("assets", '{"detail":true,"optional":false,"text":"true","object":{},"array":[]}');
|
||||
assert.deepEqual(readColumnVisibility("assets"), { detail: true, optional: false });
|
||||
});
|
||||
});
|
||||
|
||||
test("almacenamiento bloqueado o sin cuota permite seguir utilizando la selección local", () => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
throw new Error("Storage blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("Quota exceeded");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(readColumnVisibility("assets"), {});
|
||||
assert.doesNotThrow(() => writeColumnVisibility("assets", { detail: true }));
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, "window", original);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -368,3 +368,40 @@ test("las celdas vacías muestran un guion largo en es/en y conservan cero, text
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("las columnas opcionales empiezan ocultas sin ocultar identificación ni acciones obligatorias", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: "Nombre",
|
||||
hideable: false,
|
||||
defaultVisible: false,
|
||||
value: (item) => item.name,
|
||||
render: (item) => item.name,
|
||||
},
|
||||
{
|
||||
id: "detail",
|
||||
label: "Detalle opcional",
|
||||
defaultVisible: false,
|
||||
value: (item) => item.detail,
|
||||
render: (item) => item.detail,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "Acciones",
|
||||
defaultVisible: false,
|
||||
render: () => <button type="button">Editar</button>,
|
||||
},
|
||||
]}
|
||||
items={[{ id: "one", name: "Equipo", detail: "Información adicional" }]}
|
||||
title="Activos"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Equipo"));
|
||||
assert.ok(html.includes("Editar"));
|
||||
assert.equal(html.includes("Información adicional"), false);
|
||||
assert.equal(html.includes("Detalle opcional"), false);
|
||||
});
|
||||
|
||||
@ -14,6 +14,10 @@ import {
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { EntityColumn } from "../components/entity-list";
|
||||
import {
|
||||
readColumnVisibility,
|
||||
writeColumnVisibility,
|
||||
} from "../lib/entity-column-visibility-storage";
|
||||
import { ENTITY_PAGE_SIZES, entityListRange } from "../lib/entity-list";
|
||||
|
||||
const ENTITY_PAGE_SIZE_STORAGE_KEY = "entity-page-size";
|
||||
@ -37,12 +41,14 @@ export function useEntityTable<T extends { id: string }>({
|
||||
search,
|
||||
onSearchChange,
|
||||
filterKey,
|
||||
columnVisibilityStorageKey,
|
||||
}: {
|
||||
items: readonly T[];
|
||||
columns: readonly EntityColumn<T>[];
|
||||
search: string | undefined;
|
||||
onSearchChange: ((value: string) => void) | undefined;
|
||||
filterKey: string;
|
||||
columnVisibilityStorageKey?: string | undefined;
|
||||
}) {
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const currentSearch = search ?? localSearch;
|
||||
@ -52,7 +58,20 @@ export function useEntityTable<T extends { id: string }>({
|
||||
? [{ id: defaultColumn.id, desc: defaultColumn.defaultSort === "desc" }]
|
||||
: [];
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [visibility, setVisibility] = useState<VisibilityState>({});
|
||||
const [visibility, setVisibility] = useState<VisibilityState>(() =>
|
||||
readColumnVisibility(columnVisibilityStorageKey)
|
||||
);
|
||||
const [previousStorageKey, setPreviousStorageKey] = useState(columnVisibilityStorageKey);
|
||||
|
||||
if (previousStorageKey !== columnVisibilityStorageKey) {
|
||||
setPreviousStorageKey(columnVisibilityStorageKey);
|
||||
setVisibility(readColumnVisibility(columnVisibilityStorageKey));
|
||||
}
|
||||
useEffect(() => {
|
||||
if (previousStorageKey === columnVisibilityStorageKey) {
|
||||
writeColumnVisibility(columnVisibilityStorageKey, visibility);
|
||||
}
|
||||
}, [columnVisibilityStorageKey, previousStorageKey, visibility]);
|
||||
const [pagination, setPagination] = useState<PaginationState>(() => ({
|
||||
pageIndex: 0,
|
||||
pageSize: storedPageSize(),
|
||||
@ -92,7 +111,14 @@ export function useEntityTable<T extends { id: string }>({
|
||||
getRowId: (item) => item.id,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility: visibility,
|
||||
columnVisibility: Object.fromEntries(
|
||||
columns.map((column) => [
|
||||
column.id,
|
||||
column.hideable === false || column.id === "actions"
|
||||
? true
|
||||
: (visibility[column.id] ?? column.defaultVisible ?? true),
|
||||
])
|
||||
),
|
||||
columnPinning: {
|
||||
right: columns.filter((column) => column.id === "actions").map((column) => column.id),
|
||||
},
|
||||
|
||||
36
packages/rdx-ui/src/lib/entity-column-visibility-storage.ts
Normal file
36
packages/rdx-ui/src/lib/entity-column-visibility-storage.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { VisibilityState } from "@tanstack/react-table";
|
||||
|
||||
/** Solo se conservan preferencias booleanas; nunca datos de las filas o etiquetas traducidas. */
|
||||
export function readColumnVisibility(storageKey: string | undefined): VisibilityState {
|
||||
if (!storageKey || typeof window === "undefined") {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const serialized = window.localStorage.getItem(storageKey);
|
||||
const parsed: unknown = serialized ? JSON.parse(serialized) : null;
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, boolean] => typeof entry[1] === "boolean"
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Las preferencias son opcionales si el navegador bloquea el almacenamiento o contiene datos inválidos.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeColumnVisibility(storageKey: string | undefined, visibility: VisibilityState) {
|
||||
if (!storageKey || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(visibility));
|
||||
} catch {
|
||||
// La selección local sigue operativa aunque no pueda persistirse.
|
||||
}
|
||||
}
|
||||
@ -26,3 +26,5 @@ export class Maybe<T> {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export { synchronizeTemplateStructure } from "./synchronize-template-structure";
|
||||
|
||||
59
packages/rdx-utils/src/synchronize-template-structure.ts
Normal file
59
packages/rdx-utils/src/synchronize-template-structure.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/** Conserva valores retirados sin mezclar identidades ni reconstruirlos a partir del nombre. */
|
||||
export function synchronizeTemplateStructure<
|
||||
Field extends { fieldId: string; sectionId?: string | undefined; required: boolean },
|
||||
Section extends { id: string; name: string },
|
||||
>({
|
||||
previous,
|
||||
next,
|
||||
values,
|
||||
protectedFieldIds = [],
|
||||
}: {
|
||||
previous: { fields: readonly Field[]; sections?: readonly Section[] | undefined };
|
||||
next: { fields: readonly Field[]; sections?: readonly Section[] | undefined };
|
||||
values: Readonly<Record<string, unknown>>;
|
||||
protectedFieldIds?: readonly string[];
|
||||
}) {
|
||||
const hasValue = (id: string) =>
|
||||
protectedFieldIds.includes(id) ||
|
||||
(values[id] !== undefined &&
|
||||
values[id] !== null &&
|
||||
(typeof values[id] !== "string" || values[id].trim() !== ""));
|
||||
|
||||
const nextIds = new Set(next.fields.map((field) => field.fieldId));
|
||||
const sections = [...(next.sections ?? [])];
|
||||
const sectionIds = new Set(sections.map((section) => section.id));
|
||||
const fields = next.fields.map((field) => {
|
||||
const old = previous.fields.find((item) => item.fieldId === field.fieldId);
|
||||
|
||||
return !field.sectionId &&
|
||||
old?.sectionId &&
|
||||
!sectionIds.has(old.sectionId) &&
|
||||
hasValue(field.fieldId)
|
||||
? { ...field, sectionId: old.sectionId }
|
||||
: field;
|
||||
});
|
||||
|
||||
fields.push(
|
||||
...previous.fields
|
||||
.filter((field) => !nextIds.has(field.fieldId) && hasValue(field.fieldId))
|
||||
.map((field) => ({ ...field, required: false }))
|
||||
);
|
||||
for (const section of previous.sections ?? []) {
|
||||
if (!sectionIds.has(section.id) && fields.some((field) => field.sectionId === section.id)) {
|
||||
let name = section.name;
|
||||
let ordinal = 2;
|
||||
|
||||
while (
|
||||
sections.some((item) => item.name.trim().toLowerCase() === name.trim().toLowerCase())
|
||||
) {
|
||||
const suffix = ` (${ordinal++})`;
|
||||
|
||||
name = `${section.name.slice(0, 100 - suffix.length)}${suffix}`;
|
||||
}
|
||||
sections.push({ ...section, name });
|
||||
sectionIds.add(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, sections };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user