feat: update asset status handling and improve UI labels
- Changed asset status label from "status" to "technicalStatus" in AssetForm component. - Added help text for technical status in AssetForm. - Updated test cases to reflect changes in asset status from "available" to "operational". - Enhanced workstation form to utilize new asset status checks and display appropriate messages. - Refactored asset context to replace "available" with "notRetired" across various application policies and repositories. - Introduced new utility functions for asset status management, including technical status determination and assignment validation. - Added integration tests for asset state verification and unlinking assets from workstations.
This commit is contained in:
parent
bf02e56d76
commit
002781b56d
215
apps/server/src/asset-states.integration.ts
Normal file
215
apps/server/src/asset-states.integration.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { AssetInput } from "@metis/assets/common";
|
||||
import {
|
||||
AssetResponseSchema,
|
||||
InventoryResponseSchema,
|
||||
WorkstationResponseSchema,
|
||||
} from "@metis/assets/common";
|
||||
import {
|
||||
SoftwareInstallationResponseSchema,
|
||||
SoftwareInstallationsResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import {
|
||||
CatalogResponseSchema as ResourceCatalogSchema,
|
||||
ResourceResponseSchema,
|
||||
} from "@metis/resources/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { enforceAssetRelationalConstraints } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relational-constraints";
|
||||
|
||||
type Request = (path: string, body?: unknown) => Promise<Response>;
|
||||
|
||||
export async function verifyAssetStates(request: Request, companyId: string, database: Sequelize) {
|
||||
const path = `/assets/${companyId}`;
|
||||
const source = InventoryResponseSchema.parse(await (await request(path)).json()).assets[0];
|
||||
|
||||
assert.ok(source);
|
||||
const response = await request(path, {
|
||||
values: { ...source.values, status: "operational", name: "Estado técnico prueba" },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
const original = AssetResponseSchema.parse(await response.json()).asset;
|
||||
|
||||
const current = async () => {
|
||||
const asset = InventoryResponseSchema.parse(await (await request(path)).json()).assets.find(
|
||||
(item) => item.id === original.id
|
||||
);
|
||||
|
||||
assert.ok(asset);
|
||||
|
||||
return asset;
|
||||
};
|
||||
|
||||
const state = async (status: AssetInput["status"]) => {
|
||||
const previous = await current();
|
||||
const result = await request(`${path}/${original.id}/update`, {
|
||||
values: { ...previous.values, status },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 200, await result.clone().text());
|
||||
|
||||
return AssetResponseSchema.parse(await result.json()).asset;
|
||||
};
|
||||
|
||||
const stationValues = {
|
||||
name: "Uso compartido prueba",
|
||||
active: true,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
assetIds: [] as string[],
|
||||
sharedAssetIds: [original.id],
|
||||
};
|
||||
|
||||
const station = async (name: string) => {
|
||||
const result = await request(`${path}/workstations`, { values: { ...stationValues, name } });
|
||||
|
||||
assert.equal(result.status, 201, await result.clone().text());
|
||||
|
||||
return WorkstationResponseSchema.parse(await result.json()).workstation;
|
||||
};
|
||||
|
||||
assert.equal(original.displayStatus, "available");
|
||||
assert.equal(original.assigned, false);
|
||||
const first = await station("Compartido A");
|
||||
const second = await station("Compartido B");
|
||||
const assigned = await current();
|
||||
|
||||
assert.equal(assigned.values.status, "operational");
|
||||
assert.equal(assigned.displayStatus, "in_use");
|
||||
assert.equal(assigned.assigned, true);
|
||||
assert.equal(assigned.history.length, 1);
|
||||
const repairing = await state("maintenance");
|
||||
|
||||
assert.equal(repairing.displayStatus, "maintenance");
|
||||
assert.equal(repairing.assigned, true);
|
||||
const resourceCatalog = ResourceCatalogSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
);
|
||||
const osTemplate = resourceCatalog.items.find((item) => item.builtinKey === "template:os");
|
||||
|
||||
assert.ok(osTemplate);
|
||||
const osResponse = await request(`/resources/${companyId}`, {
|
||||
values: {
|
||||
name: "SO durante reparación",
|
||||
templateId: osTemplate.id,
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(osResponse.status, 201);
|
||||
const os = ResourceResponseSchema.parse(await osResponse.json()).resource;
|
||||
const installationResponse = await request(`/software-installations/${companyId}`, {
|
||||
values: {
|
||||
assetId: original.id,
|
||||
resourceId: os.id,
|
||||
licenseResourceId: null,
|
||||
version: "24H2",
|
||||
state: "configured",
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(installationResponse.status, 201, await installationResponse.clone().text());
|
||||
const installation = SoftwareInstallationResponseSchema.parse(
|
||||
await installationResponse.json()
|
||||
).configuration;
|
||||
|
||||
assert.equal(installation.needsReview, false);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${path}/workstations/${first.id}/update`, {
|
||||
values: { ...first.values, name: "Puesto durante reparación" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
for (const status of ["maintenance", "broken"] as const) {
|
||||
await state(status);
|
||||
const blocked = await request(`${path}/workstations`, {
|
||||
values: { ...stationValues, name: "Asignación no permitida" },
|
||||
});
|
||||
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(
|
||||
z.object({ code: z.string() }).parse(await blocked.json()).code,
|
||||
"ASSET_NOT_OPERATIONAL"
|
||||
);
|
||||
}
|
||||
const retiredWhileAssigned = await request(`${path}/${original.id}/update`, {
|
||||
values: { ...(await current()).values, status: "retired" },
|
||||
});
|
||||
|
||||
assert.equal(retiredWhileAssigned.status, 409);
|
||||
assert.equal(
|
||||
z.object({ code: z.string() }).parse(await retiredWhileAssigned.json()).code,
|
||||
"ASSET_ASSIGNED"
|
||||
);
|
||||
assert.equal((await current()).values.status, "broken");
|
||||
await state("operational");
|
||||
assert.equal((await request(`${path}/workstations/${first.id}/decommission`, {})).status, 200);
|
||||
assert.equal((await current()).displayStatus, "in_use");
|
||||
assert.equal((await request(`${path}/workstations/${second.id}/delete`, {})).status, 200);
|
||||
assert.equal((await current()).displayStatus, "available");
|
||||
assert.equal((await current()).assigned, false);
|
||||
const retired = await state("retired");
|
||||
|
||||
assert.equal(retired.displayStatus, "retired");
|
||||
const installations = SoftwareInstallationsResponseSchema.parse(
|
||||
await (await request(`/software-installations/${companyId}`)).json()
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
installations.configurations.find((item) => item.id === installation.id)?.needsReview,
|
||||
true
|
||||
);
|
||||
assert.equal((await request(`${path}/workstations`, { values: stationValues })).status, 409);
|
||||
for (const status of ["in_use", "available"]) {
|
||||
assert.equal(
|
||||
(await request(`${path}/${original.id}/update`, { values: { ...retired.values, status } }))
|
||||
.status,
|
||||
400
|
||||
);
|
||||
}
|
||||
// Datos históricos originales: no se convierten en asignaciones ficticias ni se reescriben.
|
||||
await state("operational");
|
||||
const before = await current();
|
||||
const revisionId = before.history.at(-1)?.id;
|
||||
|
||||
assert.ok(revisionId);
|
||||
await database.transaction(async (transaction) => {
|
||||
await database.query("UPDATE assets SET status='in_use' WHERE id=:id", {
|
||||
replacements: { id: original.id },
|
||||
transaction,
|
||||
});
|
||||
await database.query("UPDATE asset_revisions SET status='in_use' WHERE id=:id", {
|
||||
replacements: { id: revisionId },
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
await enforceAssetRelationalConstraints(database);
|
||||
const historical = await current();
|
||||
|
||||
assert.equal(historical.values.status, "operational");
|
||||
assert.equal(historical.displayStatus, "available");
|
||||
assert.equal(historical.history.at(-1)?.values.status, "in_use");
|
||||
const repeated = await request(`${path}/${original.id}/update`, { values: historical.values });
|
||||
|
||||
assert.equal(repeated.status, 200);
|
||||
assert.deepEqual(
|
||||
AssetResponseSchema.parse(await repeated.json()).asset.history,
|
||||
historical.history
|
||||
);
|
||||
const stored = await database.query<{ status: string }>(
|
||||
"SELECT status FROM asset_revisions WHERE id=:id",
|
||||
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
assert.equal(stored[0]?.status, "in_use");
|
||||
}
|
||||
@ -152,7 +152,7 @@ export async function verifyBrands(
|
||||
name: "PORT-MARCA",
|
||||
templateId: template.id,
|
||||
siteId: null,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
brandId: created.id,
|
||||
characteristics: { [field.id]: "Nombre manipulado" },
|
||||
|
||||
@ -27,6 +27,7 @@ import { migrateInterventionTemplates } from "../../../modules/companies/src/api
|
||||
import { verifyAccess } from "./access.integration";
|
||||
import { createApp } from "./app";
|
||||
import { verifyApplications } from "./applications.integration";
|
||||
import { verifyAssetStates } from "./asset-states.integration";
|
||||
import { verifyAssets } from "./assets.integration";
|
||||
import { verifyBrands } from "./brands.integration";
|
||||
import { verifyCertificates } from "./certificates.integration";
|
||||
@ -1007,6 +1008,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyAssetStates((...args) => operator.request(...args), customer.id, database);
|
||||
const countAfterAccess = await database.query<{ total: number }>(
|
||||
"SELECT COUNT(*) AS total FROM users",
|
||||
{ type: QueryTypes.SELECT }
|
||||
|
||||
@ -74,7 +74,7 @@ export async function verifyInventory(
|
||||
name: "MON-01",
|
||||
templateId: monitor.id,
|
||||
siteId: null,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
@ -111,7 +111,7 @@ export async function verifyInventory(
|
||||
const inUse = await save(`${path}/${created.id}/update`, {
|
||||
...input,
|
||||
siteId: ownSite.id,
|
||||
status: "in_use",
|
||||
status: "operational",
|
||||
characteristics: { [inch.id]: "24 pulgadas" },
|
||||
});
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ export async function verifyMail(
|
||||
for (const asset of [first, second]) {
|
||||
if (asset.values.status === "retired") {
|
||||
const response = await request(`/assets/${companyId}/${asset.id}/update`, {
|
||||
values: { ...asset.values, status: "available" },
|
||||
values: { ...asset.values, status: "operational" },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
|
||||
@ -39,6 +39,26 @@ export async function verifyPrinters(
|
||||
const host = inventory.assets.find((item) => item.values.status !== "retired");
|
||||
|
||||
assert.ok(host);
|
||||
const station = inventory.workstations.find(
|
||||
(item) => item.values.active && item.values.assetIds.includes(host.id)
|
||||
);
|
||||
const mainValues = station
|
||||
? { ...station.values, computerAssetId: host.id }
|
||||
: {
|
||||
name: "Puesto impresoras",
|
||||
active: true,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
computerAssetId: host.id,
|
||||
assetIds: [host.id],
|
||||
sharedAssetIds: [],
|
||||
};
|
||||
const mainResponse = await request(
|
||||
`/assets/${companyId}/workstations${station ? `/${station.id}/update` : ""}`,
|
||||
{ values: mainValues }
|
||||
);
|
||||
|
||||
assert.equal(mainResponse.status, station ? 200 : 201, await mainResponse.clone().text());
|
||||
const catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/asset-catalog/${companyId}`)).json()
|
||||
);
|
||||
@ -54,7 +74,7 @@ export async function verifyPrinters(
|
||||
values: {
|
||||
name,
|
||||
templateId: template.id,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
siteId: null,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
|
||||
@ -41,7 +41,7 @@ export async function verifyRelationships(
|
||||
values: {
|
||||
name: "MON-Foreign",
|
||||
templateId: template.id,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
siteId: null,
|
||||
characteristics: {},
|
||||
notes: "",
|
||||
|
||||
@ -13,6 +13,8 @@ import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { unlinkAssetFromWorkstations } from "./unlink-asset.integration-helper";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
|
||||
export async function verifyRemoteConnections(
|
||||
@ -212,6 +214,7 @@ export async function verifyRemoteConnections(
|
||||
);
|
||||
assert.equal((await current(mobile.id)).needsReview, true);
|
||||
assert.equal((await current(created.id)).needsReview, false);
|
||||
await unlinkAssetFromWorkstations(request, companyId, host.id);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/assets/${companyId}/${host.id}/update`, {
|
||||
|
||||
@ -15,6 +15,7 @@ import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { verifySupportResourceMigration } from "./support-resource-migration.integration";
|
||||
import { unlinkAssetFromWorkstations } from "./unlink-asset.integration-helper";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
|
||||
@ -232,6 +233,7 @@ export async function verifySupport(
|
||||
|
||||
assert.equal(listed.connections.length, 2);
|
||||
assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1);
|
||||
await unlinkAssetFromWorkstations(request, companyId, host.id);
|
||||
const retired = await request(`/assets/${companyId}/${host.id}/update`, {
|
||||
values: { ...host.values, status: "retired" },
|
||||
});
|
||||
|
||||
33
apps/server/src/unlink-asset.integration-helper.ts
Normal file
33
apps/server/src/unlink-asset.integration-helper.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { InventoryResponseSchema } from "@metis/assets/common";
|
||||
|
||||
type Request = (path: string, body?: unknown) => Promise<Response>;
|
||||
|
||||
export async function unlinkAssetFromWorkstations(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
assetId: string
|
||||
) {
|
||||
const inventory = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
);
|
||||
|
||||
for (const station of inventory.workstations.filter(
|
||||
(item) =>
|
||||
item.values.active &&
|
||||
[...item.values.assetIds, ...item.values.sharedAssetIds].includes(assetId)
|
||||
)) {
|
||||
const response = await request(`/assets/${companyId}/workstations/${station.id}/update`, {
|
||||
values: {
|
||||
...station.values,
|
||||
computerAssetId:
|
||||
station.values.computerAssetId === assetId ? null : station.values.computerAssetId,
|
||||
assetIds: station.values.assetIds.filter((id) => id !== assetId),
|
||||
sharedAssetIds: station.values.sharedAssetIds.filter((id) => id !== assetId),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ export async function verifyWorkProfiles(
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/assets/${companyId}/${item.id}/update`, {
|
||||
values: { ...item.values, status: "available" },
|
||||
values: { ...item.values, status: "operational" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
|
||||
@ -34,7 +34,7 @@ export async function verifyWorkstationLifecycle(
|
||||
|
||||
for (const name of ["Equipo de baja", "Impresora compartida de baja"]) {
|
||||
const response = await request(`/assets/${companyId}`, {
|
||||
values: { ...source.values, name, status: "available" },
|
||||
values: { ...source.values, name, status: "operational" },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
|
||||
@ -25,6 +25,17 @@ export async function verifyWorkstations(
|
||||
const inventory = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
);
|
||||
|
||||
for (const asset of inventory.assets.filter((item) => item.values.status !== "operational")) {
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/assets/${companyId}/${asset.id}/update`, {
|
||||
values: { ...asset.values, status: "operational" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
}
|
||||
const foreign = InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json())
|
||||
.assets[0];
|
||||
const first = inventory.assets[0];
|
||||
|
||||
@ -76,7 +76,7 @@ export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((
|
||||
name,
|
||||
templateId,
|
||||
siteId: workspaceCompany.sites[0]?.id ?? null,
|
||||
status: index === 0 ? "in_use" : "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: { [fieldId]: "Lenovo" },
|
||||
};
|
||||
@ -84,6 +84,8 @@ export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((
|
||||
return {
|
||||
id: `0199b200-0000-7000-8000-00000000000${index + 7}`,
|
||||
companyId: workspaceCompany.id,
|
||||
assigned: false,
|
||||
displayStatus: "available",
|
||||
specification: {
|
||||
templateId,
|
||||
templateName: "Portátil",
|
||||
|
||||
@ -6,7 +6,7 @@ La ficha de compañía incorpora la sección Activos. El listado permite buscar
|
||||
|
||||
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
||||
|
||||
Estados: Disponible, En uso, En mantenimiento y Retirado. El estado se registra de forma explícita: un equipo compartido puede estar en uso sin una persona única. Para retirarlo puede conservar una sede y todo su historial. La asignación a una persona se consulta a través de los puestos activos. No hay borrado de activos ni transferencia entre compañías.
|
||||
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura como exclusivo o compartido en uno o varios puestos activos. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
||||
|
||||
La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk.
|
||||
|
||||
@ -55,3 +55,18 @@ El campo de serie Marca se selecciona desde el [catálogo global de marcas](cata
|
||||
## Persistencia relacional
|
||||
|
||||
Los puestos de trabajo y la asistencia técnica se almacenan también de forma relacional, conservando sus revisiones. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
||||
|
||||
|
||||
## Estado técnico, asignación y elegibilidad
|
||||
|
||||
`values.status` en la API representa el estado técnico. `assigned` y `displayStatus` son datos calculados al construir las respuestas de alta, edición y consulta; no se persisten como columnas. Listados, búsqueda, ordenación y fichas muestran `displayStatus`. El formulario solo ofrece estados técnicos y explica la distinción.
|
||||
|
||||
`assetTechnicalStatus`, `assetDisplayStatus` y `canAssignAsset` son funciones puras pequeñas del dominio. La selección de activos del puesto reutiliza la regla de asignación y muestra por qué un elemento está bloqueado. Una nueva asociación a un puesto activo, o su reactivación, requiere un activo Operativo. Las reservas exclusivas y compartidas mantienen sus restricciones y bloqueos transaccionales. Una asociación existente puede conservarse al editar un puesto si su activo se avería o entra en mantenimiento; cambiarlo de exclusivo a compartido o viceversa se considera una nueva asociación.
|
||||
|
||||
Una avería o mantenimiento no borra asignaciones, perfiles ni software, y prevalece visualmente sobre En uso. Se conserva la información del puesto para organizar la reparación. Al recuperar Operativo vuelve a mostrarse En uso si sigue asignado. Al dar de baja/eliminar un puesto o retirar un miembro se recalcula el uso; un activo compartido permanece En uso mientras otro puesto activo lo utilice. Un activo averiado o en mantenimiento conserva su estado al liberarse.
|
||||
|
||||
El servicio de inventario exporta `notRetired`, en sustitución del booleano genérico `available`. Perfiles, software, correo, impresoras, certificados, accesos remotos, procesos y consultas de soporte utilizan ese contrato explícito. Se conserva su criterio para registrar configuraciones: no retirado no significa libre ni operativo. La creación/activación de un perfil mantiene además los requisitos de persona y ordenador principal de un puesto activo. Las configuraciones existentes y sus avisos siguen documentando preparación/retirada manual, sin simular operaciones sobre equipos.
|
||||
|
||||
La persistencia conserva los códigos originales Disponible/En uso en los registros existentes y sus revisiones. Se interpretan como estado técnico Operativo al consultar la situación actual; no se inventan asignaciones ni se reescribe el historial. Las nuevas entradas HTTP solo aceptan los cuatro estados técnicos. Una consulta y un guardado semánticamente equivalente no añaden revisiones de normalización. El mapper sigue comprobando que el estado original de raíz y última revisión coincida. La restricción SQL se amplía idempotentemente para admitir los nuevos estados, preservando la lectura de los registros originales; no se añade JSON ni nuevas columnas de estados calculados.
|
||||
|
||||
Pruebas de reglas puras, elegibilidad exclusiva/compartida, historial e idempotencia, uso compartido por varios puestos, avería/reparación conservando asignaciones, bloqueo de nuevas asociaciones, retirada con referencias activas, liberación por baja/eliminación y respuestas HTTP/MariaDB. Los escenarios de retirada en las pruebas desvinculan primero los puestos, como exige el nuevo comportamiento.
|
||||
|
||||
@ -16,7 +16,7 @@ Desactivar un puesto conserva su composición y libera sus reservas. Un activo p
|
||||
|
||||
Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe estar activa para una nueva asignación. Si se desactiva después, se conserva la asignación anterior para revisión y puede retirarse. La persona no recibe acceso a Metis Desk.
|
||||
|
||||
El responsable del puesto no cambia automáticamente la sede ni el estado de cada activo, ni identifica a todos sus usuarios. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
|
||||
El responsable del puesto no cambia automáticamente la sede ni el estado de cada activo, ni identifica a todos sus usuarios. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos averiados o en mantenimiento pueden permanecer en una composición para revisión y reparación. Las nuevas asociaciones activas requieren activos operativos. Para retirar un activo se desvincula primero de los puestos activos; las composiciones históricas se conservan.
|
||||
|
||||
La eliminación definitiva se realiza mediante una operación explícita con confirmación; véase Baja y eliminación. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.
|
||||
|
||||
@ -62,3 +62,5 @@ En este modelo los recursos compartidos del puesto son activos compartidos (por
|
||||
`POST /api/assets/:companyId/workstations/:id/decommission` y `POST /api/assets/:companyId/workstations/:id/delete` reciben cuerpo vacío `{}`. Requieren sesión, origen y CSRF; compañía y puesto se validan en servidor. La baja registra actor de sesión y fecha en la revisión. Ambas operaciones comparten los bloqueos de catálogo y compañía con las reservas de activos. La baja conserva las referencias históricas; la eliminación elimina las asociaciones internas mediante las claves foráneas y las revisiones, sin desactivar restricciones ni persistir JSON.
|
||||
|
||||
Las pruebas cubren baja activa/inactiva, liberación de reservas exclusivas y compartidas, ordenador y responsable, historial e idempotencia, eliminación activa/inactiva, referencias externas, rollback lógico, pertenencia a compañía, cuerpo estricto y protección CSRF/origen.
|
||||
|
||||
El estado mostrado del activo se calcula desde su estado técnico y los puestos activos, según [Inventario de activos](inventario-de-activos.md). La baja libera composición y reservas sin cambiar el estado técnico de sus activos.
|
||||
|
||||
@ -16,7 +16,7 @@ La instalación del ordenador y la disponibilidad de una aplicación para una pe
|
||||
|
||||
La pareja ordenador/recurso es fija y solo admite una instalación abierta. Una cerrada permite un nuevo registro. Se comparte el ciclo ya existente: pendiente → configurado o nunca configurado; configurado → pendiente de retirar → retirado. Los estados terminales no se editan. Las notas pueden completarse mientras se revisa o retira una instalación.
|
||||
|
||||
El servidor comprueba pertenencia a compañía, tipo de recurso, disponibilidad del activo y actividad del recurso. Las altas o reconfiguraciones pendientes/configuradas exigen referencias disponibles; retirar y completar notas sigue siendo posible si el activo está retirado o el recurso inactivo. Se conserva historial y aparece un aviso de revisión. Eliminar corrige un alta por error y elimina también sus revisiones, con confirmación explícita en la interfaz.
|
||||
El servidor comprueba pertenencia a compañía, tipo de recurso, condición no retirado del activo y actividad del recurso. Las altas o reconfiguraciones pendientes/configuradas exigen referencias disponibles; retirar y completar notas sigue siendo posible si el activo está retirado o el recurso inactivo. Se conserva historial y aparece un aviso de revisión. Eliminar corrige un alta por error y elimina también sus revisiones, con confirmación explícita en la interfaz.
|
||||
|
||||
## Persistencia y API
|
||||
|
||||
@ -44,3 +44,5 @@ El uso `license` permite reconocer también plantillas propias y copias de la pl
|
||||
`licenseResourceId` es una columna UUID nullable con clave foránea en `software_installations` y `software_installation_revisions`. Las instalaciones y revisiones anteriores conservan NULL: no se inventan relaciones. Las claves foráneas protegen tanto la referencia actual como las históricas. No se añade JSON ni nuevos endpoints.
|
||||
|
||||
Se comprueban referencias de otra compañía o de tipo incorrecto, inactividad/caducidad, idempotencia, desvinculación, revisiones anteriores, migración repetida y enlaces/traducciones es/en. La limpieza de la base exclusiva de pruebas elimina primero las instalaciones y revisiones para permitir ejecuciones consecutivas sin desactivar claves foráneas.
|
||||
|
||||
La elegibilidad del activo utiliza el indicador explícito `notRetired`. Un equipo en uso o en mantenimiento puede conservar y documentar software; la asignación exclusiva a un puesto aplica una regla distinta, definida en [Inventario de activos](inventario-de-activos.md).
|
||||
|
||||
@ -17,6 +17,19 @@ export function mapAssetInput(
|
||||
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
|
||||
return Result.fail(new AssetCatalogError("TEMPLATE_FIXED"));
|
||||
}
|
||||
if (
|
||||
input.status === "retired" &&
|
||||
previous?.values.technicalStatus !== "retired" &&
|
||||
context.workstations.some(
|
||||
(station) =>
|
||||
station.values.active &&
|
||||
[...station.values.assetIds, ...station.values.sharedAssetIds].some(
|
||||
(id) => id.toPrimitive() === previous?.id.toPrimitive()
|
||||
)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_ASSIGNED"));
|
||||
}
|
||||
let specification = previous?.specification;
|
||||
|
||||
if (!specification) {
|
||||
|
||||
@ -1,12 +1,28 @@
|
||||
import type { AssetDTO } from "../../common/inventory";
|
||||
import type { Asset } from "../domain/asset.aggregate";
|
||||
import { assetDisplayStatus } from "../domain/asset-state";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
|
||||
export function buildAssetResponse(
|
||||
asset: Asset,
|
||||
actors: ReadonlyMap<string, string>,
|
||||
workstations: readonly Workstation[] = []
|
||||
): AssetDTO {
|
||||
const assigned = workstations.some(
|
||||
(station) =>
|
||||
station.values.active &&
|
||||
[...station.values.assetIds, ...station.values.sharedAssetIds].some((id) =>
|
||||
id.equals(asset.id)
|
||||
)
|
||||
);
|
||||
|
||||
export function buildAssetResponse(asset: Asset, actors: ReadonlyMap<string, string>): AssetDTO {
|
||||
return {
|
||||
id: asset.id.toPrimitive(),
|
||||
companyId: asset.companyId.toPrimitive(),
|
||||
specification: asset.specification.toPrimitive(),
|
||||
values: asset.values.toPrimitive(),
|
||||
values: { ...asset.values.toPrimitive(), status: asset.values.technicalStatus },
|
||||
assigned,
|
||||
displayStatus: assetDisplayStatus(asset.values.technicalStatus, assigned),
|
||||
history: asset.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
|
||||
@ -24,7 +24,16 @@ export interface IAssetRepository {
|
||||
remove(
|
||||
company: UniqueID,
|
||||
id: UniqueID
|
||||
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
asset: Asset;
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
workstations: readonly Workstation[];
|
||||
},
|
||||
AssetCatalogError
|
||||
>
|
||||
>;
|
||||
removeWorkstation(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
|
||||
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
|
||||
saveWorkstation(
|
||||
@ -46,7 +55,16 @@ export interface IAssetRepository {
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
|
||||
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
asset: Asset;
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
workstations: readonly Workstation[];
|
||||
},
|
||||
AssetCatalogError
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export function validateAssetReferences(context: InventoryContext, values: AssetValues) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import { canAssignAsset } from "../domain/asset-state";
|
||||
import { WorkstationValues } from "../domain/value-objects/workstation-values.vo";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
import type { WorkstationInput } from "../domain/workstation-values";
|
||||
@ -31,25 +32,56 @@ export function mapWorkstationInput(
|
||||
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
|
||||
}
|
||||
for (const id of [...values.assetIds, ...values.sharedAssetIds]) {
|
||||
const asset = context.assets.find((item) => item.id.toPrimitive() === id);
|
||||
const error = memberError(id, values, context, previous);
|
||||
|
||||
if (!asset) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
if (
|
||||
values.active &&
|
||||
context.workstations.some(
|
||||
(item) =>
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||
item.values.active &&
|
||||
(item.values.assetIds.some((assetId) => assetId.toPrimitive() === id) ||
|
||||
(values.assetIds.includes(id) &&
|
||||
item.values.sharedAssetIds.some((assetId) => assetId.toPrimitive() === id)))
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));
|
||||
if (error) {
|
||||
return Result.fail(new AssetCatalogError(error));
|
||||
}
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
function memberError(
|
||||
id: string,
|
||||
values: WorkstationInput,
|
||||
context: InventoryContext,
|
||||
previous: Workstation | undefined
|
||||
) {
|
||||
const asset = context.assets.find((item) => item.id.toPrimitive() === id);
|
||||
|
||||
if (!asset) {
|
||||
return "ASSET_NOT_FOUND";
|
||||
}
|
||||
if (!values.active) {
|
||||
return undefined;
|
||||
}
|
||||
const exclusive = values.assetIds.includes(id);
|
||||
const previousMembers = exclusive ? previous?.values.assetIds : previous?.values.sharedAssetIds;
|
||||
const retained =
|
||||
previous?.values.active && previousMembers?.some((item) => item.toPrimitive() === id);
|
||||
|
||||
if (
|
||||
!(
|
||||
retained ||
|
||||
canAssignAsset(asset.values.technicalStatus, exclusive ? "exclusive" : "shared", false, false)
|
||||
)
|
||||
) {
|
||||
return "ASSET_NOT_OPERATIONAL";
|
||||
}
|
||||
const others = context.workstations.filter(
|
||||
(item) => item.values.active && item.id.toPrimitive() !== previous?.id.toPrimitive()
|
||||
);
|
||||
const exclusiveElsewhere = others.some((item) =>
|
||||
item.values.assetIds.some((assetId) => assetId.toPrimitive() === id)
|
||||
);
|
||||
const sharedElsewhere = others.some((item) =>
|
||||
item.values.sharedAssetIds.some((assetId) => assetId.toPrimitive() === id)
|
||||
);
|
||||
|
||||
if (exclusiveElsewhere || (exclusive && sharedElsewhere)) {
|
||||
return "ASSET_IN_WORKSTATION";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
18
modules/assets/src/api/domain/asset-state.ts
Normal file
18
modules/assets/src/api/domain/asset-state.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { AssetInput, AssetRecordedInput } from "./asset-values";
|
||||
|
||||
export function assetTechnicalStatus(status: AssetRecordedInput["status"]): AssetInput["status"] {
|
||||
return status === "available" || status === "in_use" ? "operational" : status;
|
||||
}
|
||||
|
||||
export function assetDisplayStatus(status: AssetInput["status"], assigned: boolean) {
|
||||
return status === "operational" ? (assigned ? "in_use" : "available") : status;
|
||||
}
|
||||
|
||||
export function canAssignAsset(
|
||||
status: AssetInput["status"],
|
||||
kind: "exclusive" | "shared",
|
||||
exclusiveElsewhere: boolean,
|
||||
sharedElsewhere: boolean
|
||||
) {
|
||||
return status === "operational" && !exclusiveElsewhere && (kind === "shared" || !sharedElsewhere);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { FieldTypeSchema, TemplateSectionSchema } from "./asset-definition";
|
||||
|
||||
export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]);
|
||||
export const AssetStatusSchema = z.enum(["operational", "broken", "maintenance", "retired"]);
|
||||
|
||||
export const AssetCharacteristicSchema = z.strictObject({
|
||||
fieldId: z.uuid(),
|
||||
@ -26,6 +26,13 @@ export const AssetInputSchema = z.strictObject({
|
||||
characteristics: z.record(z.uuid(), AssetValueSchema),
|
||||
});
|
||||
|
||||
// Las revisiones conservan el estado registrado en su momento.
|
||||
export const AssetRecordedInputSchema = AssetInputSchema.extend({
|
||||
status: z.enum(["available", "in_use", ...AssetStatusSchema.options]),
|
||||
});
|
||||
|
||||
export type AssetRecordedInput = z.infer<typeof AssetRecordedInputSchema>;
|
||||
|
||||
export const AssetSpecificationSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
templateName: z.string().min(1).max(100),
|
||||
|
||||
@ -4,6 +4,7 @@ import { test } from "node:test";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import { Asset } from "./asset.aggregate";
|
||||
import { assetDisplayStatus, assetTechnicalStatus, canAssignAsset } from "./asset-state";
|
||||
import { type AssetInput, AssetInputSchema } from "./asset-values";
|
||||
import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
|
||||
|
||||
@ -37,7 +38,7 @@ function input(characteristics: AssetInput["characteristics"] = {}): AssetInput
|
||||
name: " Equipo ",
|
||||
templateId,
|
||||
siteId: null,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics,
|
||||
};
|
||||
@ -106,7 +107,7 @@ test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes",
|
||||
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, asset);
|
||||
const next = AssetValues.create({ ...input(), status: "in_use" }, spec);
|
||||
const next = AssetValues.create({ ...input(), status: "maintenance" }, spec);
|
||||
|
||||
assert.ok(next.isSuccess);
|
||||
const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z");
|
||||
@ -142,3 +143,29 @@ test("texto largo conserva saltos de línea y rechaza tipos no textuales", () =>
|
||||
assert.equal(result.data.toPrimitive().characteristics[fieldId], "24 RJ45\n4 SFP+");
|
||||
assert.equal(AssetValues.create(input({ [fieldId]: 24 }), created.data).isSuccess, false);
|
||||
});
|
||||
|
||||
test("estado técnico y uso: asignaciones calculadas, elegibilidad exclusiva/compartida e historial conservado", () => {
|
||||
assert.equal(assetTechnicalStatus("available"), "operational");
|
||||
assert.equal(assetTechnicalStatus("in_use"), "operational");
|
||||
assert.equal(assetDisplayStatus("operational", false), "available");
|
||||
assert.equal(assetDisplayStatus("operational", true), "in_use");
|
||||
assert.ok(canAssignAsset("operational", "exclusive", false, false));
|
||||
assert.ok(!canAssignAsset("operational", "exclusive", false, true));
|
||||
assert.ok(!canAssignAsset("operational", "shared", true, false));
|
||||
assert.ok(canAssignAsset("operational", "shared", false, true));
|
||||
for (const state of ["broken", "maintenance", "retired"] as const) {
|
||||
assert.equal(assetDisplayStatus(state, false), state);
|
||||
assert.equal(assetDisplayStatus(state, true), state);
|
||||
assert.ok(!canAssignAsset(state, "exclusive", false, false));
|
||||
assert.ok(!canAssignAsset(state, "shared", false, false));
|
||||
}
|
||||
assert.equal(AssetInputSchema.safeParse({ ...input(), status: "in_use" }).success, false);
|
||||
assert.equal(AssetInputSchema.safeParse({ ...input(), status: "available" }).success, false);
|
||||
const spec = specification();
|
||||
const previous = AssetValues.create({ ...input(), status: "in_use" }, spec);
|
||||
const current = AssetValues.create(input(), spec);
|
||||
|
||||
assert.ok(previous.isSuccess && current.isSuccess);
|
||||
assert.equal(previous.data.toPrimitive().status, "in_use");
|
||||
assert.ok(previous.data.equals(current.data));
|
||||
});
|
||||
|
||||
@ -219,7 +219,7 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
|
||||
name: "Registro",
|
||||
templateId: selected.id.toPrimitive(),
|
||||
siteId: null,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
|
||||
@ -3,8 +3,14 @@ import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetCatalogError } from "../asset-definition";
|
||||
import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values";
|
||||
import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values";
|
||||
import { assetTechnicalStatus } from "../asset-state";
|
||||
import type {
|
||||
AssetCharacteristic,
|
||||
AssetInput,
|
||||
AssetRecordedInput,
|
||||
AssetSpecificationData,
|
||||
} from "../asset-values";
|
||||
import { AssetRecordedInputSchema, AssetSpecificationSchema } from "../asset-values";
|
||||
import {
|
||||
freezeTemplateSections,
|
||||
sameTemplateSections,
|
||||
@ -117,7 +123,7 @@ function validCharacteristic(field: AssetCharacteristic, value: unknown) {
|
||||
|
||||
export class AssetValues {
|
||||
private constructor(
|
||||
private readonly data: AssetInput,
|
||||
private readonly data: AssetRecordedInput,
|
||||
public readonly name: Name,
|
||||
public readonly siteId: Maybe<UniqueID>,
|
||||
public readonly brandId: Maybe<UniqueID>
|
||||
@ -129,11 +135,15 @@ export class AssetValues {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
get technicalStatus() {
|
||||
return assetTechnicalStatus(this.data.status);
|
||||
}
|
||||
|
||||
static create(
|
||||
input: AssetInput,
|
||||
input: AssetRecordedInput,
|
||||
specification: AssetSpecification
|
||||
): Result<AssetValues, AssetCatalogError> {
|
||||
const parsed = AssetInputSchema.safeParse(input);
|
||||
const parsed = AssetRecordedInputSchema.safeParse(input);
|
||||
const name = Name.create(input.name, 100);
|
||||
|
||||
if (
|
||||
@ -181,7 +191,7 @@ export class AssetValues {
|
||||
first.templateId === second.templateId &&
|
||||
first.siteId === second.siteId &&
|
||||
(first.brandId ?? null) === (second.brandId ?? null) &&
|
||||
first.status === second.status &&
|
||||
this.technicalStatus === other.technicalStatus &&
|
||||
first.notes === second.notes &&
|
||||
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
||||
Object.entries(first.characteristics).every(
|
||||
|
||||
@ -49,7 +49,7 @@ export function registerAssetRoutes(
|
||||
buildRelationshipResponse(item, result.data.actorNames)
|
||||
),
|
||||
assets: result.data.assets.map((asset) =>
|
||||
buildAssetResponse(asset, result.data.actorNames)
|
||||
buildAssetResponse(asset, result.data.actorNames, result.data.workstations)
|
||||
),
|
||||
});
|
||||
})
|
||||
@ -77,7 +77,13 @@ export function registerAssetRoutes(
|
||||
|
||||
return;
|
||||
}
|
||||
response.json({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) });
|
||||
response.json({
|
||||
asset: buildAssetResponse(
|
||||
result.data.asset,
|
||||
result.data.actorNames,
|
||||
result.data.workstations
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
@ -107,9 +113,13 @@ export function registerAssetRoutes(
|
||||
|
||||
return;
|
||||
}
|
||||
response
|
||||
.status(id ? 200 : 201)
|
||||
.json({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) });
|
||||
response.status(id ? 200 : 201).json({
|
||||
asset: buildAssetResponse(
|
||||
result.data.asset,
|
||||
result.data.actorNames,
|
||||
result.data.workstations
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import type { AssetInput, AssetSpecificationData } from "../../domain/asset-values";
|
||||
import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
|
||||
import type { AssetRecordedInput, AssetSpecificationData } from "../../domain/asset-values";
|
||||
import { AssetRecordedInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
|
||||
|
||||
import type { AssetPersistenceMapper } from "./asset-persistence.mapper";
|
||||
import { readAssetRows, sqlIdentifier, writeAssetRow } from "./asset-relational-sql";
|
||||
@ -111,7 +111,7 @@ export function decodeCharacteristic(
|
||||
|
||||
function characteristicRows(
|
||||
assetId: string,
|
||||
values: AssetInput,
|
||||
values: AssetRecordedInput,
|
||||
specification: AssetSpecificationData,
|
||||
revisionId?: string
|
||||
) {
|
||||
@ -186,7 +186,7 @@ export async function readInventory(
|
||||
);
|
||||
|
||||
const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) =>
|
||||
AssetInputSchema.parse({
|
||||
AssetRecordedInputSchema.parse({
|
||||
name: row.name,
|
||||
templateId: row.templateId,
|
||||
siteId: row.siteId,
|
||||
|
||||
@ -3,7 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { Asset } from "../../domain/asset.aggregate";
|
||||
import { AssetRevision } from "../../domain/asset-revision.entity";
|
||||
import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
|
||||
import { AssetRecordedInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
|
||||
import { AssetSpecification, AssetValues } from "../../domain/value-objects/asset-values.vo";
|
||||
|
||||
import type { InventoryRow, RevisionRow } from "./asset-inventory-relational";
|
||||
@ -27,7 +27,10 @@ export class AssetPersistenceMapper {
|
||||
if (!specification.isSuccess) {
|
||||
throw new Error("Especificación de activo persistida inconsistente.");
|
||||
}
|
||||
const values = AssetValues.create(AssetInputSchema.parse(row.values), specification.data);
|
||||
const values = AssetValues.create(
|
||||
AssetRecordedInputSchema.parse(row.values),
|
||||
specification.data
|
||||
);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
throw new Error("Valores de activo persistidos inconsistentes.");
|
||||
@ -45,7 +48,7 @@ export class AssetPersistenceMapper {
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((revision, position) => {
|
||||
const previous = AssetValues.create(
|
||||
AssetInputSchema.parse(revision.values),
|
||||
AssetRecordedInputSchema.parse(revision.values),
|
||||
specification.data
|
||||
);
|
||||
|
||||
|
||||
@ -16,7 +16,18 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
|
||||
const add = async (table: string, name: string, definition: string) => {
|
||||
const existing = constraints.some((item) => item.tableName === table && item.name === name);
|
||||
|
||||
if (existing && definition.startsWith("CHECK") && definition.includes("multiline")) {
|
||||
if (existing && definition.startsWith("CHECK") && definition.includes("operational")) {
|
||||
const checks = await database.query<{ clause: string }>(
|
||||
"SELECT CHECK_CLAUSE AS clause FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND CONSTRAINT_NAME=:name AND TABLE_NAME=:table",
|
||||
{ replacements: { name, table }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!checks[0]?.clause.includes("operational")) {
|
||||
await database.query(
|
||||
`ALTER TABLE ${sqlIdentifier(table)} DROP CONSTRAINT ${sqlIdentifier(name)}, ADD CONSTRAINT ${sqlIdentifier(name)} ${definition}`
|
||||
);
|
||||
}
|
||||
} else if (existing && definition.startsWith("CHECK") && definition.includes("multiline")) {
|
||||
await upgradeAssetTextConstraint(database, table, name, definition);
|
||||
} else if (!existing) {
|
||||
await database.query(
|
||||
@ -31,7 +42,7 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
|
||||
await add(
|
||||
table,
|
||||
`${table}_status`,
|
||||
"CHECK (status IN ('available','in_use','maintenance','retired'))"
|
||||
"CHECK (status IN ('operational','broken','available','in_use','maintenance','retired'))"
|
||||
);
|
||||
}
|
||||
await add("asset_template_sections", "asset_section_template_identity", "UNIQUE (templateId,id)");
|
||||
|
||||
@ -303,7 +303,11 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
}
|
||||
await this.removeInventory(id.toPrimitive(), companyId.toPrimitive(), transaction);
|
||||
|
||||
return Result.ok({ asset, actorNames: loaded.data.actorNames });
|
||||
return Result.ok({
|
||||
asset,
|
||||
actorNames: loaded.data.actorNames,
|
||||
workstations: loaded.data.workstations,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -382,7 +386,11 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
await writeInventory(this.database, rows, existing, transaction);
|
||||
}
|
||||
|
||||
return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames });
|
||||
return Result.ok({
|
||||
asset: changed.data,
|
||||
actorNames: loaded.data.actorNames,
|
||||
workstations: loaded.data.workstations,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,7 +131,7 @@ function inventoryAssignments(context: InventoryContext) {
|
||||
id: UniqueID;
|
||||
name: string;
|
||||
personId: string | null;
|
||||
available: boolean;
|
||||
notRetired: boolean;
|
||||
isProfileHost: boolean;
|
||||
}[] = [];
|
||||
|
||||
@ -149,7 +149,7 @@ function inventoryAssignments(context: InventoryContext) {
|
||||
id: asset.id,
|
||||
name: values.name,
|
||||
personId,
|
||||
available: values.status !== "retired",
|
||||
notRetired: asset.values.technicalStatus !== "retired",
|
||||
isProfileHost,
|
||||
});
|
||||
}
|
||||
@ -158,7 +158,7 @@ function inventoryAssignments(context: InventoryContext) {
|
||||
id: asset.id,
|
||||
name: values.name,
|
||||
personId: null,
|
||||
available: values.status !== "retired",
|
||||
notRetired: asset.values.technicalStatus !== "retired",
|
||||
isProfileHost,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
AssetInputSchema,
|
||||
AssetRecordedInputSchema,
|
||||
AssetSpecificationSchema,
|
||||
AssetStatusSchema,
|
||||
AssetValueSchema,
|
||||
@ -22,7 +23,7 @@ export const SaveAssetRequestSchema = z.strictObject({ values: AssetInputSchema
|
||||
|
||||
export const AssetRevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: AssetInputSchema,
|
||||
values: AssetRecordedInputSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
@ -33,6 +34,8 @@ export const AssetSchema = z.strictObject({
|
||||
companyId: z.uuid(),
|
||||
specification: AssetSpecificationSchema,
|
||||
values: AssetInputSchema,
|
||||
displayStatus: z.enum(["available", "in_use", "broken", "maintenance", "retired"]),
|
||||
assigned: z.boolean(),
|
||||
history: z.array(AssetRevisionSchema).min(1),
|
||||
});
|
||||
|
||||
@ -44,6 +47,8 @@ export const InventoryResponseSchema = z.strictObject({
|
||||
|
||||
export const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
|
||||
|
||||
export { assetTechnicalStatus, canAssignAsset } from "../api/domain/asset-state";
|
||||
|
||||
export type AssetDTO = z.infer<typeof AssetSchema>;
|
||||
|
||||
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;
|
||||
|
||||
@ -139,7 +139,9 @@
|
||||
"ASSET_NOT_FOUND": "Asset not found in this company.",
|
||||
"INTERNAL_ERROR": "The operation could not be completed.",
|
||||
"FORBIDDEN": "Sign in again.",
|
||||
"UNAUTHENTICATED": "Sign in again."
|
||||
"UNAUTHENTICATED": "Sign in again.",
|
||||
"ASSET_ASSIGNED": "Unlink the asset from active workstations before retiring it.",
|
||||
"ASSET_NOT_OPERATIONAL": "Only operational assets can be added to an active workstation."
|
||||
},
|
||||
"workstations": "Workstations",
|
||||
"newWorkstation": "New workstation",
|
||||
@ -376,5 +378,9 @@
|
||||
"error": "Could not delete the workstation. Please try again."
|
||||
},
|
||||
"inUse": "Other records, such as tickets or interventions, reference this workstation. Decommission it to retain those references."
|
||||
}
|
||||
},
|
||||
"operational": "Operational",
|
||||
"broken": "Broken",
|
||||
"technicalStatus": "Technical state",
|
||||
"technicalStatusHelp": "Available and In use are calculated from active workstations. Breakdowns and maintenance retain existing assignments."
|
||||
}
|
||||
|
||||
@ -139,7 +139,9 @@
|
||||
"ASSET_NOT_FOUND": "Activo no encontrado en esta compañía.",
|
||||
"INTERNAL_ERROR": "No se pudo completar la operación.",
|
||||
"FORBIDDEN": "Vuelve a iniciar sesión.",
|
||||
"UNAUTHENTICATED": "Vuelve a iniciar sesión."
|
||||
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
|
||||
"ASSET_ASSIGNED": "Desvincula el activo de los puestos activos antes de retirarlo.",
|
||||
"ASSET_NOT_OPERATIONAL": "Solo se pueden incorporar activos operativos a un puesto activo."
|
||||
},
|
||||
"workstations": "Puestos de trabajo",
|
||||
"newWorkstation": "Nuevo puesto",
|
||||
@ -376,5 +378,9 @@
|
||||
"error": "No se pudo eliminar el puesto. Inténtalo de nuevo."
|
||||
},
|
||||
"inUse": "El puesto está referenciado por otros registros, por ejemplo tickets o intervenciones. Da de baja el puesto para conservar esas referencias."
|
||||
}
|
||||
},
|
||||
"operational": "Operativo",
|
||||
"broken": "Averiado",
|
||||
"technicalStatus": "Estado técnico",
|
||||
"technicalStatusHelp": "Disponible y En uso se calculan según los puestos activos. Una avería o mantenimiento conserva las asignaciones existentes."
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
{asset.specification.templateName}
|
||||
</BrowserLink>
|
||||
</DetailField>
|
||||
<DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
|
||||
<DetailField label={t("status")}>{t(asset.displayStatus)}</DetailField>
|
||||
</dl>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
@ -29,8 +29,8 @@ export function AssetPanelSummary(props: AssetDetailProps) {
|
||||
<div>
|
||||
<dt className="sr-only">{t("status")}</dt>
|
||||
<dd>
|
||||
<EntityStatus active={asset.values.status !== "retired"}>
|
||||
{t(asset.values.status)}
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -71,7 +71,7 @@ export function useAssetInventoryPageController({
|
||||
asset.values.name,
|
||||
asset.specification.templateName,
|
||||
site?.name ?? "",
|
||||
t(asset.values.status),
|
||||
t(asset.displayStatus),
|
||||
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
|
||||
});
|
||||
|
||||
|
||||
@ -140,10 +140,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (asset) => t(asset.values.status),
|
||||
value: (asset) => t(asset.displayStatus),
|
||||
render: (asset) => (
|
||||
<EntityStatus active={asset.values.status !== "retired"}>
|
||||
{t(asset.values.status)}
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
|
||||
@ -27,7 +27,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
|
||||
name: "",
|
||||
templateId: "",
|
||||
siteId: null,
|
||||
status: "available",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
},
|
||||
|
||||
@ -12,8 +12,9 @@ import { I18nextProvider } from "react-i18next";
|
||||
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
||||
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||
import type { RelationshipDTO } from "../../../common";
|
||||
import type { RelationshipDTO, WorkstationDTO } from "../../../common";
|
||||
import { AssetSchema } from "../../../common/inventory";
|
||||
import { WorkstationSchema } from "../../../common/workstations";
|
||||
import { AssetRelationships } from "../../asset-relationships";
|
||||
import { catalogKeys } from "../../catalog/shared/api";
|
||||
import { CatalogRelationForm } from "../../catalog-relations";
|
||||
@ -35,7 +36,12 @@ const lookups = {
|
||||
onCreatePerson: () => undefined,
|
||||
};
|
||||
|
||||
async function render(language: string, node: ReactNode, relationships: RelationshipDTO[] = []) {
|
||||
async function render(
|
||||
language: string,
|
||||
node: ReactNode,
|
||||
relationships: RelationshipDTO[] = [],
|
||||
workstations: WorkstationDTO[] = []
|
||||
) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
@ -47,7 +53,7 @@ async function render(language: string, node: ReactNode, relationships: Relation
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [],
|
||||
relationships,
|
||||
workstations: [],
|
||||
workstations,
|
||||
});
|
||||
client.setQueryData(
|
||||
catalogKeys.scope(companyId),
|
||||
@ -227,6 +233,8 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
const personId = UniqueID.generateNewID().toPrimitive();
|
||||
const siteId = UniqueID.generateNewID().toPrimitive();
|
||||
const asset = AssetSchema.parse({
|
||||
displayStatus: "in_use",
|
||||
assigned: true,
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
specification: {
|
||||
@ -240,7 +248,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
name: "Impresora de administración",
|
||||
templateId: fieldId,
|
||||
siteId,
|
||||
status: "in_use",
|
||||
status: "operational",
|
||||
notes: "Uso compartido",
|
||||
characteristics: { [fieldId]: "ABC123" },
|
||||
},
|
||||
@ -254,7 +262,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
name: "Impresora de administración",
|
||||
templateId: fieldId,
|
||||
siteId,
|
||||
status: "in_use",
|
||||
status: "operational",
|
||||
notes: "Uso compartido",
|
||||
characteristics: { [fieldId]: "ABC123" },
|
||||
},
|
||||
@ -276,15 +284,47 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
onEdit={() => undefined}
|
||||
onView={() => undefined}
|
||||
open
|
||||
/>
|
||||
/>,
|
||||
[],
|
||||
[
|
||||
WorkstationSchema.parse({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
values: {
|
||||
name: "Puesto Administración",
|
||||
active: true,
|
||||
siteId,
|
||||
personId,
|
||||
assetIds: [],
|
||||
sharedAssetIds: [asset.id],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
actorId: personId,
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-13T10:00:00.000Z",
|
||||
values: {
|
||||
name: "Puesto Administración",
|
||||
active: true,
|
||||
siteId,
|
||||
personId,
|
||||
assetIds: [],
|
||||
sharedAssetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
html.indexOf(`href="/companies/${companyId}/catalogs/assets?tab=templates"`) <
|
||||
html.indexOf("Ficha e historial")
|
||||
);
|
||||
assert.ok(html.includes("En uso"));
|
||||
assert.ok(html.indexOf("En uso") < html.indexOf("Ficha e historial"));
|
||||
assert.ok(html.indexOf("Sin puesto activo") < html.indexOf("Ficha e historial"));
|
||||
assert.ok(html.indexOf("Puesto Administración") < html.indexOf("Ficha e historial"));
|
||||
assert.ok(html.indexOf("Ficha e historial") < html.indexOf("Ubicación y asignación"));
|
||||
assert.ok(html.indexOf(">Editar<") < html.indexOf("Ubicación y asignación"));
|
||||
for (const group of ["Ubicación y asignación", "Características", "Observaciones"]) {
|
||||
|
||||
@ -115,11 +115,12 @@ export function AssetForm(props: AssetFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
|
||||
<Label htmlFor={`${prefix}-status`}>{t("technicalStatus")}</Label>
|
||||
<SelectWithAction
|
||||
describedBy={`${prefix}-status-help`}
|
||||
disabled={mutation.isPending}
|
||||
id={`${prefix}-status`}
|
||||
label={t("status")}
|
||||
label={t("technicalStatus")}
|
||||
onValueChange={changeStatus}
|
||||
options={AssetStatusSchema.options.map((value) => ({
|
||||
value,
|
||||
@ -129,6 +130,9 @@ export function AssetForm(props: AssetFormProps) {
|
||||
required
|
||||
value={values.status}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground" id={`${prefix}-status-help`}>
|
||||
{t("technicalStatusHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||
|
||||
@ -153,7 +153,7 @@ test("Marcas: el campo de serie se identifica por referencia de catálogo, no po
|
||||
siteId: null,
|
||||
personId: null,
|
||||
brandId: brand.id,
|
||||
status: "available" as const,
|
||||
status: "operational" as const,
|
||||
notes: "",
|
||||
characteristics: { [field.id]: "LG" },
|
||||
},
|
||||
@ -171,6 +171,8 @@ test("Marcas: el campo de serie se identifica por referencia de catálogo, no po
|
||||
},
|
||||
],
|
||||
},
|
||||
displayStatus: "available" as const,
|
||||
assigned: false,
|
||||
history: [],
|
||||
};
|
||||
const html = await render(
|
||||
|
||||
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationInput } from "../../../../common";
|
||||
import { WorkstationInputSchema } from "../../../../common";
|
||||
import { canAssignAsset } from "../../../../common/inventory";
|
||||
import { getAssetInventory } from "../../../assets/list/api";
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
@ -65,8 +66,38 @@ export function useWorkstationFormController({
|
||||
.flatMap((item) => item.values.sharedAssetIds)
|
||||
);
|
||||
|
||||
const unavailableReason = (field: "assetIds" | "sharedAssetIds", id: string) => {
|
||||
if (!values.active) {
|
||||
return undefined;
|
||||
}
|
||||
const retained = workstation?.values.active && workstation.values[field].includes(id);
|
||||
const asset = assets.find((item) => item.id === id);
|
||||
|
||||
if (!asset) {
|
||||
return "ASSET_NOT_FOUND";
|
||||
}
|
||||
if (
|
||||
!(
|
||||
retained ||
|
||||
canAssignAsset(
|
||||
asset.values.status,
|
||||
field === "assetIds" ? "exclusive" : "shared",
|
||||
false,
|
||||
false
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "ASSET_NOT_OPERATIONAL";
|
||||
}
|
||||
if (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id))) {
|
||||
return "ASSET_IN_WORKSTATION";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) =>
|
||||
values.active && (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id)));
|
||||
Boolean(unavailableReason(field, id));
|
||||
|
||||
const add = (field: "assetIds" | "sharedAssetIds", id: string) => {
|
||||
if (
|
||||
@ -132,6 +163,7 @@ export function useWorkstationFormController({
|
||||
catalog,
|
||||
assets,
|
||||
unavailable,
|
||||
unavailableReason,
|
||||
add,
|
||||
selectAssociation,
|
||||
mutation,
|
||||
|
||||
@ -26,6 +26,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
catalog,
|
||||
assets,
|
||||
unavailable,
|
||||
unavailableReason,
|
||||
add,
|
||||
selectAssociation,
|
||||
mutation,
|
||||
@ -127,9 +128,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
)
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: unavailable(field, item.id)
|
||||
? t("workstationOccupied", { name: item.values.name })
|
||||
: item.values.name,
|
||||
label:
|
||||
unavailableReason(field, item.id) === "ASSET_NOT_OPERATIONAL"
|
||||
? `${item.values.name} · ${t(item.values.status)}`
|
||||
: unavailable(field, item.id)
|
||||
? t("workstationOccupied", { name: item.values.name })
|
||||
: item.values.name,
|
||||
disabled: unavailable(field, item.id),
|
||||
}))}
|
||||
placeholder={t("chooseAsset")}
|
||||
@ -146,7 +150,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
|
||||
{unavailable(field, id) && (
|
||||
<p className="text-destructive">
|
||||
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
|
||||
{t(`inventoryErrors.${unavailableReason(field, id)}`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@ export function applicationAvailable(
|
||||
!!profile &&
|
||||
profile.values.active &&
|
||||
context.people.some((person) => person.id.equals(profile.values.personId) && person.active) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.notRetired) &&
|
||||
context.resources.some(
|
||||
(item) => item.id.equals(values.resourceId) && item.active && item.usage === "application"
|
||||
)
|
||||
|
||||
@ -9,7 +9,7 @@ export type ApplicationContext = Readonly<{
|
||||
configurations: readonly ApplicationConfiguration[];
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
resources: readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
|
||||
@ -24,7 +24,7 @@ export function certificateAvailable(
|
||||
!!profile &&
|
||||
profile.values.active &&
|
||||
context.people.some((person) => person.id.equals(profile.values.personId) && person.active) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.notRetired) &&
|
||||
context.resources.some(
|
||||
(item) =>
|
||||
item.id.equals(values.resourceId) &&
|
||||
|
||||
@ -10,7 +10,7 @@ export type CertificateContext = Readonly<{
|
||||
configurations: readonly CertificateConfiguration[];
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
resources: readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
|
||||
@ -52,7 +52,7 @@ export function folderAvailable(values: FolderMappingValues, context: FolderCont
|
||||
need.startDate <= context.today &&
|
||||
(need.endDate === null || context.today < need.endDate) &&
|
||||
context.people.some((item) => item.id.equals(profile.values.personId) && item.active) &&
|
||||
context.assets.some((item) => item.id.equals(profile.values.assetId) && item.available) &&
|
||||
context.assets.some((item) => item.id.equals(profile.values.assetId) && item.notRetired) &&
|
||||
current?.isSuccess === true &&
|
||||
values.path.equals(current.data) &&
|
||||
activeReference(context, resource.resourceTypeId) &&
|
||||
|
||||
@ -11,7 +11,7 @@ export type FolderContext = Readonly<{
|
||||
profiles: readonly WorkProfile[];
|
||||
items: readonly { id: UniqueID; values: AccessValues }[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
}>;
|
||||
|
||||
@ -17,7 +17,7 @@ export function mailAuthorized(values: MailConfigurationValues, context: MailCon
|
||||
mailbox &&
|
||||
mailbox.values.active &&
|
||||
context.people.some((person) => person.id.equals(profile.values.personId) && person.active) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available)
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.notRetired)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
|
||||
@ -8,7 +8,7 @@ import type { WorkProfile } from "../domain/work-profile.aggregate";
|
||||
import type { MailboxContext } from "./mailbox.repository";
|
||||
|
||||
export interface IMailInventoryService {
|
||||
list(companyId: UniqueID): Promise<readonly Readonly<{ id: UniqueID; available: boolean }>[]>;
|
||||
list(companyId: UniqueID): Promise<readonly Readonly<{ id: UniqueID; notRetired: boolean }>[]>;
|
||||
}
|
||||
|
||||
export type MailContext = Readonly<{
|
||||
@ -17,7 +17,7 @@ export type MailContext = Readonly<{
|
||||
mailboxes: MailboxContext["mailboxes"];
|
||||
uses: MailboxContext["uses"];
|
||||
profiles: readonly WorkProfile[];
|
||||
assets: readonly Readonly<{ id: UniqueID; available: boolean }>[];
|
||||
assets: readonly Readonly<{ id: UniqueID; notRetired: boolean }>[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
}>;
|
||||
|
||||
@ -19,7 +19,7 @@ export function printerAvailable(values: PrinterConfigurationValues, context: Pr
|
||||
!!profile &&
|
||||
profile.values.active &&
|
||||
context.people.some((person) => person.id.equals(profile.values.personId) && person.active) &&
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.available) &&
|
||||
context.assets.some((asset) => asset.id.equals(values.printerId) && asset.available)
|
||||
context.assets.some((asset) => asset.id.equals(profile.values.assetId) && asset.notRetired) &&
|
||||
context.assets.some((asset) => asset.id.equals(values.printerId) && asset.notRetired)
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ export type PrinterContext = Readonly<{
|
||||
configurations: readonly PrinterConfiguration[];
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ProcessReferenceInput } from "../domain/process-values";
|
||||
|
||||
export type ProcessReviewSources = {
|
||||
assets: readonly { id: string; name: string; personId: string | null; available: boolean }[];
|
||||
assets: readonly { id: string; name: string; personId: string | null; notRetired: boolean }[];
|
||||
profiles: readonly { id: string; personId: string; name: string; active: boolean }[];
|
||||
configurations: readonly {
|
||||
id: string;
|
||||
@ -30,7 +30,7 @@ export function buildProcessReview(
|
||||
const closed = (state: string | null) => state === "removed" || state === "never_configured";
|
||||
|
||||
const result: ProcessReferenceInput[] = sources.assets
|
||||
.filter((a) => a.personId === personId && a.available)
|
||||
.filter((a) => a.personId === personId && a.notRetired)
|
||||
.map((a) => ({ kind: "asset", id: a.id, label: a.name }));
|
||||
|
||||
for (const p of sources.profiles) {
|
||||
|
||||
@ -33,6 +33,6 @@ export interface IProcessAssetInventoryService {
|
||||
list(
|
||||
company: UniqueID
|
||||
): Promise<
|
||||
readonly { id: UniqueID; name: string; personId: string | null; available: boolean }[]
|
||||
readonly { id: UniqueID; name: string; personId: string | null; notRetired: boolean }[]
|
||||
>;
|
||||
}
|
||||
|
||||
@ -39,10 +39,10 @@ export function remoteNeedsReview(values: RemoteConnectionValues, context: Remot
|
||||
!remoteAuthorizationEffective(values, context) ||
|
||||
Boolean(
|
||||
v.sourceAssetId &&
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId && a.available)
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId && a.notRetired)
|
||||
) ||
|
||||
(v.targetKind === "asset" &&
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.targetId && a.available)) ||
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.targetId && a.notRetired)) ||
|
||||
(v.targetKind === "resource" &&
|
||||
!context.resources.some((r) => r.id.toPrimitive() === v.targetId && r.active))
|
||||
);
|
||||
|
||||
@ -10,7 +10,7 @@ export type RemoteContext = Readonly<{
|
||||
authorizations: readonly TeleworkAuthorization[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
sites: readonly UniqueID[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
resources: readonly { id: UniqueID; active: boolean }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
|
||||
@ -37,7 +37,7 @@ export function softwareLicenseAvailable(
|
||||
|
||||
export function softwareAvailable(values: SoftwareInstallationValues, context: SoftwareContext) {
|
||||
return (
|
||||
context.assets.some((item) => item.id.equals(values.assetId) && item.available) &&
|
||||
context.assets.some((item) => item.id.equals(values.assetId) && item.notRetired) &&
|
||||
context.resources.some(
|
||||
(item) =>
|
||||
item.id.equals(values.resourceId) &&
|
||||
|
||||
@ -6,7 +6,7 @@ import type { SoftwareInstallationError } from "../domain/software-installation-
|
||||
|
||||
export type SoftwareContext = Readonly<{
|
||||
configurations: readonly SoftwareInstallation[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean }[];
|
||||
resources: readonly {
|
||||
id: UniqueID;
|
||||
active: boolean;
|
||||
|
||||
@ -28,7 +28,7 @@ export type TicketSaveContext = TicketContext & {
|
||||
};
|
||||
|
||||
export interface ITicketInventoryService {
|
||||
list(company: UniqueID): Promise<readonly { id: UniqueID; name: string; available: boolean }[]>;
|
||||
list(company: UniqueID): Promise<readonly { id: UniqueID; name: string; notRetired: boolean }[]>;
|
||||
workstations(
|
||||
company: UniqueID
|
||||
): Promise<readonly { id: UniqueID; name: string; active: boolean }[]>;
|
||||
|
||||
@ -13,7 +13,7 @@ export function profileReferencesAvailable(values: WorkProfileValues, context: W
|
||||
context.people.some((person) => person.id.equals(values.personId) && person.active) &&
|
||||
context.assets.some(
|
||||
(asset) =>
|
||||
asset.id.equals(values.assetId) && asset.available && asset.isProfileHost === true
|
||||
asset.id.equals(values.assetId) && asset.notRetired && asset.isProfileHost === true
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
@ -8,13 +8,13 @@ import type { WorkProfileError } from "../domain/work-profile-values";
|
||||
export interface IWorkProfileInventoryService {
|
||||
list(
|
||||
companyId: UniqueID
|
||||
): Promise<readonly { id: UniqueID; available: boolean; isProfileHost?: boolean }[]>;
|
||||
): Promise<readonly { id: UniqueID; notRetired: boolean; isProfileHost?: boolean }[]>;
|
||||
}
|
||||
|
||||
export type WorkProfileContext = Readonly<{
|
||||
profiles: readonly WorkProfile[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
assets: readonly { id: UniqueID; available: boolean; isProfileHost?: boolean }[];
|
||||
assets: readonly { id: UniqueID; notRetired: boolean; isProfileHost?: boolean }[];
|
||||
items: readonly { id: UniqueID; values: AccessDefinition }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
|
||||
@ -48,7 +48,7 @@ function fixture() {
|
||||
profiles: [profile.data],
|
||||
configurations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: host, available: true }],
|
||||
assets: [{ id: host, notRetired: true }],
|
||||
resources: [{ id: resource, active: true, usage: "application" }],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
};
|
||||
@ -217,7 +217,7 @@ test("aplicaciones: inactividad conserva notas y permite retirada, impide reconf
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapApplicationInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess,
|
||||
mapApplicationInput(input, { ...context, assets: [{ id: host, notRetired: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
@ -48,7 +48,7 @@ function fixture() {
|
||||
today: "2026-10-06",
|
||||
configurations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: host, available: true }],
|
||||
assets: [{ id: host, notRetired: true }],
|
||||
resources: [{ id: resource, active: true, usage: "certificate", expiresOn: null }],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
};
|
||||
@ -213,7 +213,7 @@ test("certificados: inactividad conserva notas y permite retirada, impide reconf
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
mapCertificateInput(input, { ...context, assets: [{ id: host, available: false }] }).isSuccess,
|
||||
mapCertificateInput(input, { ...context, assets: [{ id: host, notRetired: false }] }).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
@ -77,7 +77,7 @@ assert.ok(use.isSuccess);
|
||||
const context: MailContext = {
|
||||
configurations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: asset, available: true }],
|
||||
assets: [{ id: asset, notRetired: true }],
|
||||
profiles: [workProfile],
|
||||
mailboxes: [box],
|
||||
uses: [use.data],
|
||||
@ -187,7 +187,7 @@ test("Correo: buzón independiente, vigencia, perfiles, varios buzones y duplica
|
||||
...context,
|
||||
configurations: [item],
|
||||
profiles: [workProfile, secondProfile],
|
||||
assets: [...context.assets, { id: device, available: true }],
|
||||
assets: [...context.assets, { id: device, notRetired: true }],
|
||||
}
|
||||
).isSuccess
|
||||
);
|
||||
|
||||
@ -49,7 +49,7 @@ function fixture() {
|
||||
configurations: [],
|
||||
profiles: [profile.data],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [host, printer, secondPrinter].map((id) => ({ id, available: true })),
|
||||
assets: [host, printer, secondPrinter].map((id) => ({ id, notRetired: true })),
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
};
|
||||
|
||||
@ -200,7 +200,7 @@ test("impresoras: indisponibilidad señala revisión y permite notas y retirada"
|
||||
assert.equal(buildPrinterResponse(root, inactive).needsReview, true);
|
||||
const unavailable = {
|
||||
...context,
|
||||
assets: context.assets.map((asset) => ({ ...asset, available: !asset.id.equals(printer) })),
|
||||
assets: context.assets.map((asset) => ({ ...asset, notRetired: !asset.id.equals(printer) })),
|
||||
};
|
||||
|
||||
assert.equal(mapPrinterInput(input, unavailable).isSuccess, false);
|
||||
|
||||
@ -237,7 +237,7 @@ const sources: ProcessReviewSources = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
personId: person.toPrimitive(),
|
||||
name: "Portátil",
|
||||
available: true,
|
||||
notRetired: true,
|
||||
},
|
||||
],
|
||||
profiles: [],
|
||||
|
||||
@ -41,8 +41,8 @@ const context: RemoteContext = {
|
||||
people: [{ id: person, active: true }],
|
||||
sites: [site],
|
||||
assets: [
|
||||
{ id: source, available: true },
|
||||
{ id: target, available: true },
|
||||
{ id: source, notRetired: true },
|
||||
{ id: target, notRetired: true },
|
||||
],
|
||||
resources: [{ id: resource, active: true }],
|
||||
actors: new Map(),
|
||||
@ -236,8 +236,8 @@ test("remotos: disponibilidad de origen y destino, retirada y método personaliz
|
||||
remoteNeedsReview(values(input), {
|
||||
...authorized,
|
||||
assets: [
|
||||
{ id: source, available: false },
|
||||
{ id: target, available: true },
|
||||
{ id: source, notRetired: false },
|
||||
{ id: target, notRetired: true },
|
||||
],
|
||||
}),
|
||||
true
|
||||
|
||||
@ -28,7 +28,7 @@ const input: SoftwareInstallationInput = {
|
||||
};
|
||||
const context: SoftwareContext = {
|
||||
configurations: [],
|
||||
assets: [{ id: asset, available: true }],
|
||||
assets: [{ id: asset, notRetired: true }],
|
||||
resources: [{ id: resource, active: true, expiresOn: null, usage: "os" }],
|
||||
actors: new Map([[actor.toPrimitive(), "David"]]),
|
||||
};
|
||||
@ -94,7 +94,7 @@ test("instalaciones: versiones, idempotencia, retirada, revisión y persistencia
|
||||
);
|
||||
|
||||
assert.ok(configured.isSuccess);
|
||||
const unavailable = { ...context, assets: [{ id: asset, available: false }] };
|
||||
const unavailable = { ...context, assets: [{ id: asset, notRetired: false }] };
|
||||
|
||||
assert.ok(buildSoftwareResponse(configured.data, unavailable).needsReview);
|
||||
assert.ok(
|
||||
@ -181,7 +181,7 @@ test("licencia del software: opcional, misma compañía y tipo, vigencia, desvin
|
||||
assert.ok(
|
||||
mapSoftwareInput(
|
||||
input,
|
||||
{ ...unavailableContext, assets: [{ id: asset, available: false }] },
|
||||
{ ...unavailableContext, assets: [{ id: asset, notRetired: false }] },
|
||||
linked.data
|
||||
).isSuccess
|
||||
);
|
||||
|
||||
@ -53,7 +53,7 @@ function context(): WorkProfileContext {
|
||||
return {
|
||||
profiles: [],
|
||||
people: [{ id: person, active: true }],
|
||||
assets: [{ id: asset, available: true, isProfileHost: true }],
|
||||
assets: [{ id: asset, notRetired: true, isProfileHost: true }],
|
||||
items: [],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
today: "2026-10-04",
|
||||
@ -113,7 +113,7 @@ test("Perfil: misma persona/dispositivo único vigente, múltiples dispositivos/
|
||||
assert.ok(
|
||||
mapWorkProfileInput(
|
||||
{ ...input(), assetId: second.toPrimitive() },
|
||||
{ ...ctx, assets: [...ctx.assets, { id: second, available: true, isProfileHost: true }] }
|
||||
{ ...ctx, assets: [...ctx.assets, { id: second, notRetired: true, isProfileHost: true }] }
|
||||
).isSuccess
|
||||
);
|
||||
assert.ok(
|
||||
@ -202,7 +202,7 @@ test("Perfil: solo un ordenador principal activo admite altas y reactivaciones;
|
||||
const previous = profile();
|
||||
const unavailable = {
|
||||
...context(),
|
||||
assets: [{ id: asset, available: true, isProfileHost: false }],
|
||||
assets: [{ id: asset, notRetired: true, isProfileHost: false }],
|
||||
};
|
||||
const result = mapWorkProfileInput(input(), unavailable);
|
||||
|
||||
|
||||
@ -114,7 +114,7 @@ export class SequelizeTicketRepository implements ITicketRepository {
|
||||
name: item.name,
|
||||
active: Boolean(item.active),
|
||||
})),
|
||||
assets: assets.map((item) => ({ id: item.id, name: item.name, active: item.available })),
|
||||
assets: assets.map((item) => ({ id: item.id, name: item.name, active: item.notRetired })),
|
||||
resources: resources.map((item) => ({ id: item.id, name: item.name, active: item.active })),
|
||||
workstations,
|
||||
};
|
||||
|
||||
@ -85,11 +85,13 @@ const field = {
|
||||
required: false,
|
||||
};
|
||||
const host = AssetSchema.parse({
|
||||
displayStatus: "available",
|
||||
assigned: false,
|
||||
...audited(5, {
|
||||
name: "LG Gram",
|
||||
templateId: id(9),
|
||||
siteId: null,
|
||||
status: "in_use",
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: { [field.fieldId]: false },
|
||||
}),
|
||||
|
||||
@ -122,7 +122,11 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
|
||||
(client) => {
|
||||
client.setQueryData(inventoryKeys.company(company.id), {
|
||||
assets: [
|
||||
{ id: computerId, companyId: company.id, values: { name: "PC-ADMIN", status: "in_use" } },
|
||||
{
|
||||
id: computerId,
|
||||
companyId: company.id,
|
||||
values: { name: "PC-ADMIN", status: "operational" },
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
workstations: [station],
|
||||
|
||||
@ -56,8 +56,8 @@ export function WorkstationAssets({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{asset.specification.templateName}
|
||||
</p>
|
||||
<EntityStatus active={asset.values.status !== "retired"}>
|
||||
{t(asset.values.status)}
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
<dl>
|
||||
{asset.specification.fields
|
||||
|
||||
Loading…
Reference in New Issue
Block a user