diff --git a/apps/server/src/asset-states.integration.ts b/apps/server/src/asset-states.integration.ts new file mode 100644 index 0000000..8298775 --- /dev/null +++ b/apps/server/src/asset-states.integration.ts @@ -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; + +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"); +} diff --git a/apps/server/src/brands.integration.ts b/apps/server/src/brands.integration.ts index 99af557..5be72f2 100644 --- a/apps/server/src/brands.integration.ts +++ b/apps/server/src/brands.integration.ts @@ -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" }, diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 6cd96c3..1c3c0b9 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -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 } diff --git a/apps/server/src/inventory.integration.ts b/apps/server/src/inventory.integration.ts index 7d91341..774cd6b 100644 --- a/apps/server/src/inventory.integration.ts +++ b/apps/server/src/inventory.integration.ts @@ -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" }, }); diff --git a/apps/server/src/mail.integration.ts b/apps/server/src/mail.integration.ts index 877bdef..e3f06f2 100644 --- a/apps/server/src/mail.integration.ts +++ b/apps/server/src/mail.integration.ts @@ -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()); diff --git a/apps/server/src/printers.integration.ts b/apps/server/src/printers.integration.ts index f556429..a9c6d14 100644 --- a/apps/server/src/printers.integration.ts +++ b/apps/server/src/printers.integration.ts @@ -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: {}, diff --git a/apps/server/src/relationships.integration.ts b/apps/server/src/relationships.integration.ts index 68a7260..85e475a 100644 --- a/apps/server/src/relationships.integration.ts +++ b/apps/server/src/relationships.integration.ts @@ -41,7 +41,7 @@ export async function verifyRelationships( values: { name: "MON-Foreign", templateId: template.id, - status: "available", + status: "operational", siteId: null, characteristics: {}, notes: "", diff --git a/apps/server/src/remote.integration.ts b/apps/server/src/remote.integration.ts index 376aca5..580882e 100644 --- a/apps/server/src/remote.integration.ts +++ b/apps/server/src/remote.integration.ts @@ -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; 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`, { diff --git a/apps/server/src/support.integration.ts b/apps/server/src/support.integration.ts index f9cf5d0..6f6d9a6 100644 --- a/apps/server/src/support.integration.ts +++ b/apps/server/src/support.integration.ts @@ -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; @@ -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" }, }); diff --git a/apps/server/src/unlink-asset.integration-helper.ts b/apps/server/src/unlink-asset.integration-helper.ts new file mode 100644 index 0000000..dbe24d8 --- /dev/null +++ b/apps/server/src/unlink-asset.integration-helper.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; + +import { InventoryResponseSchema } from "@metis/assets/common"; + +type Request = (path: string, body?: unknown) => Promise; + +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()); + } +} diff --git a/apps/server/src/work-profiles.integration.ts b/apps/server/src/work-profiles.integration.ts index 533147a..e5de9ac 100644 --- a/apps/server/src/work-profiles.integration.ts +++ b/apps/server/src/work-profiles.integration.ts @@ -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 diff --git a/apps/server/src/workstation-lifecycle.integration.ts b/apps/server/src/workstation-lifecycle.integration.ts index 3d6001b..97b964e 100644 --- a/apps/server/src/workstation-lifecycle.integration.ts +++ b/apps/server/src/workstation-lifecycle.integration.ts @@ -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()); diff --git a/apps/server/src/workstations.integration.ts b/apps/server/src/workstations.integration.ts index 890c99e..308cd18 100644 --- a/apps/server/src/workstations.integration.ts +++ b/apps/server/src/workstations.integration.ts @@ -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]; diff --git a/apps/web/src/test-fixtures/workspace.ts b/apps/web/src/test-fixtures/workspace.ts index 9e8ddf3..b9b8ee7 100644 --- a/apps/web/src/test-fixtures/workspace.ts +++ b/apps/web/src/test-fixtures/workspace.ts @@ -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", diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md index b60658d..6fd305c 100644 --- a/docs/inventario-de-activos.md +++ b/docs/inventario-de-activos.md @@ -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. diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index e479582..755bc40 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -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. diff --git a/docs/software-del-ordenador.md b/docs/software-del-ordenador.md index 705f052..b4d51da 100644 --- a/docs/software-del-ordenador.md +++ b/docs/software-del-ordenador.md @@ -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). diff --git a/modules/assets/src/api/application/asset-input.mapper.ts b/modules/assets/src/api/application/asset-input.mapper.ts index 8932b22..c418138 100644 --- a/modules/assets/src/api/application/asset-input.mapper.ts +++ b/modules/assets/src/api/application/asset-input.mapper.ts @@ -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) { diff --git a/modules/assets/src/api/application/asset-response.builder.ts b/modules/assets/src/api/application/asset-response.builder.ts index 9602713..60b55e3 100644 --- a/modules/assets/src/api/application/asset-response.builder.ts +++ b/modules/assets/src/api/application/asset-response.builder.ts @@ -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, + 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): 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(), diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts index 038e194..e186648 100644 --- a/modules/assets/src/api/application/asset.repository.ts +++ b/modules/assets/src/api/application/asset.repository.ts @@ -24,7 +24,16 @@ export interface IAssetRepository { remove( company: UniqueID, id: UniqueID - ): Promise }, AssetCatalogError>>; + ): Promise< + Result< + { + asset: Asset; + actorNames: ReadonlyMap; + workstations: readonly Workstation[]; + }, + AssetCatalogError + > + >; removeWorkstation(company: UniqueID, id: UniqueID): Promise>; removeRelationship(company: UniqueID, id: UniqueID): Promise>; saveWorkstation( @@ -46,7 +55,16 @@ export interface IAssetRepository { save( companyId: UniqueID, action: (context: InventoryContext) => Result - ): Promise }, AssetCatalogError>>; + ): Promise< + Result< + { + asset: Asset; + actorNames: ReadonlyMap; + workstations: readonly Workstation[]; + }, + AssetCatalogError + > + >; } export function validateAssetReferences(context: InventoryContext, values: AssetValues) { diff --git a/modules/assets/src/api/application/workstation-input.mapper.ts b/modules/assets/src/api/application/workstation-input.mapper.ts index da5c594..d4e01a2 100644 --- a/modules/assets/src/api/application/workstation-input.mapper.ts +++ b/modules/assets/src/api/application/workstation-input.mapper.ts @@ -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; +} diff --git a/modules/assets/src/api/domain/asset-state.ts b/modules/assets/src/api/domain/asset-state.ts new file mode 100644 index 0000000..89a8f67 --- /dev/null +++ b/modules/assets/src/api/domain/asset-state.ts @@ -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); +} diff --git a/modules/assets/src/api/domain/asset-values.ts b/modules/assets/src/api/domain/asset-values.ts index c18e8d2..271199e 100644 --- a/modules/assets/src/api/domain/asset-values.ts +++ b/modules/assets/src/api/domain/asset-values.ts @@ -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; + export const AssetSpecificationSchema = z.strictObject({ templateId: z.uuid(), templateName: z.string().min(1).max(100), diff --git a/modules/assets/src/api/domain/asset.test.ts b/modules/assets/src/api/domain/asset.test.ts index f52f022..036ce95 100644 --- a/modules/assets/src/api/domain/asset.test.ts +++ b/modules/assets/src/api/domain/asset.test.ts @@ -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)); +}); diff --git a/modules/assets/src/api/domain/template-sections.test.ts b/modules/assets/src/api/domain/template-sections.test.ts index 40a840c..2754e4e 100644 --- a/modules/assets/src/api/domain/template-sections.test.ts +++ b/modules/assets/src/api/domain/template-sections.test.ts @@ -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: {}, }; diff --git a/modules/assets/src/api/domain/value-objects/asset-values.vo.ts b/modules/assets/src/api/domain/value-objects/asset-values.vo.ts index d4ede35..43ce7ea 100644 --- a/modules/assets/src/api/domain/value-objects/asset-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/asset-values.vo.ts @@ -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, public readonly brandId: Maybe @@ -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 { - 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( diff --git a/modules/assets/src/api/infrastructure/http/asset.routes.ts b/modules/assets/src/api/infrastructure/http/asset.routes.ts index 8de376d..eda4ad0 100644 --- a/modules/assets/src/api/infrastructure/http/asset.routes.ts +++ b/modules/assets/src/api/infrastructure/http/asset.routes.ts @@ -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); }); diff --git a/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts b/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts index fda8d1c..ddb8b55 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts @@ -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, diff --git a/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts index 8f8a39e..e2c4b90 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper.ts @@ -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 ); diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts b/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts index 2c7d246..26dbe4f 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-relational-constraints.ts @@ -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)"); diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index cb55c05..181ec37 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -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, + }); }); } } diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index 6c0b799..fcc3e2c 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -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, }); } diff --git a/modules/assets/src/common/inventory.ts b/modules/assets/src/common/inventory.ts index 2bbe6c6..c289287 100644 --- a/modules/assets/src/common/inventory.ts +++ b/modules/assets/src/common/inventory.ts @@ -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; export type AssetResponseDTO = z.infer; diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index a3acdbf..73eaaf1 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -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." } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index fd3b682..4f031de 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -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." } diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index 316a8f2..5d4cdc4 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -38,7 +38,7 @@ export function AssetDetail(props: AssetDetailProps) { {asset.specification.templateName} - {t(asset.values.status)} + {t(asset.displayStatus)} )} diff --git a/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx index e5484ff..5cc9c94 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx @@ -29,8 +29,8 @@ export function AssetPanelSummary(props: AssetDetailProps) {
{t("status")}
- - {t(asset.values.status)} + + {t(asset.displayStatus)}
diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index 9f06a0c..e5159a5 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -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())); }); diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 6b04f5b..54f6b84 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -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) => ( - - {t(asset.values.status)} + + {t(asset.displayStatus)} ), }, diff --git a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts index 74e6da9..40b382c 100644 --- a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts +++ b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts @@ -27,7 +27,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass name: "", templateId: "", siteId: null, - status: "available", + status: "operational", notes: "", characteristics: {}, }, diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index 907a3ac..284f8d6 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -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"]) { diff --git a/modules/assets/src/web/assets/shared/ui/asset-form.tsx b/modules/assets/src/web/assets/shared/ui/asset-form.tsx index 0626547..8bba7bb 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-form.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-form.tsx @@ -115,11 +115,12 @@ export function AssetForm(props: AssetFormProps) { />
- + ({ value, @@ -129,6 +130,9 @@ export function AssetForm(props: AssetFormProps) { required value={values.status} /> +

+ {t("technicalStatusHelp")} +

diff --git a/modules/assets/src/web/brands/shared/brands.test.tsx b/modules/assets/src/web/brands/shared/brands.test.tsx index e3d1f66..99abacd 100644 --- a/modules/assets/src/web/brands/shared/brands.test.tsx +++ b/modules/assets/src/web/brands/shared/brands.test.tsx @@ -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( diff --git a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts index 7be27f0..ce069b4 100644 --- a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts +++ b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts @@ -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, diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index c4d732a..8e2d546 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -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) {

{assets.find((item) => item.id === id)?.values.name ?? id}

{unavailable(field, id) && (

- {t("inventoryErrors.ASSET_IN_WORKSTATION")} + {t(`inventoryErrors.${unavailableReason(field, id)}`)}

)}
diff --git a/modules/companies/src/api/application/application-configuration-policy.ts b/modules/companies/src/api/application/application-configuration-policy.ts index 400ffdb..10cb0f2 100644 --- a/modules/companies/src/api/application/application-configuration-policy.ts +++ b/modules/companies/src/api/application/application-configuration-policy.ts @@ -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" ) diff --git a/modules/companies/src/api/application/application-configuration.repository.ts b/modules/companies/src/api/application/application-configuration.repository.ts index f1855c1..64e508f 100644 --- a/modules/companies/src/api/application/application-configuration.repository.ts +++ b/modules/companies/src/api/application/application-configuration.repository.ts @@ -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; diff --git a/modules/companies/src/api/application/certificate-configuration-policy.ts b/modules/companies/src/api/application/certificate-configuration-policy.ts index 271755d..bdfda14 100644 --- a/modules/companies/src/api/application/certificate-configuration-policy.ts +++ b/modules/companies/src/api/application/certificate-configuration-policy.ts @@ -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) && diff --git a/modules/companies/src/api/application/certificate-configuration.repository.ts b/modules/companies/src/api/application/certificate-configuration.repository.ts index 0441ee4..e362d6d 100644 --- a/modules/companies/src/api/application/certificate-configuration.repository.ts +++ b/modules/companies/src/api/application/certificate-configuration.repository.ts @@ -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; diff --git a/modules/companies/src/api/application/folder-mapping-policy.ts b/modules/companies/src/api/application/folder-mapping-policy.ts index 9fa894f..7899052 100644 --- a/modules/companies/src/api/application/folder-mapping-policy.ts +++ b/modules/companies/src/api/application/folder-mapping-policy.ts @@ -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) && diff --git a/modules/companies/src/api/application/folder-mapping.repository.ts b/modules/companies/src/api/application/folder-mapping.repository.ts index 1a4fafc..0eb4823 100644 --- a/modules/companies/src/api/application/folder-mapping.repository.ts +++ b/modules/companies/src/api/application/folder-mapping.repository.ts @@ -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; today: string; }>; diff --git a/modules/companies/src/api/application/mail-configuration-policy.ts b/modules/companies/src/api/application/mail-configuration-policy.ts index ee2a750..3c89cdd 100644 --- a/modules/companies/src/api/application/mail-configuration-policy.ts +++ b/modules/companies/src/api/application/mail-configuration-policy.ts @@ -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; diff --git a/modules/companies/src/api/application/mail-configuration.repository.ts b/modules/companies/src/api/application/mail-configuration.repository.ts index 674af9d..07bfdec 100644 --- a/modules/companies/src/api/application/mail-configuration.repository.ts +++ b/modules/companies/src/api/application/mail-configuration.repository.ts @@ -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[]>; + list(companyId: UniqueID): Promise[]>; } 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; today: string; }>; diff --git a/modules/companies/src/api/application/printer-configuration-policy.ts b/modules/companies/src/api/application/printer-configuration-policy.ts index 1a243e3..eac4c12 100644 --- a/modules/companies/src/api/application/printer-configuration-policy.ts +++ b/modules/companies/src/api/application/printer-configuration-policy.ts @@ -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) ); } diff --git a/modules/companies/src/api/application/printer-configuration.repository.ts b/modules/companies/src/api/application/printer-configuration.repository.ts index 82513b9..bdaf2c7 100644 --- a/modules/companies/src/api/application/printer-configuration.repository.ts +++ b/modules/companies/src/api/application/printer-configuration.repository.ts @@ -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; }>; diff --git a/modules/companies/src/api/application/process-review.builder.ts b/modules/companies/src/api/application/process-review.builder.ts index c385275..5dc69ce 100644 --- a/modules/companies/src/api/application/process-review.builder.ts +++ b/modules/companies/src/api/application/process-review.builder.ts @@ -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) { diff --git a/modules/companies/src/api/application/process.repository.ts b/modules/companies/src/api/application/process.repository.ts index 92e668f..e0b28da 100644 --- a/modules/companies/src/api/application/process.repository.ts +++ b/modules/companies/src/api/application/process.repository.ts @@ -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 }[] >; } diff --git a/modules/companies/src/api/application/remote-connection-policy.ts b/modules/companies/src/api/application/remote-connection-policy.ts index 7fcf16f..9321713 100644 --- a/modules/companies/src/api/application/remote-connection-policy.ts +++ b/modules/companies/src/api/application/remote-connection-policy.ts @@ -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)) ); diff --git a/modules/companies/src/api/application/remote-connection.repository.ts b/modules/companies/src/api/application/remote-connection.repository.ts index d377496..19fccab 100644 --- a/modules/companies/src/api/application/remote-connection.repository.ts +++ b/modules/companies/src/api/application/remote-connection.repository.ts @@ -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; today: string; diff --git a/modules/companies/src/api/application/software-installation-policy.ts b/modules/companies/src/api/application/software-installation-policy.ts index 2255da6..0a2266b 100644 --- a/modules/companies/src/api/application/software-installation-policy.ts +++ b/modules/companies/src/api/application/software-installation-policy.ts @@ -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) && diff --git a/modules/companies/src/api/application/software-installation.repository.ts b/modules/companies/src/api/application/software-installation.repository.ts index 75be578..202cd1b 100644 --- a/modules/companies/src/api/application/software-installation.repository.ts +++ b/modules/companies/src/api/application/software-installation.repository.ts @@ -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; diff --git a/modules/companies/src/api/application/ticket.repository.ts b/modules/companies/src/api/application/ticket.repository.ts index 93f8f9e..0c11d42 100644 --- a/modules/companies/src/api/application/ticket.repository.ts +++ b/modules/companies/src/api/application/ticket.repository.ts @@ -28,7 +28,7 @@ export type TicketSaveContext = TicketContext & { }; export interface ITicketInventoryService { - list(company: UniqueID): Promise; + list(company: UniqueID): Promise; workstations( company: UniqueID ): Promise; diff --git a/modules/companies/src/api/application/work-profile-input.mapper.ts b/modules/companies/src/api/application/work-profile-input.mapper.ts index 839593b..91430a5 100644 --- a/modules/companies/src/api/application/work-profile-input.mapper.ts +++ b/modules/companies/src/api/application/work-profile-input.mapper.ts @@ -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 ) ) ) { diff --git a/modules/companies/src/api/application/work-profile.repository.ts b/modules/companies/src/api/application/work-profile.repository.ts index 709956d..7e4ce10 100644 --- a/modules/companies/src/api/application/work-profile.repository.ts +++ b/modules/companies/src/api/application/work-profile.repository.ts @@ -8,13 +8,13 @@ import type { WorkProfileError } from "../domain/work-profile-values"; export interface IWorkProfileInventoryService { list( companyId: UniqueID - ): Promise; + ): Promise; } 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; today: string; diff --git a/modules/companies/src/api/domain/application-configuration.test.ts b/modules/companies/src/api/domain/application-configuration.test.ts index 21ff32b..1d3553e 100644 --- a/modules/companies/src/api/domain/application-configuration.test.ts +++ b/modules/companies/src/api/domain/application-configuration.test.ts @@ -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 ); }); diff --git a/modules/companies/src/api/domain/certificate-configuration.test.ts b/modules/companies/src/api/domain/certificate-configuration.test.ts index 08b2d03..0e32d30 100644 --- a/modules/companies/src/api/domain/certificate-configuration.test.ts +++ b/modules/companies/src/api/domain/certificate-configuration.test.ts @@ -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 ); }); diff --git a/modules/companies/src/api/domain/mail-configuration.test.ts b/modules/companies/src/api/domain/mail-configuration.test.ts index daabd74..6171be3 100644 --- a/modules/companies/src/api/domain/mail-configuration.test.ts +++ b/modules/companies/src/api/domain/mail-configuration.test.ts @@ -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 ); diff --git a/modules/companies/src/api/domain/printer-configuration.test.ts b/modules/companies/src/api/domain/printer-configuration.test.ts index d9b5b86..b1dfd3a 100644 --- a/modules/companies/src/api/domain/printer-configuration.test.ts +++ b/modules/companies/src/api/domain/printer-configuration.test.ts @@ -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); diff --git a/modules/companies/src/api/domain/process.test.ts b/modules/companies/src/api/domain/process.test.ts index f2a8269..d9d2eab 100644 --- a/modules/companies/src/api/domain/process.test.ts +++ b/modules/companies/src/api/domain/process.test.ts @@ -237,7 +237,7 @@ const sources: ProcessReviewSources = { id: UniqueID.generateNewID().toPrimitive(), personId: person.toPrimitive(), name: "Portátil", - available: true, + notRetired: true, }, ], profiles: [], diff --git a/modules/companies/src/api/domain/remote-connection.test.ts b/modules/companies/src/api/domain/remote-connection.test.ts index b8ad85b..0339391 100644 --- a/modules/companies/src/api/domain/remote-connection.test.ts +++ b/modules/companies/src/api/domain/remote-connection.test.ts @@ -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 diff --git a/modules/companies/src/api/domain/software-installation.test.ts b/modules/companies/src/api/domain/software-installation.test.ts index b46e36e..7194632 100644 --- a/modules/companies/src/api/domain/software-installation.test.ts +++ b/modules/companies/src/api/domain/software-installation.test.ts @@ -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 ); diff --git a/modules/companies/src/api/domain/work-profile.test.ts b/modules/companies/src/api/domain/work-profile.test.ts index 9c9118e..95ea27d 100644 --- a/modules/companies/src/api/domain/work-profile.test.ts +++ b/modules/companies/src/api/domain/work-profile.test.ts @@ -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); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts index a94a690..ac70a32 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-ticket.repository.ts @@ -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, }; diff --git a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx index d420fde..6e3879c 100644 --- a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx +++ b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx @@ -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 }, }), diff --git a/modules/companies/src/web/work-profiles/shared/profile.test.tsx b/modules/companies/src/web/work-profiles/shared/profile.test.tsx index f8cd4ad..f97bfb1 100644 --- a/modules/companies/src/web/work-profiles/shared/profile.test.tsx +++ b/modules/companies/src/web/work-profiles/shared/profile.test.tsx @@ -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], diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx index df789a7..e56b1a4 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx @@ -56,8 +56,8 @@ export function WorkstationAssets({

{asset.specification.templateName}

- - {t(asset.values.status)} + + {t(asset.displayStatus)}
{asset.specification.fields