feat: update asset status handling and improve UI labels

- Changed asset status label from "status" to "technicalStatus" in AssetForm component.
- Added help text for technical status in AssetForm.
- Updated test cases to reflect changes in asset status from "available" to "operational".
- Enhanced workstation form to utilize new asset status checks and display appropriate messages.
- Refactored asset context to replace "available" with "notRetired" across various application policies and repositories.
- Introduced new utility functions for asset status management, including technical status determination and assignment validation.
- Added integration tests for asset state verification and unlinking assets from workstations.
This commit is contained in:
David Arranz 2026-10-13 11:35:31 +02:00
parent bf02e56d76
commit 002781b56d
76 changed files with 722 additions and 137 deletions

View File

@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import type { AssetInput } from "@metis/assets/common";
import {
AssetResponseSchema,
InventoryResponseSchema,
WorkstationResponseSchema,
} from "@metis/assets/common";
import {
SoftwareInstallationResponseSchema,
SoftwareInstallationsResponseSchema,
} from "@metis/companies/common";
import {
CatalogResponseSchema as ResourceCatalogSchema,
ResourceResponseSchema,
} from "@metis/resources/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { enforceAssetRelationalConstraints } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relational-constraints";
type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyAssetStates(request: Request, companyId: string, database: Sequelize) {
const path = `/assets/${companyId}`;
const source = InventoryResponseSchema.parse(await (await request(path)).json()).assets[0];
assert.ok(source);
const response = await request(path, {
values: { ...source.values, status: "operational", name: "Estado técnico prueba" },
});
assert.equal(response.status, 201, await response.clone().text());
const original = AssetResponseSchema.parse(await response.json()).asset;
const current = async () => {
const asset = InventoryResponseSchema.parse(await (await request(path)).json()).assets.find(
(item) => item.id === original.id
);
assert.ok(asset);
return asset;
};
const state = async (status: AssetInput["status"]) => {
const previous = await current();
const result = await request(`${path}/${original.id}/update`, {
values: { ...previous.values, status },
});
assert.equal(result.status, 200, await result.clone().text());
return AssetResponseSchema.parse(await result.json()).asset;
};
const stationValues = {
name: "Uso compartido prueba",
active: true,
siteId: null,
personId: null,
assetIds: [] as string[],
sharedAssetIds: [original.id],
};
const station = async (name: string) => {
const result = await request(`${path}/workstations`, { values: { ...stationValues, name } });
assert.equal(result.status, 201, await result.clone().text());
return WorkstationResponseSchema.parse(await result.json()).workstation;
};
assert.equal(original.displayStatus, "available");
assert.equal(original.assigned, false);
const first = await station("Compartido A");
const second = await station("Compartido B");
const assigned = await current();
assert.equal(assigned.values.status, "operational");
assert.equal(assigned.displayStatus, "in_use");
assert.equal(assigned.assigned, true);
assert.equal(assigned.history.length, 1);
const repairing = await state("maintenance");
assert.equal(repairing.displayStatus, "maintenance");
assert.equal(repairing.assigned, true);
const resourceCatalog = ResourceCatalogSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json()
);
const osTemplate = resourceCatalog.items.find((item) => item.builtinKey === "template:os");
assert.ok(osTemplate);
const osResponse = await request(`/resources/${companyId}`, {
values: {
name: "SO durante reparación",
templateId: osTemplate.id,
active: true,
notes: "",
characteristics: {},
},
});
assert.equal(osResponse.status, 201);
const os = ResourceResponseSchema.parse(await osResponse.json()).resource;
const installationResponse = await request(`/software-installations/${companyId}`, {
values: {
assetId: original.id,
resourceId: os.id,
licenseResourceId: null,
version: "24H2",
state: "configured",
notes: null,
},
});
assert.equal(installationResponse.status, 201, await installationResponse.clone().text());
const installation = SoftwareInstallationResponseSchema.parse(
await installationResponse.json()
).configuration;
assert.equal(installation.needsReview, false);
assert.equal(
(
await request(`${path}/workstations/${first.id}/update`, {
values: { ...first.values, name: "Puesto durante reparación" },
})
).status,
200
);
for (const status of ["maintenance", "broken"] as const) {
await state(status);
const blocked = await request(`${path}/workstations`, {
values: { ...stationValues, name: "Asignación no permitida" },
});
assert.equal(blocked.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await blocked.json()).code,
"ASSET_NOT_OPERATIONAL"
);
}
const retiredWhileAssigned = await request(`${path}/${original.id}/update`, {
values: { ...(await current()).values, status: "retired" },
});
assert.equal(retiredWhileAssigned.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await retiredWhileAssigned.json()).code,
"ASSET_ASSIGNED"
);
assert.equal((await current()).values.status, "broken");
await state("operational");
assert.equal((await request(`${path}/workstations/${first.id}/decommission`, {})).status, 200);
assert.equal((await current()).displayStatus, "in_use");
assert.equal((await request(`${path}/workstations/${second.id}/delete`, {})).status, 200);
assert.equal((await current()).displayStatus, "available");
assert.equal((await current()).assigned, false);
const retired = await state("retired");
assert.equal(retired.displayStatus, "retired");
const installations = SoftwareInstallationsResponseSchema.parse(
await (await request(`/software-installations/${companyId}`)).json()
);
assert.equal(
installations.configurations.find((item) => item.id === installation.id)?.needsReview,
true
);
assert.equal((await request(`${path}/workstations`, { values: stationValues })).status, 409);
for (const status of ["in_use", "available"]) {
assert.equal(
(await request(`${path}/${original.id}/update`, { values: { ...retired.values, status } }))
.status,
400
);
}
// Datos históricos originales: no se convierten en asignaciones ficticias ni se reescriben.
await state("operational");
const before = await current();
const revisionId = before.history.at(-1)?.id;
assert.ok(revisionId);
await database.transaction(async (transaction) => {
await database.query("UPDATE assets SET status='in_use' WHERE id=:id", {
replacements: { id: original.id },
transaction,
});
await database.query("UPDATE asset_revisions SET status='in_use' WHERE id=:id", {
replacements: { id: revisionId },
transaction,
});
});
await enforceAssetRelationalConstraints(database);
const historical = await current();
assert.equal(historical.values.status, "operational");
assert.equal(historical.displayStatus, "available");
assert.equal(historical.history.at(-1)?.values.status, "in_use");
const repeated = await request(`${path}/${original.id}/update`, { values: historical.values });
assert.equal(repeated.status, 200);
assert.deepEqual(
AssetResponseSchema.parse(await repeated.json()).asset.history,
historical.history
);
const stored = await database.query<{ status: string }>(
"SELECT status FROM asset_revisions WHERE id=:id",
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
);
assert.equal(stored[0]?.status, "in_use");
}

View File

@ -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" },

View File

@ -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 }

View File

@ -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" },
});

View File

@ -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());

View File

@ -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: {},

View File

@ -41,7 +41,7 @@ export async function verifyRelationships(
values: {
name: "MON-Foreign",
templateId: template.id,
status: "available",
status: "operational",
siteId: null,
characteristics: {},
notes: "",

View File

@ -13,6 +13,8 @@ import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { unlinkAssetFromWorkstations } from "./unlink-asset.integration-helper";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyRemoteConnections(
@ -212,6 +214,7 @@ export async function verifyRemoteConnections(
);
assert.equal((await current(mobile.id)).needsReview, true);
assert.equal((await current(created.id)).needsReview, false);
await unlinkAssetFromWorkstations(request, companyId, host.id);
assert.equal(
(
await request(`/assets/${companyId}/${host.id}/update`, {

View File

@ -15,6 +15,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { verifySupportResourceMigration } from "./support-resource-migration.integration";
import { unlinkAssetFromWorkstations } from "./unlink-asset.integration-helper";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
@ -232,6 +233,7 @@ export async function verifySupport(
assert.equal(listed.connections.length, 2);
assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1);
await unlinkAssetFromWorkstations(request, companyId, host.id);
const retired = await request(`/assets/${companyId}/${host.id}/update`, {
values: { ...host.values, status: "retired" },
});

View File

@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { InventoryResponseSchema } from "@metis/assets/common";
type Request = (path: string, body?: unknown) => Promise<Response>;
export async function unlinkAssetFromWorkstations(
request: Request,
companyId: string,
assetId: string
) {
const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
for (const station of inventory.workstations.filter(
(item) =>
item.values.active &&
[...item.values.assetIds, ...item.values.sharedAssetIds].includes(assetId)
)) {
const response = await request(`/assets/${companyId}/workstations/${station.id}/update`, {
values: {
...station.values,
computerAssetId:
station.values.computerAssetId === assetId ? null : station.values.computerAssetId,
assetIds: station.values.assetIds.filter((id) => id !== assetId),
sharedAssetIds: station.values.sharedAssetIds.filter((id) => id !== assetId),
},
});
assert.equal(response.status, 200, await response.clone().text());
}
}

View File

@ -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

View File

@ -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());

View File

@ -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];

View File

@ -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",

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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) {

View File

@ -1,12 +1,28 @@
import type { AssetDTO } from "../../common/inventory";
import type { Asset } from "../domain/asset.aggregate";
import { assetDisplayStatus } from "../domain/asset-state";
import type { Workstation } from "../domain/workstation.aggregate";
export function buildAssetResponse(
asset: Asset,
actors: ReadonlyMap<string, string>,
workstations: readonly Workstation[] = []
): AssetDTO {
const assigned = workstations.some(
(station) =>
station.values.active &&
[...station.values.assetIds, ...station.values.sharedAssetIds].some((id) =>
id.equals(asset.id)
)
);
export function buildAssetResponse(asset: Asset, actors: ReadonlyMap<string, string>): AssetDTO {
return {
id: asset.id.toPrimitive(),
companyId: asset.companyId.toPrimitive(),
specification: asset.specification.toPrimitive(),
values: asset.values.toPrimitive(),
values: { ...asset.values.toPrimitive(), status: asset.values.technicalStatus },
assigned,
displayStatus: assetDisplayStatus(asset.values.technicalStatus, assigned),
history: asset.history.map((revision) => ({
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),

View File

@ -24,7 +24,16 @@ export interface IAssetRepository {
remove(
company: UniqueID,
id: UniqueID
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
): Promise<
Result<
{
asset: Asset;
actorNames: ReadonlyMap<string, string>;
workstations: readonly Workstation[];
},
AssetCatalogError
>
>;
removeWorkstation(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation(
@ -46,7 +55,16 @@ export interface IAssetRepository {
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
): Promise<
Result<
{
asset: Asset;
actorNames: ReadonlyMap<string, string>;
workstations: readonly Workstation[];
},
AssetCatalogError
>
>;
}
export function validateAssetReferences(context: InventoryContext, values: AssetValues) {

View File

@ -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;
}

View File

@ -0,0 +1,18 @@
import type { AssetInput, AssetRecordedInput } from "./asset-values";
export function assetTechnicalStatus(status: AssetRecordedInput["status"]): AssetInput["status"] {
return status === "available" || status === "in_use" ? "operational" : status;
}
export function assetDisplayStatus(status: AssetInput["status"], assigned: boolean) {
return status === "operational" ? (assigned ? "in_use" : "available") : status;
}
export function canAssignAsset(
status: AssetInput["status"],
kind: "exclusive" | "shared",
exclusiveElsewhere: boolean,
sharedElsewhere: boolean
) {
return status === "operational" && !exclusiveElsewhere && (kind === "shared" || !sharedElsewhere);
}

View File

@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { FieldTypeSchema, TemplateSectionSchema } from "./asset-definition";
export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]);
export const AssetStatusSchema = z.enum(["operational", "broken", "maintenance", "retired"]);
export const AssetCharacteristicSchema = z.strictObject({
fieldId: z.uuid(),
@ -26,6 +26,13 @@ export const AssetInputSchema = z.strictObject({
characteristics: z.record(z.uuid(), AssetValueSchema),
});
// Las revisiones conservan el estado registrado en su momento.
export const AssetRecordedInputSchema = AssetInputSchema.extend({
status: z.enum(["available", "in_use", ...AssetStatusSchema.options]),
});
export type AssetRecordedInput = z.infer<typeof AssetRecordedInputSchema>;
export const AssetSpecificationSchema = z.strictObject({
templateId: z.uuid(),
templateName: z.string().min(1).max(100),

View File

@ -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));
});

View File

@ -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: {},
};

View File

@ -3,8 +3,14 @@ import { Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { AssetCatalogError } from "../asset-definition";
import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values";
import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values";
import { assetTechnicalStatus } from "../asset-state";
import type {
AssetCharacteristic,
AssetInput,
AssetRecordedInput,
AssetSpecificationData,
} from "../asset-values";
import { AssetRecordedInputSchema, AssetSpecificationSchema } from "../asset-values";
import {
freezeTemplateSections,
sameTemplateSections,
@ -117,7 +123,7 @@ function validCharacteristic(field: AssetCharacteristic, value: unknown) {
export class AssetValues {
private constructor(
private readonly data: AssetInput,
private readonly data: AssetRecordedInput,
public readonly name: Name,
public readonly siteId: Maybe<UniqueID>,
public readonly brandId: Maybe<UniqueID>
@ -129,11 +135,15 @@ export class AssetValues {
return this.data;
}
get technicalStatus() {
return assetTechnicalStatus(this.data.status);
}
static create(
input: AssetInput,
input: AssetRecordedInput,
specification: AssetSpecification
): Result<AssetValues, AssetCatalogError> {
const parsed = AssetInputSchema.safeParse(input);
const parsed = AssetRecordedInputSchema.safeParse(input);
const name = Name.create(input.name, 100);
if (
@ -181,7 +191,7 @@ export class AssetValues {
first.templateId === second.templateId &&
first.siteId === second.siteId &&
(first.brandId ?? null) === (second.brandId ?? null) &&
first.status === second.status &&
this.technicalStatus === other.technicalStatus &&
first.notes === second.notes &&
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
Object.entries(first.characteristics).every(

View File

@ -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);
});

View File

@ -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,

View File

@ -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
);

View File

@ -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)");

View File

@ -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,
});
});
}
}

View File

@ -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,
});
}

View File

@ -2,6 +2,7 @@ import { z } from "zod/v4";
import {
AssetInputSchema,
AssetRecordedInputSchema,
AssetSpecificationSchema,
AssetStatusSchema,
AssetValueSchema,
@ -22,7 +23,7 @@ export const SaveAssetRequestSchema = z.strictObject({ values: AssetInputSchema
export const AssetRevisionSchema = z.strictObject({
id: z.uuid(),
values: AssetInputSchema,
values: AssetRecordedInputSchema,
actorId: z.uuid(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
@ -33,6 +34,8 @@ export const AssetSchema = z.strictObject({
companyId: z.uuid(),
specification: AssetSpecificationSchema,
values: AssetInputSchema,
displayStatus: z.enum(["available", "in_use", "broken", "maintenance", "retired"]),
assigned: z.boolean(),
history: z.array(AssetRevisionSchema).min(1),
});
@ -44,6 +47,8 @@ export const InventoryResponseSchema = z.strictObject({
export const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
export { assetTechnicalStatus, canAssignAsset } from "../api/domain/asset-state";
export type AssetDTO = z.infer<typeof AssetSchema>;
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;

View File

@ -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."
}

View File

@ -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."
}

View File

@ -38,7 +38,7 @@ export function AssetDetail(props: AssetDetailProps) {
{asset.specification.templateName}
</BrowserLink>
</DetailField>
<DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
<DetailField label={t("status")}>{t(asset.displayStatus)}</DetailField>
</dl>
</DetailSection>
)}

View File

@ -29,8 +29,8 @@ export function AssetPanelSummary(props: AssetDetailProps) {
<div>
<dt className="sr-only">{t("status")}</dt>
<dd>
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
</dd>
</div>

View File

@ -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()));
});

View File

@ -140,10 +140,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
{
id: "state",
label: t("status"),
value: (asset) => t(asset.values.status),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
),
},

View File

@ -27,7 +27,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
name: "",
templateId: "",
siteId: null,
status: "available",
status: "operational",
notes: "",
characteristics: {},
},

View File

@ -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"]) {

View File

@ -115,11 +115,12 @@ export function AssetForm(props: AssetFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
<Label htmlFor={`${prefix}-status`}>{t("technicalStatus")}</Label>
<SelectWithAction
describedBy={`${prefix}-status-help`}
disabled={mutation.isPending}
id={`${prefix}-status`}
label={t("status")}
label={t("technicalStatus")}
onValueChange={changeStatus}
options={AssetStatusSchema.options.map((value) => ({
value,
@ -129,6 +130,9 @@ export function AssetForm(props: AssetFormProps) {
required
value={values.status}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-status-help`}>
{t("technicalStatusHelp")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>

View File

@ -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(

View File

@ -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,

View File

@ -26,6 +26,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
catalog,
assets,
unavailable,
unavailableReason,
add,
selectAssociation,
mutation,
@ -127,9 +128,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
)
.map((item) => ({
value: item.id,
label: unavailable(field, item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
label:
unavailableReason(field, item.id) === "ASSET_NOT_OPERATIONAL"
? `${item.values.name} · ${t(item.values.status)}`
: unavailable(field, item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: unavailable(field, item.id),
}))}
placeholder={t("chooseAsset")}
@ -146,7 +150,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{unavailable(field, id) && (
<p className="text-destructive">
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
{t(`inventoryErrors.${unavailableReason(field, id)}`)}
</p>
)}
</div>

View File

@ -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"
)

View File

@ -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;

View File

@ -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) &&

View File

@ -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;

View File

@ -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) &&

View File

@ -11,7 +11,7 @@ export type FolderContext = Readonly<{
profiles: readonly WorkProfile[];
items: readonly { id: UniqueID; values: AccessValues }[];
people: readonly { id: UniqueID; active: boolean }[];
assets: readonly { id: UniqueID; available: boolean }[];
assets: readonly { id: UniqueID; notRetired: boolean }[];
actors: ReadonlyMap<string, string>;
today: string;
}>;

View File

@ -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;

View File

@ -8,7 +8,7 @@ import type { WorkProfile } from "../domain/work-profile.aggregate";
import type { MailboxContext } from "./mailbox.repository";
export interface IMailInventoryService {
list(companyId: UniqueID): Promise<readonly Readonly<{ id: UniqueID; available: boolean }>[]>;
list(companyId: UniqueID): Promise<readonly Readonly<{ id: UniqueID; notRetired: boolean }>[]>;
}
export type MailContext = Readonly<{
@ -17,7 +17,7 @@ export type MailContext = Readonly<{
mailboxes: MailboxContext["mailboxes"];
uses: MailboxContext["uses"];
profiles: readonly WorkProfile[];
assets: readonly Readonly<{ id: UniqueID; available: boolean }>[];
assets: readonly Readonly<{ id: UniqueID; notRetired: boolean }>[];
actors: ReadonlyMap<string, string>;
today: string;
}>;

View File

@ -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)
);
}

View File

@ -9,7 +9,7 @@ export type PrinterContext = Readonly<{
configurations: readonly PrinterConfiguration[];
profiles: readonly WorkProfile[];
people: readonly { id: UniqueID; active: boolean }[];
assets: readonly { id: UniqueID; available: boolean }[];
assets: readonly { id: UniqueID; notRetired: boolean }[];
actors: ReadonlyMap<string, string>;
}>;

View File

@ -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) {

View File

@ -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 }[]
>;
}

View File

@ -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))
);

View File

@ -10,7 +10,7 @@ export type RemoteContext = Readonly<{
authorizations: readonly TeleworkAuthorization[];
people: readonly { id: UniqueID; active: boolean }[];
sites: readonly UniqueID[];
assets: readonly { id: UniqueID; available: boolean }[];
assets: readonly { id: UniqueID; notRetired: boolean }[];
resources: readonly { id: UniqueID; active: boolean }[];
actors: ReadonlyMap<string, string>;
today: string;

View File

@ -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) &&

View File

@ -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;

View File

@ -28,7 +28,7 @@ export type TicketSaveContext = TicketContext & {
};
export interface ITicketInventoryService {
list(company: UniqueID): Promise<readonly { id: UniqueID; name: string; available: boolean }[]>;
list(company: UniqueID): Promise<readonly { id: UniqueID; name: string; notRetired: boolean }[]>;
workstations(
company: UniqueID
): Promise<readonly { id: UniqueID; name: string; active: boolean }[]>;

View File

@ -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
)
)
) {

View File

@ -8,13 +8,13 @@ import type { WorkProfileError } from "../domain/work-profile-values";
export interface IWorkProfileInventoryService {
list(
companyId: UniqueID
): Promise<readonly { id: UniqueID; available: boolean; isProfileHost?: boolean }[]>;
): Promise<readonly { id: UniqueID; notRetired: boolean; isProfileHost?: boolean }[]>;
}
export type WorkProfileContext = Readonly<{
profiles: readonly WorkProfile[];
people: readonly { id: UniqueID; active: boolean }[];
assets: readonly { id: UniqueID; available: boolean; isProfileHost?: boolean }[];
assets: readonly { id: UniqueID; notRetired: boolean; isProfileHost?: boolean }[];
items: readonly { id: UniqueID; values: AccessDefinition }[];
actors: ReadonlyMap<string, string>;
today: string;

View File

@ -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
);
});

View File

@ -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
);
});

View File

@ -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
);

View File

@ -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);

View File

@ -237,7 +237,7 @@ const sources: ProcessReviewSources = {
id: UniqueID.generateNewID().toPrimitive(),
personId: person.toPrimitive(),
name: "Portátil",
available: true,
notRetired: true,
},
],
profiles: [],

View File

@ -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

View File

@ -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
);

View File

@ -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);

View File

@ -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,
};

View File

@ -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 },
}),

View File

@ -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],

View File

@ -56,8 +56,8 @@ export function WorkstationAssets({
<p className="text-sm text-muted-foreground">
{asset.specification.templateName}
</p>
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
<dl>
{asset.specification.fields