From 867d5ec9c1f2ef552975345a29931fbffa720dce Mon Sep 17 00:00:00 2001
From: David
Date: Sun, 4 Oct 2026 21:04:14 +0200
Subject: [PATCH] Puestos de trabajo
---
apps/server/src/assets.integration.ts | 2 +-
apps/server/src/identity.test.ts | 28 ++
apps/server/src/relationships.integration.ts | 155 ++++++++++
apps/server/src/workstations.integration.ts | 136 +++++++++
apps/web/src/app.test.tsx | 2 +
.../modelo-ddd-e-identificadores.md | 4 +
docs/campos-y-plantillas-de-activos.md | 8 +-
docs/inventario-de-activos.md | 2 +-
docs/plan-de-desarrollo.md | 11 +-
docs/puestos-de-trabajo.md | 33 +++
docs/relaciones-entre-activos.md | 29 ++
modules/assets/package.json | 2 +-
.../asset-relationship-response.builder.ts | 19 ++
.../asset-relationship.use-cases.ts | 80 ++++++
.../src/api/application/asset.repository.ts | 19 ++
.../application/workstation-input.mapper.ts | 47 +++
.../workstation-response.builder.ts | 19 ++
.../api/application/workstation.use-cases.ts | 36 +++
.../src/api/domain/asset-catalog-seed.ts | 9 +
.../src/api/domain/asset-catalog.test.ts | 4 +-
.../src/api/domain/asset-definition.entity.ts | 7 +
.../assets/src/api/domain/asset-definition.ts | 7 +
.../domain/asset-relationship.aggregate.ts | 78 +++++
.../src/api/domain/asset-relationship.test.ts | 124 ++++++++
.../value-objects/asset-definition-values.ts | 24 ++
.../asset-relationship-values.ts | 92 ++++++
.../value-objects/workstation-values.ts | 67 +++++
.../api/domain/workstation-revision.entity.ts | 25 ++
.../src/api/domain/workstation-values.ts | 9 +
.../src/api/domain/workstation.aggregate.ts | 72 +++++
.../assets/src/api/domain/workstation.test.ts | 72 +++++
.../http/asset-relationship.routes.ts | 81 ++++++
.../api/infrastructure/http/asset.routes.ts | 8 +
.../infrastructure/http/workstation.routes.ts | 58 ++++
.../asset-relationship-persistence.mapper.ts | 61 ++++
.../persistence/asset-relationship.model.ts | 77 +++++
.../persistence/load-workstations.ts | 51 ++++
.../persistence/sequelize-asset.repository.ts | 96 ++++++-
.../workstation-persistence.mapper.ts | 101 +++++++
.../persistence/workstation.model.ts | 119 ++++++++
modules/assets/src/api/module.ts | 15 +-
modules/assets/src/common/index.ts | 2 +
modules/assets/src/common/inventory.ts | 9 +-
modules/assets/src/common/relationships.ts | 25 ++
modules/assets/src/common/workstations.ts | 23 ++
modules/assets/src/web/asset-detail.tsx | 17 ++
modules/assets/src/web/asset-form.tsx | 44 +--
.../assets/src/web/asset-relationships.tsx | 268 ++++++++++++++++++
.../src/web/catalog-definition-dialog.tsx | 5 +
modules/assets/src/web/catalog-help.tsx | 8 +-
modules/assets/src/web/catalog-page.tsx | 33 ++-
.../assets/src/web/catalog-relation-form.tsx | 97 +++++++
modules/assets/src/web/i18n.ts | 12 +-
modules/assets/src/web/index.ts | 1 +
modules/assets/src/web/inventory-api.ts | 17 ++
modules/assets/src/web/inventory-i18n.ts | 20 +-
modules/assets/src/web/inventory-page.tsx | 15 +-
modules/assets/src/web/inventory.test.tsx | 129 ++++++++-
modules/assets/src/web/relationship-i18n.ts | 64 +++++
modules/assets/src/web/workstation-form.tsx | 254 +++++++++++++++++
modules/assets/src/web/workstation-i18n.ts | 52 ++++
modules/assets/src/web/workstations-page.tsx | 167 +++++++++++
.../src/web/components/company-detail.tsx | 14 +-
.../src/web/components/company-inventory.tsx | 7 +-
64 files changed, 3117 insertions(+), 55 deletions(-)
create mode 100644 apps/server/src/relationships.integration.ts
create mode 100644 apps/server/src/workstations.integration.ts
create mode 100644 docs/puestos-de-trabajo.md
create mode 100644 docs/relaciones-entre-activos.md
create mode 100644 modules/assets/src/api/application/asset-relationship-response.builder.ts
create mode 100644 modules/assets/src/api/application/asset-relationship.use-cases.ts
create mode 100644 modules/assets/src/api/application/workstation-input.mapper.ts
create mode 100644 modules/assets/src/api/application/workstation-response.builder.ts
create mode 100644 modules/assets/src/api/application/workstation.use-cases.ts
create mode 100644 modules/assets/src/api/domain/asset-relationship.aggregate.ts
create mode 100644 modules/assets/src/api/domain/asset-relationship.test.ts
create mode 100644 modules/assets/src/api/domain/value-objects/asset-relationship-values.ts
create mode 100644 modules/assets/src/api/domain/value-objects/workstation-values.ts
create mode 100644 modules/assets/src/api/domain/workstation-revision.entity.ts
create mode 100644 modules/assets/src/api/domain/workstation-values.ts
create mode 100644 modules/assets/src/api/domain/workstation.aggregate.ts
create mode 100644 modules/assets/src/api/domain/workstation.test.ts
create mode 100644 modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts
create mode 100644 modules/assets/src/api/infrastructure/http/workstation.routes.ts
create mode 100644 modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts
create mode 100644 modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts
create mode 100644 modules/assets/src/api/infrastructure/persistence/load-workstations.ts
create mode 100644 modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts
create mode 100644 modules/assets/src/api/infrastructure/persistence/workstation.model.ts
create mode 100644 modules/assets/src/common/relationships.ts
create mode 100644 modules/assets/src/common/workstations.ts
create mode 100644 modules/assets/src/web/asset-relationships.tsx
create mode 100644 modules/assets/src/web/catalog-relation-form.tsx
create mode 100644 modules/assets/src/web/relationship-i18n.ts
create mode 100644 modules/assets/src/web/workstation-form.tsx
create mode 100644 modules/assets/src/web/workstation-i18n.ts
create mode 100644 modules/assets/src/web/workstations-page.tsx
diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts
index 160b9f7..f73f18a 100644
--- a/apps/server/src/assets.integration.ts
+++ b/apps/server/src/assets.integration.ts
@@ -30,7 +30,7 @@ export async function verifyAssets(
assert.equal(data.code, code);
}
const initial = await read(global);
- assert.equal(initial.items.length, 42);
+ assert.equal(initial.items.length, 47);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12);
assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
assert.ok(
diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts
index 822bb09..8c9c51f 100644
--- a/apps/server/src/identity.test.ts
+++ b/apps/server/src/identity.test.ts
@@ -23,6 +23,8 @@ import { verifyAssets } from "./assets.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
import { verifyInventory } from "./inventory.integration";
+import { verifyRelationships } from "./relationships.integration";
+import { verifyWorkstations } from "./workstations.integration";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
const environment = readDatabaseEnvironment(process.env);
@@ -35,6 +37,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
await database.query("DELETE FROM http_sessions");
+ await database.query("DELETE FROM workstation_assets");
+ await database.query("DELETE FROM workstation_revisions");
+ await database.query("DELETE FROM workstations");
+ await database.query("DELETE FROM asset_relationships");
await database.query("DELETE FROM asset_revisions");
await database.query("DELETE FROM assets");
await database.query("DELETE FROM asset_availability");
@@ -693,6 +699,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
+ await verifyRelationships(
+ (...args) => operator.request(...args),
+ customer.id,
+ secondCompany.id,
+ database
+ );
+ await verifyWorkstations(
+ (...args) => operator.request(...args),
+ customer.id,
+ secondCompany.id,
+ database
+ );
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT }
@@ -702,6 +720,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
assert.equal((await operator.session()).user, null);
+ await expectError(
+ await operator.request(`/assets/${customer.id}/relationships`, {}),
+ 401,
+ "UNAUTHENTICATED"
+ );
+ await expectError(
+ await operator.request(`/assets/${customer.id}/workstations`, {}),
+ 401,
+ "UNAUTHENTICATED"
+ );
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
await assert.rejects(
diff --git a/apps/server/src/relationships.integration.ts b/apps/server/src/relationships.integration.ts
new file mode 100644
index 0000000..e0ea447
--- /dev/null
+++ b/apps/server/src/relationships.integration.ts
@@ -0,0 +1,155 @@
+import assert from "node:assert/strict";
+
+import {
+ AssetResponseSchema,
+ CatalogResponseSchema,
+ InventoryResponseSchema,
+ RelationshipResponseSchema,
+ SaveDefinitionResponseSchema,
+} from "@metis/assets/common";
+import type { Sequelize } from "sequelize";
+import { z } from "zod/v4";
+
+type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise;
+export async function verifyRelationships(
+ request: Request,
+ companyId: string,
+ otherId: string,
+ database: Sequelize
+) {
+ const path = `/assets/${companyId}`;
+ const catalog = CatalogResponseSchema.parse(
+ await (await request(`/asset-catalog/${companyId}`)).json()
+ );
+ const type = catalog.items.find(
+ (item) => item.values.kind === "relation" && item.values.symmetric
+ );
+ assert.ok(type);
+ const template = catalog.items.find(
+ (item) => item.values.kind === "template" && item.values.name === "Monitor"
+ );
+ assert.ok(template);
+ const own = InventoryResponseSchema.parse(await (await request(path)).json()).assets;
+ const first = own[0];
+ const second = own[1];
+ assert.ok(first && second);
+ const foreignResponse = await request(`/assets/${otherId}`, {
+ values: {
+ name: "MON-Foreign",
+ templateId: template.id,
+ status: "available",
+ siteId: null,
+ personId: null,
+ characteristics: {},
+ notes: "",
+ },
+ });
+ assert.equal(foreignResponse.status, 201);
+ const foreign = AssetResponseSchema.parse(await foreignResponse.json()).asset;
+ const input = { sourceId: first.id, targetId: second.id, typeId: type.id, notes: "USB" };
+ const linkPath = `${path}/relationships`;
+ const save = async (values: unknown) => {
+ const response = await request(linkPath, { values });
+ assert.equal(response.status, 201, await response.clone().text());
+ return RelationshipResponseSchema.parse(await response.json()).relationship;
+ };
+ const error = async (values: unknown, code: string, status = 409) => {
+ const response = await request(linkPath, { values });
+ assert.equal(response.status, status);
+ assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
+ };
+ await error({ ...input, targetId: first.id }, "SELF_RELATIONSHIP");
+ await error({ ...input, targetId: foreign.id }, "ASSET_NOT_FOUND", 404);
+ await error({ ...input, typeId: template.id }, "RELATION_TYPE_UNAVAILABLE");
+ await error({ ...input, createdBy: first.id }, "INVALID_REQUEST", 400);
+ assert.equal((await request(linkPath, { values: input }, "https://other.example")).status, 403);
+ assert.equal((await request(linkPath, { values: input }, undefined, "a".repeat(64))).status, 403);
+ const [created, retry] = await Promise.all([save(input), save(input)]);
+ assert.equal(created.id, retry.id);
+ assert.equal(created.id.charAt(14), "7");
+ assert.ok(created.createdByName);
+ assert.equal((await save({ ...input, sourceId: second.id, targetId: first.id })).id, created.id);
+ await error({ ...input, notes: "Otro cable" }, "RELATIONSHIP_EXISTS");
+ await assert.rejects(
+ database.query("UPDATE asset_relationships SET targetId=:target WHERE id=:id", {
+ replacements: { target: foreign.id, id: created.id },
+ })
+ );
+ assert.equal(
+ (await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: false })).status,
+ 200
+ );
+ await error(input, "RELATION_TYPE_UNAVAILABLE");
+ const endPath = `${path}/relationships/${created.id}/end`;
+ const ending = await request(endPath, {});
+ assert.equal(ending.status, 200);
+ const ended = RelationshipResponseSchema.parse(await ending.json()).relationship;
+ assert.ok(ended.endedAt);
+ assert.ok(ended.endedByName);
+ assert.deepEqual(
+ RelationshipResponseSchema.parse(await (await request(endPath, {})).json()).relationship,
+ ended
+ );
+ assert.equal(
+ (await request(`/assets/${otherId}/relationships/${created.id}/end`, {})).status,
+ 404
+ );
+ assert.equal(
+ (await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: null })).status,
+ 200
+ );
+ const replacement = await save(input);
+ assert.notEqual(replacement.id, created.id);
+ await verifyDirectional(request, companyId, otherId, first.id, second.id);
+ const stored = InventoryResponseSchema.parse(await (await request(path)).json()).relationships;
+ assert.deepEqual(
+ stored.find((item) => item.id === ended.id),
+ ended
+ );
+ assert.ok(
+ !InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).relationships
+ .length
+ );
+}
+async function verifyDirectional(
+ request: Request,
+ companyId: string,
+ otherId: string,
+ sourceId: string,
+ targetId: string
+) {
+ const definition = {
+ kind: "relation",
+ name: "Replica a",
+ inverseName: "Recibe réplica de",
+ description: "",
+ symmetric: false,
+ };
+ const response = await request(`/asset-catalog/${companyId}`, { values: definition });
+ assert.equal(response.status, 201);
+ const typeId = SaveDefinitionResponseSchema.parse(await response.json()).savedId;
+ const path = `/assets/${companyId}/relationships`;
+ const values = { sourceId, targetId, typeId, notes: "" };
+ const added = await request(path, { values });
+ assert.equal(added.status, 201);
+ const link = RelationshipResponseSchema.parse(await added.json()).relationship;
+ const reverse = await request(path, {
+ values: { ...values, sourceId: targetId, targetId: sourceId },
+ });
+ assert.equal(reverse.status, 201);
+ assert.notEqual(RelationshipResponseSchema.parse(await reverse.json()).relationship.id, link.id);
+ const edited = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
+ values: { ...definition, name: "Replica hacia" },
+ });
+ assert.equal(edited.status, 200);
+ const conversion = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
+ values: { ...definition, symmetric: true },
+ });
+ assert.equal(conversion.status, 409);
+ const stored = InventoryResponseSchema.parse(
+ await (await request(`/assets/${companyId}`)).json()
+ ).relationships.find((item) => item.id === link.id);
+ assert.equal(stored?.values.name, "Replica a");
+ const other = await request(`/assets/${otherId}/relationships`, { values });
+ assert.equal(other.status, 404);
+}
diff --git a/apps/server/src/workstations.integration.ts b/apps/server/src/workstations.integration.ts
new file mode 100644
index 0000000..3e7192b
--- /dev/null
+++ b/apps/server/src/workstations.integration.ts
@@ -0,0 +1,136 @@
+import assert from "node:assert/strict";
+
+import { InventoryResponseSchema, WorkstationResponseSchema } from "@metis/assets/common";
+import { CompanyResponseSchema } from "@metis/companies/common";
+import type { Sequelize } from "sequelize";
+import { z } from "zod/v4";
+
+type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise;
+export async function verifyWorkstations(
+ request: Request,
+ companyId: string,
+ otherId: string,
+ database: Sequelize
+) {
+ const path = `/assets/${companyId}/workstations`;
+ const own = CompanyResponseSchema.parse(
+ await (await request(`/companies/${companyId}`)).json()
+ ).company;
+ const other = CompanyResponseSchema.parse(
+ await (await request(`/companies/${otherId}`)).json()
+ ).company;
+ const inventory = InventoryResponseSchema.parse(
+ await (await request(`/assets/${companyId}`)).json()
+ );
+ const foreign = InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json())
+ .assets[0];
+ const first = inventory.assets[0];
+ const second = inventory.assets[1];
+ assert.ok(first && second && foreign);
+ const personFields = {
+ name: "Persona puesto",
+ position: null,
+ email: null,
+ phone: null,
+ active: true,
+ isContact: false,
+ };
+ const personResponse = await request(`/companies/${companyId}/people`, personFields);
+ assert.equal(personResponse.status, 201);
+ const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
+ assert.ok(person);
+ const input = {
+ name: "Puesto Ana",
+ active: true,
+ siteId: own.sites[0]?.id ?? null,
+ personId: person.id,
+ assetIds: [first.id, second.id],
+ };
+ const save = async (values: unknown, id?: string) => {
+ const response = await request(id ? `${path}/${id}/update` : path, { values });
+ assert.equal(response.status, id ? 200 : 201, await response.clone().text());
+ return WorkstationResponseSchema.parse(await response.json()).workstation;
+ };
+ const error = async (values: unknown, code: string, id?: string, status = 409) => {
+ const response = await request(id ? `${path}/${id}/update` : path, { values });
+ assert.equal(response.status, status);
+ assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
+ };
+ await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404);
+ await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404);
+ await error({ ...input, personId: other.people[0]?.id }, "PERSON_UNAVAILABLE");
+ await error({ ...input, assetIds: [first.id, first.id] }, "INVALID_MEMBERS");
+ await error({ ...input, actorId: first.id }, "INVALID_REQUEST", undefined, 400);
+ assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
+ assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403);
+ const [a, b] = await Promise.all([
+ request(path, { values: input }),
+ request(path, { values: { ...input, name: "Puesto conflictivo" } }),
+ ]);
+ assert.deepEqual([a.status, b.status].sort(), [201, 409]);
+ const success = a.status === 201 ? a : b;
+ const created = WorkstationResponseSchema.parse(await success.json()).workstation;
+ assert.equal(created.id.charAt(14), "7");
+ assert.equal(created.history[0]?.id.charAt(14), "7");
+ assert.ok(created.history[0]?.actorName);
+ const unchanged = await save(
+ { ...created.values, assetIds: [...created.values.assetIds].reverse() },
+ created.id
+ );
+ assert.deepEqual(unchanged, created);
+ const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] });
+ assert.equal(
+ (
+ await request(`/companies/${companyId}/people/${person.id}/update`, {
+ ...personFields,
+ active: false,
+ })
+ ).status,
+ 200
+ );
+ await error({ ...empty.values, name: "Nueva asignación inactiva" }, "PERSON_UNAVAILABLE");
+ assert.deepEqual(await save(created.values, created.id), created);
+ await error({ ...empty.values, assetIds: [first.id] }, "ASSET_IN_WORKSTATION", empty.id);
+ await error({ ...empty.values, assetIds: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404);
+ const inactive = await save({ ...created.values, active: false }, created.id);
+ assert.equal(inactive.history.length, 2);
+ const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id);
+ await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id);
+ const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id);
+ assert.equal(removed.history.length, 3);
+ const restored = await save(created.values, created.id);
+ assert.equal(restored.history.length, 3);
+ assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds);
+ const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, {
+ values: created.values,
+ });
+ assert.equal(foreignUpdate.status, 404);
+ await assert.rejects(
+ database.query("UPDATE workstation_assets SET assetId=:asset WHERE workstationId=:id", {
+ replacements: { asset: foreign.id, id: created.id },
+ })
+ );
+ const corruption = { ...restored.values, assetIds: [foreign.id] };
+ await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
+ replacements: { values: JSON.stringify(corruption), id: restored.id },
+ });
+ try {
+ assert.equal((await request(`/assets/${companyId}`)).status, 500);
+ } finally {
+ await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
+ replacements: { values: JSON.stringify(restored.values), id: restored.id },
+ });
+ }
+ const current = InventoryResponseSchema.parse(
+ await (await request(`/assets/${companyId}`)).json()
+ );
+ assert.deepEqual(
+ current.workstations.find((item) => item.id === restored.id),
+ restored
+ );
+ assert.equal(
+ InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).workstations
+ .length,
+ 0
+ );
+}
diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx
index 2716d76..62e1df3 100644
--- a/apps/web/src/app.test.tsx
+++ b/apps/web/src/app.test.tsx
@@ -102,6 +102,8 @@ test("el espacio mantiene compañía, secciones y ficha del primer activo sin fo
client.setQueryData(companyKeys.detail(workspaceCompany.id), { company: workspaceCompany });
client.setQueryData(inventoryKeys.company(workspaceCompany.id), {
assets: workspaceAssets,
+ relationships: [],
+ workstations: [],
});
client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog);
}
diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md
index ab1b114..c9e481b 100644
--- a/docs/arquitectura/modelo-ddd-e-identificadores.md
+++ b/docs/arquitectura/modelo-ddd-e-identificadores.md
@@ -49,6 +49,10 @@ AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateFiel
Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md).
+AssetRelationship es la raíz de un vínculo entre dos activos de una compañía, con UUID propio y auditoría de creación y retirada. AssetRelationshipValues reconstruye extremos, tipo y nombres como Value Objects y conserva una instantánea. Consultar [Relaciones entre activos](../relaciones-entre-activos.md).
+
+Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md).
+
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización.
diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md
index 68b1eb9..b23733b 100644
--- a/docs/campos-y-plantillas-de-activos.md
+++ b/docs/campos-y-plantillas-de-activos.md
@@ -3,7 +3,7 @@
## Uso
-El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Plantillas de activos: combina las globales con sus adiciones propias.
+El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Catálogo de activos: combina las globales con sus adiciones propias.
Las altas y ediciones se abren mediante el botón de cabecera o la acción del listado. Los formularios son modales, con ayuda inferior y Guardar / Cancelar. Ver consulta una definición sin modificarla. Crear copia inicia una nueva plantilla personalizable.
@@ -15,7 +15,7 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d
## Datos de serie
-Se incluyen 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI.
+Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI.
Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía.
@@ -41,6 +41,6 @@ Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_avai
Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update y POST /api/asset-catalog/:scope/:id/status requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito.
-## Siguiente entrega
+## Inventario y relaciones
-Registrar activos reales utilizando estas plantillas, con compañía, sede, asignación a personas y movimientos. Las relaciones y conjuntos de puesto de trabajo se incorporarán después. Este bloque no registra todavía inventario, configuración por dispositivo ni relaciones entre activos.
+El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. El mismo catálogo incorpora los [tipos de relación](relaciones-entre-activos.md). Los [puestos de trabajo](puestos-de-trabajo.md) agrupan los activos registrados.
diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md
index e44af6e..e0cda43 100644
--- a/docs/inventario-de-activos.md
+++ b/docs/inventario-de-activos.md
@@ -42,4 +42,4 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y
Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial.
-Las relaciones entre activos y los conjuntos de puesto de trabajo se incorporarán en el siguiente bloque. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan.
+Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan.
diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md
index c81e1c2..7e7ab82 100644
--- a/docs/plan-de-desarrollo.md
+++ b/docs/plan-de-desarrollo.md
@@ -41,10 +41,9 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos
## Siguientes entregas: orden actualizado
-1. Relaciones entre activos y conjuntos de puesto de trabajo.
-2. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
-3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
-4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
+1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
+2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
+3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
@@ -52,7 +51,7 @@ Este orden se concreta antes de cada entrega. Las licencias y su relación con c
Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro estados, características tipadas y revisiones con actor y fecha. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md).
-Los conjuntos de puesto de trabajo y las relaciones entre activos siguen pendientes.
+Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, reserva exclusiva de activos en puestos vigentes e historial.
## Tickets e intervenciones: pendiente
@@ -66,4 +65,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
## Decisiones pendientes
-Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; las relaciones entre activos y los tickets siguen pendientes.
+Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets siguen pendientes.
diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md
new file mode 100644
index 0000000..113c35e
--- /dev/null
+++ b/docs/puestos-de-trabajo.md
@@ -0,0 +1,33 @@
+# Puestos de trabajo
+
+## Uso
+
+La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
+
+Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas, sedes y personas existentes para evitar un tercer modal.
+
+La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra su puesto vigente.
+
+## Reglas
+
+Un activo solo puede pertenecer a un puesto activo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos para un puesto activo. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden ocupar el mismo activo.
+
+Desactivar un puesto conserva su composición y libera sus reservas. Un activo puede figurar en composiciones de puestos inactivos. Reactivar comprueba de nuevo la disponibilidad: si un activo está ocupado, hay que retirarlo de uno de los puestos antes de guardar. Retirar un activo del puesto no elimina el activo ni sus relaciones.
+
+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.
+
+La asignación del puesto no cambia automáticamente sede, persona o estado de cada activo; son registros independientes. 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.
+
+No se eliminan puestos ni revisiones. 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.
+
+## Arquitectura y API
+
+Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito.
+
+Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos, claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
+
+El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas.
+
+GET /api/assets/:companyId incluye workstations junto a assets y relationships. POST /api/assets/:companyId/workstations y POST /api/assets/:companyId/workstations/:id/update reciben values. Las escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión y no se admite en el cuerpo.
+
+Las pruebas verifican identidad, composición opcional, límites, igualdad, historial, ámbito, ausencia de anidación, desactivación y reactivación, personas inactivas, concurrencia y persistencia. La comprobación de navegador incluye altas rápidas, cancelación, composición, activos ocupados e historial.
diff --git a/docs/relaciones-entre-activos.md b/docs/relaciones-entre-activos.md
new file mode 100644
index 0000000..d352f2e
--- /dev/null
+++ b/docs/relaciones-entre-activos.md
@@ -0,0 +1,29 @@
+# Relaciones entre activos
+
+## Uso y catálogo
+
+Catálogo de activos incorpora la pestaña Relaciones, tanto global como por compañía. Las definiciones de serie son Conectado a, Alojado en / Aloja, Depende de / Es necesario para, Alimentado por / Alimenta e Instalado en / Tiene instalado. Se pueden añadir tipos globales o exclusivos de una compañía. Las definiciones de serie se pueden activar o desactivar, pero no modificar ni borrar.
+
+La disponibilidad global actúa como valor predeterminado; una compañía puede personalizarla y volver a usar la configuración global. Desactivar un tipo impide crear vínculos nuevos, pero conserva los existentes y permite retirarlos. Una definición con dirección tiene un nombre por sentido; una simétrica usa el mismo nombre en ambos. Esta elección queda fija tras el alta.
+
+En la ficha lateral o Ficha e historial, Relacionar activo abre un modal para seleccionar el tipo, otro activo y notas opcionales. El «+» junto al tipo permite crearlo para esa compañía y lo selecciona sin perder el resto del formulario. La nueva definición se conserva aunque después se cancele el vínculo. La ayuda queda inmediatamente antes de Guardar y Cancelar.
+
+Las fichas de ambos extremos muestran el vínculo con su nombre correspondiente. Por ejemplo, la máquina virtual muestra Alojado en · Servidor y el servidor muestra Aloja · Máquina virtual. Los nombres se capturan al crear cada vínculo; renombrar un tipo propio no reescribe los vínculos anteriores.
+
+## Reglas e historial
+
+Ambos activos pertenecen a la misma compañía y deben existir. Un activo no puede relacionarse consigo mismo. Una relación vigente no puede duplicarse con el mismo tipo y extremos: en las simétricas, invertir los extremos tampoco crea otro vínculo. Repetir exactamente el alta devuelve la relación existente; cambiar las notas de un duplicado produce un error. En las relaciones con dirección, el sentido inverso es un vínculo distinto.
+
+Retirar relación requiere una confirmación en un modal. Conserva UUID, extremos, notas, tipo y autor y fecha de creación, añadiendo autor y fecha de retirada. Repetir la retirada no altera los datos. Las relaciones retiradas se consultan en un apartado desplegable. Se puede volver a crear un vínculo retirado, con un nuevo UUID.
+
+Los activos retirados conservan sus relaciones para revisión; retirar el activo no desconecta automáticamente otros equipos. No se incluyen restricciones por plantilla, detección de ciclos ni conjuntos anidados. Los [puestos de trabajo](puestos-de-trabajo.md) agrupan activos sin crear relaciones automáticamente.
+
+## Arquitectura y API
+
+AssetRelationship es un agregado con UniqueID UUID v7. AssetRelationshipValues es un Value Object inmutable que reconstruye los extremos y el tipo como UniqueID, y los nombres como Name. AssetCatalog conserva los tipos como AssetDefinition de clase relation.
+
+Infrastructure reconstruye y valida valores, auditoría, pertenencia y referencias. asset_relationships contiene claves foráneas a compañía, activos, tipo y usuarios. Las claves compuestas impiden extremos de otra compañía; la clave vigente única evita duplicados incluso ante concurrencia. Las escrituras transaccionales bloquean catálogo y compañía en el orden compartido con el inventario.
+
+GET /api/assets/:companyId devuelve assets y relationships, incluidas las retiradas. POST /api/assets/:companyId/relationships recibe values con sourceId, targetId, typeId y notes. POST /api/assets/:companyId/relationships/:id/end recibe un cuerpo vacío. Ambas escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión.
+
+Las pruebas cubren UUID, dirección, simetría, autovínculos, duplicados concurrentes, ámbito, disponibilidad, protección de serie, dirección fija, instantáneas, auditoría e idempotencia. La comprobación en navegador verifica alta, alta rápida, cancelación y retirada.
diff --git a/modules/assets/package.json b/modules/assets/package.json
index 39e08c1..9cdf84f 100644
--- a/modules/assets/package.json
+++ b/modules/assets/package.json
@@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
- "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
+ "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",
diff --git a/modules/assets/src/api/application/asset-relationship-response.builder.ts b/modules/assets/src/api/application/asset-relationship-response.builder.ts
new file mode 100644
index 0000000..c830d7d
--- /dev/null
+++ b/modules/assets/src/api/application/asset-relationship-response.builder.ts
@@ -0,0 +1,19 @@
+import type { RelationshipDTO } from "../../common/relationships";
+import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
+export function buildRelationshipResponse(
+ item: AssetRelationship,
+ actors: ReadonlyMap
+): RelationshipDTO {
+ const endedBy = item.endedBy.getOrUndefined()?.toPrimitive() ?? null;
+ return {
+ id: item.id.toPrimitive(),
+ companyId: item.companyId.toPrimitive(),
+ values: item.values.toPrimitive(),
+ createdBy: item.createdBy.toPrimitive(),
+ createdByName: actors.get(item.createdBy.toPrimitive()) ?? "",
+ createdAt: item.createdAt,
+ endedBy,
+ endedByName: endedBy ? (actors.get(endedBy) ?? "") : null,
+ endedAt: item.endedAt.getOrUndefined() ?? null,
+ };
+}
diff --git a/modules/assets/src/api/application/asset-relationship.use-cases.ts b/modules/assets/src/api/application/asset-relationship.use-cases.ts
new file mode 100644
index 0000000..959e28d
--- /dev/null
+++ b/modules/assets/src/api/application/asset-relationship.use-cases.ts
@@ -0,0 +1,80 @@
+import { UniqueID } from "@repo/rdx-ddd";
+import { Maybe, Result } from "@repo/rdx-utils";
+
+import type { RelationshipInputDTO } from "../../common/relationships";
+import { AssetCatalogError } from "../domain/asset-definition";
+import { AssetRelationship } from "../domain/asset-relationship.aggregate";
+import { AssetRelationshipValues } from "../domain/value-objects/asset-relationship-values";
+
+import type { IAssetRepository, InventoryContext } from "./asset.repository";
+
+export class AssetRelationshipUseCases {
+ constructor(private readonly repository: IAssetRepository) {}
+ save(companyId: string, input: RelationshipInputDTO, actorId: string) {
+ const company = UniqueID.create(companyId);
+ const actor = UniqueID.create(actorId);
+ if (!(company.isSuccess && actor.isSuccess)) {
+ return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
+ }
+ return this.repository.saveRelationship(company.data, (context) => {
+ const mapped = mapRelationshipInput(input, company.data, context);
+ if (!mapped.isSuccess) {
+ return Result.fail(mapped.error);
+ }
+ const previous = context.relationships.find(
+ (item) => item.active && item.values.key() === mapped.data.key()
+ );
+ if (previous) {
+ return previous.values.notes === mapped.data.notes
+ ? Result.ok(previous)
+ : Result.fail(new AssetCatalogError("RELATIONSHIP_EXISTS"));
+ }
+ return AssetRelationship.create(
+ company.data,
+ mapped.data,
+ actor.data,
+ new Date().toISOString()
+ );
+ });
+ }
+ end(companyId: string, id: string, actorId: string) {
+ const company = UniqueID.create(companyId);
+ const identity = UniqueID.create(id);
+ const actor = UniqueID.create(actorId);
+ if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
+ return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
+ }
+ return this.repository.saveRelationship(company.data, (context) => {
+ const item = context.relationships.find((entry) => entry.id.equals(identity.data));
+ return item
+ ? item.end(actor.data, new Date().toISOString())
+ : Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
+ });
+ }
+}
+export function mapRelationshipInput(
+ input: RelationshipInputDTO,
+ company: UniqueID,
+ context: InventoryContext
+) {
+ if (
+ ![input.sourceId, input.targetId].every((id) =>
+ context.assets.some((asset) => asset.id.toPrimitive() === id)
+ )
+ ) {
+ return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
+ }
+ const scope = Maybe.some(company);
+ const type = context.catalog
+ .visible(scope)
+ .find((item) => item.id.toPrimitive() === input.typeId);
+ if (!(type && type.values.data.kind === "relation" && context.catalog.active(type, scope))) {
+ return Result.fail(new AssetCatalogError("RELATION_TYPE_UNAVAILABLE"));
+ }
+ return AssetRelationshipValues.create({
+ ...input,
+ name: type.values.data.name,
+ inverseName: type.values.data.inverseName,
+ symmetric: type.values.data.symmetric,
+ });
+}
diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts
index fd5a990..a67f810 100644
--- a/modules/assets/src/api/application/asset.repository.ts
+++ b/modules/assets/src/api/application/asset.repository.ts
@@ -4,17 +4,36 @@ import { Result } from "@repo/rdx-utils";
import type { Asset } from "../domain/asset.aggregate";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
import { AssetCatalogError } from "../domain/asset-definition";
+import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
import type { AssetValues } from "../domain/value-objects/asset-values";
+import type { Workstation } from "../domain/workstation.aggregate";
export type InventoryContext = Readonly<{
+ workstations: readonly Workstation[];
catalog: AssetCatalog;
assets: readonly Asset[];
+ relationships: readonly AssetRelationship[];
sites: readonly UniqueID[];
people: readonly { id: UniqueID; active: boolean }[];
actorNames: ReadonlyMap;
}>;
export interface IAssetRepository {
+ saveWorkstation(
+ companyId: UniqueID,
+ action: (context: InventoryContext) => Result
+ ): Promise<
+ Result<{ workstation: Workstation; actorNames: ReadonlyMap }, AssetCatalogError>
+ >;
get(companyId: UniqueID): Promise>;
+ saveRelationship(
+ companyId: UniqueID,
+ action: (context: InventoryContext) => Result
+ ): Promise<
+ Result<
+ { relationship: AssetRelationship; actorNames: ReadonlyMap },
+ AssetCatalogError
+ >
+ >;
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result
diff --git a/modules/assets/src/api/application/workstation-input.mapper.ts b/modules/assets/src/api/application/workstation-input.mapper.ts
new file mode 100644
index 0000000..c89c08d
--- /dev/null
+++ b/modules/assets/src/api/application/workstation-input.mapper.ts
@@ -0,0 +1,47 @@
+import { Result } from "@repo/rdx-utils";
+
+import { AssetCatalogError } from "../domain/asset-definition";
+import { WorkstationValues } from "../domain/value-objects/workstation-values";
+import type { Workstation } from "../domain/workstation.aggregate";
+import type { WorkstationInput } from "../domain/workstation-values";
+
+import type { InventoryContext } from "./asset.repository";
+export function mapWorkstationInput(
+ input: WorkstationInput,
+ context: InventoryContext,
+ previous?: Workstation
+) {
+ const mapped = WorkstationValues.create(input);
+ if (!mapped.isSuccess) {
+ return mapped;
+ }
+ const values = mapped.data.toPrimitive();
+ if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) {
+ return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
+ }
+ const person = context.people.find((item) => item.id.toPrimitive() === values.personId);
+ if (
+ values.personId &&
+ (!person || (!person.active && previous?.values.toPrimitive().personId !== values.personId))
+ ) {
+ return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
+ }
+ for (const id of values.assetIds) {
+ const asset = context.assets.find((item) => item.id.toPrimitive() === id);
+ 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)
+ )
+ ) {
+ return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));
+ }
+ }
+ return mapped;
+}
diff --git a/modules/assets/src/api/application/workstation-response.builder.ts b/modules/assets/src/api/application/workstation-response.builder.ts
new file mode 100644
index 0000000..d9d2224
--- /dev/null
+++ b/modules/assets/src/api/application/workstation-response.builder.ts
@@ -0,0 +1,19 @@
+import type { WorkstationDTO } from "../../common/workstations";
+import type { Workstation } from "../domain/workstation.aggregate";
+export function buildWorkstationResponse(
+ item: Workstation,
+ names: ReadonlyMap
+): WorkstationDTO {
+ return {
+ id: item.id.toPrimitive(),
+ companyId: item.companyId.toPrimitive(),
+ values: item.values.toPrimitive(),
+ history: item.history.map((revision) => ({
+ id: revision.id.toPrimitive(),
+ values: revision.values.toPrimitive(),
+ actorId: revision.actorId.toPrimitive(),
+ actorName: names.get(revision.actorId.toPrimitive()) ?? "",
+ recordedAt: revision.recordedAt,
+ })),
+ };
+}
diff --git a/modules/assets/src/api/application/workstation.use-cases.ts b/modules/assets/src/api/application/workstation.use-cases.ts
new file mode 100644
index 0000000..2d5349f
--- /dev/null
+++ b/modules/assets/src/api/application/workstation.use-cases.ts
@@ -0,0 +1,36 @@
+import { UniqueID } from "@repo/rdx-ddd";
+import { Result } from "@repo/rdx-utils";
+
+import { AssetCatalogError } from "../domain/asset-definition";
+import { Workstation } from "../domain/workstation.aggregate";
+import type { WorkstationInput } from "../domain/workstation-values";
+
+import type { IAssetRepository } from "./asset.repository";
+import { mapWorkstationInput } from "./workstation-input.mapper";
+export class WorkstationUseCases {
+ constructor(private readonly repository: IAssetRepository) {}
+ save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
+ const company = UniqueID.create(companyId);
+ const actor = UniqueID.create(actorId);
+ const identity = id ? UniqueID.create(id) : undefined;
+ if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
+ return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
+ }
+ return this.repository.saveWorkstation(company.data, (context) => {
+ const previous = identity?.isSuccess
+ ? context.workstations.find((item) => item.id.equals(identity.data))
+ : undefined;
+ if (id && !previous) {
+ return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
+ }
+ const values = mapWorkstationInput(input, context, previous);
+ if (!values.isSuccess) {
+ return Result.fail(values.error);
+ }
+ const now = new Date().toISOString();
+ return previous
+ ? previous.update(values.data, actor.data, now)
+ : Workstation.create(company.data, values.data, actor.data, now);
+ });
+ }
+}
diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts
index 51e8435..7c60ab4 100644
--- a/modules/assets/src/api/domain/asset-catalog-seed.ts
+++ b/modules/assets/src/api/domain/asset-catalog-seed.ts
@@ -100,5 +100,14 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
});
add(`template:${key}`, { kind: "template", name, description: "", fields: references });
}
+ for (const [key, name, inverseName, symmetric] of [
+ ["connected", "Conectado a", "Conectado a", true],
+ ["hosted", "Alojado en", "Aloja", false],
+ ["depends", "Depende de", "Es necesario para", false],
+ ["powered", "Alimentado por", "Alimenta", false],
+ ["installed", "Instalado en", "Tiene instalado", false],
+ ] as const) {
+ add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" });
+ }
return current;
}
diff --git a/modules/assets/src/api/domain/asset-catalog.test.ts b/modules/assets/src/api/domain/asset-catalog.test.ts
index 5423449..5c12d40 100644
--- a/modules/assets/src/api/domain/asset-catalog.test.ts
+++ b/modules/assets/src/api/domain/asset-catalog.test.ts
@@ -24,7 +24,7 @@ function changed(result: ReturnType) {
}
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
- assert.equal(catalog.definitions.length, 42);
+ assert.equal(catalog.definitions.length, 47);
assert.equal(seedAssetCatalog(catalog), catalog);
const ids = [
catalog.id,
@@ -58,7 +58,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva
assert.equal(catalog.active(stock, first), false);
assert.equal(catalog.active(stock, second), true);
assert.equal(stock.active, true);
- assert.equal(catalog.definitions.length, 42);
+ assert.equal(catalog.definitions.length, 47);
const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId);
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts
index 34ed6a0..167fbf2 100644
--- a/modules/assets/src/api/domain/asset-definition.entity.ts
+++ b/modules/assets/src/api/domain/asset-definition.entity.ts
@@ -76,6 +76,13 @@ export class AssetDefinition extends DomainEntity {
) {
return Result.fail(new AssetCatalogError("FIELD_TYPE_FIXED"));
}
+ if (
+ previous.kind === "relation" &&
+ next.kind === "relation" &&
+ previous.symmetric !== next.symmetric
+ ) {
+ return Result.fail(new AssetCatalogError("RELATION_DIRECTION_FIXED"));
+ }
if (this.values.equals(values)) {
return Result.ok(this);
}
diff --git a/modules/assets/src/api/domain/asset-definition.ts b/modules/assets/src/api/domain/asset-definition.ts
index ee40a1b..e9c33a0 100644
--- a/modules/assets/src/api/domain/asset-definition.ts
+++ b/modules/assets/src/api/domain/asset-definition.ts
@@ -18,6 +18,13 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),
}),
+ z.strictObject({
+ kind: z.literal("relation"),
+ name: title,
+ inverseName: title,
+ description: z.string().max(1000),
+ symmetric: z.boolean(),
+ }),
]);
export type AssetDefinitionSpec = z.infer;
export type AssetFieldType = z.infer;
diff --git a/modules/assets/src/api/domain/asset-relationship.aggregate.ts b/modules/assets/src/api/domain/asset-relationship.aggregate.ts
new file mode 100644
index 0000000..f0a7be2
--- /dev/null
+++ b/modules/assets/src/api/domain/asset-relationship.aggregate.ts
@@ -0,0 +1,78 @@
+import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
+import { Maybe, Result } from "@repo/rdx-utils";
+import { z } from "zod/v4";
+
+import { AssetCatalogError } from "./asset-definition";
+import type { AssetRelationshipValues } from "./value-objects/asset-relationship-values";
+
+type RelationshipProps = Readonly<{
+ companyId: UniqueID;
+ values: AssetRelationshipValues;
+ createdBy: UniqueID;
+ createdAt: string;
+ endedBy: Maybe;
+ endedAt: Maybe;
+}>;
+export class AssetRelationship extends AggregateRoot {
+ private constructor(props: RelationshipProps, id?: UniqueID) {
+ super(Object.freeze({ ...props }), id);
+ }
+ static create(
+ companyId: UniqueID,
+ values: AssetRelationshipValues,
+ actor: UniqueID,
+ now: string
+ ): Result {
+ if (!z.iso.datetime().safeParse(now).success) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ return Result.ok(
+ new AssetRelationship({
+ companyId,
+ values,
+ createdBy: actor,
+ createdAt: now,
+ endedBy: Maybe.none(),
+ endedAt: Maybe.none(),
+ })
+ );
+ }
+ static rehydrate(props: RelationshipProps, id: UniqueID) {
+ return new AssetRelationship(props, id);
+ }
+ get companyId() {
+ return this.props.companyId;
+ }
+ get values() {
+ return this.props.values;
+ }
+ get createdBy() {
+ return this.props.createdBy;
+ }
+ get createdAt() {
+ return this.props.createdAt;
+ }
+ get endedBy() {
+ return this.props.endedBy;
+ }
+ get endedAt() {
+ return this.props.endedAt;
+ }
+ get active() {
+ return !this.endedAt.getOrUndefined();
+ }
+ end(actor: UniqueID, now: string): Result {
+ if (!this.active) {
+ return Result.ok(this);
+ }
+ if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ return Result.ok(
+ new AssetRelationship(
+ { ...this.props, endedBy: Maybe.some(actor), endedAt: Maybe.some(now) },
+ this.id
+ )
+ );
+ }
+}
diff --git a/modules/assets/src/api/domain/asset-relationship.test.ts b/modules/assets/src/api/domain/asset-relationship.test.ts
new file mode 100644
index 0000000..73d022d
--- /dev/null
+++ b/modules/assets/src/api/domain/asset-relationship.test.ts
@@ -0,0 +1,124 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { UniqueID } from "@repo/rdx-ddd";
+import { Maybe } from "@repo/rdx-utils";
+
+import { AssetCatalog } from "./asset-catalog.aggregate";
+import { seedAssetCatalog } from "./asset-catalog-seed";
+import { AssetRelationship } from "./asset-relationship.aggregate";
+import { AssetDefinitionValues } from "./value-objects/asset-definition-values";
+import {
+ AssetRelationshipValues,
+ type RelationshipValuesData,
+} from "./value-objects/asset-relationship-values";
+
+const sourceId = UniqueID.generateNewID().toPrimitive();
+const targetId = UniqueID.generateNewID().toPrimitive();
+const typeId = UniqueID.generateNewID().toPrimitive();
+function input(symmetric = false): RelationshipValuesData {
+ return {
+ sourceId,
+ targetId,
+ typeId,
+ name: " Alojado en ",
+ inverseName: " Aloja ",
+ symmetric,
+ notes: " rack 1 ",
+ };
+}
+function values(data = input()) {
+ const result = AssetRelationshipValues.create(data);
+ assert.ok(result.isSuccess);
+ return result.data;
+}
+test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => {
+ const first = values();
+ assert.equal(first.toPrimitive().name, "Alojado en");
+ assert.equal(first.notes, "rack 1");
+ assert.notEqual(
+ first.key(),
+ values({ ...input(), sourceId: targetId, targetId: sourceId }).key()
+ );
+ const symmetric = values(input(true));
+ assert.equal(
+ symmetric.key(),
+ values({ ...input(true), sourceId: targetId, targetId: sourceId }).key()
+ );
+ assert.equal(symmetric.toPrimitive().inverseName, "Alojado en");
+ const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId });
+ assert.ok(!self.isSuccess);
+ assert.equal(self.error.code, "SELF_RELATIONSHIP");
+ assert.ok(!AssetRelationshipValues.create({ ...input(), targetId: "bad" }).isSuccess);
+ assert.ok(!AssetRelationshipValues.create({ ...input(), inverseName: " " }).isSuccess);
+ assert.ok(!AssetRelationshipValues.create({ ...input(), notes: "x".repeat(10001) }).isSuccess);
+ assert.ok(Object.isFrozen(first));
+});
+test("Retirar conserva UUID, extremos y auditoría; repetir la retirada no modifica el historial", () => {
+ const company = UniqueID.generateNewID();
+ const actor = UniqueID.generateNewID();
+ const otherActor = UniqueID.generateNewID();
+ const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
+ assert.ok(created.isSuccess);
+ const link = created.data;
+ assert.equal(link.id.toPrimitive().charAt(14), "7");
+ assert.ok(link.active);
+ assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess);
+ const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z");
+ assert.ok(ended.isSuccess);
+ assert.equal(ended.data.active, false);
+ assert.ok(ended.data.id.equals(link.id));
+ assert.equal(ended.data.values, link.values);
+ assert.ok(ended.data.createdBy.equals(actor));
+ assert.ok(ended.data.endedBy.getOrUndefined()?.equals(otherActor));
+ const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z");
+ assert.ok(retry.isSuccess);
+ assert.equal(retry.data, ended.data);
+ assert.ok(link.active);
+});
+test("Tipos de relación de serie protegidos, ámbito de compañía y dirección fija", () => {
+ const scope = Maybe.some(UniqueID.generateNewID());
+ const other = Maybe.some(UniqueID.generateNewID());
+ let catalog = seedAssetCatalog(AssetCatalog.create());
+ const stock = catalog.definitions.find((item) => item.values.data.kind === "relation");
+ assert.ok(stock);
+ const spec = {
+ kind: "relation",
+ name: "Conectado a",
+ inverseName: "Conectado a",
+ description: "",
+ symmetric: true,
+ } as const;
+ const custom = AssetDefinitionValues.create({
+ ...spec,
+ name: "Replica a",
+ inverseName: "Replicado desde",
+ symmetric: false,
+ });
+ assert.ok(custom.isSuccess);
+ const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data);
+ assert.ok(!protectedChange.isSuccess);
+ assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED");
+ const status = catalog.setActive(scope, stock.id, false);
+ assert.ok(status.isSuccess);
+ catalog = status.data;
+ assert.equal(catalog.active(stock, scope), false);
+ assert.equal(catalog.active(stock, other), true);
+ const added = catalog.save(scope, Maybe.none(), custom.data);
+ assert.ok(added.isSuccess);
+ catalog = added.data;
+ const item = catalog.definitions.at(-1);
+ assert.ok(item);
+ assert.equal(
+ catalog.visible(other).some((entry) => entry.id.equals(item.id)),
+ false
+ );
+ const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" });
+ assert.ok(converted.isSuccess);
+ const rejected = catalog.save(scope, Maybe.some(item.id), converted.data);
+ assert.ok(!rejected.isSuccess);
+ assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED");
+ const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data);
+ assert.ok(unchanged.isSuccess);
+ assert.equal(unchanged.data, catalog);
+});
diff --git a/modules/assets/src/api/domain/value-objects/asset-definition-values.ts b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts
index 272b13f..8d0b89c 100644
--- a/modules/assets/src/api/domain/value-objects/asset-definition-values.ts
+++ b/modules/assets/src/api/domain/value-objects/asset-definition-values.ts
@@ -41,6 +41,9 @@ export class AssetDefinitionValues {
Object.freeze(values);
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
}
+ if (data.kind === "relation") {
+ return AssetDefinitionValues.relation(data, name.data);
+ }
const ids: UniqueID[] = [];
for (const field of data.fields) {
const id = UniqueID.create(field.fieldId);
@@ -68,6 +71,24 @@ export class AssetDefinitionValues {
)
);
}
+ private static relation(data: Extract, name: Name) {
+ const inverse = Name.create(data.symmetric ? data.name : data.inverseName, 100);
+ if (!inverse.isSuccess) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ return Result.ok(
+ new AssetDefinitionValues(
+ Object.freeze({
+ ...data,
+ name: name.toPrimitive(),
+ inverseName: inverse.data.toPrimitive(),
+ description: data.description.trim(),
+ }),
+ name,
+ Object.freeze([])
+ )
+ );
+ }
equals(other: AssetDefinitionValues): boolean {
const a = this.data;
const b = other.data;
@@ -82,6 +103,9 @@ export class AssetDefinitionValues {
a.choices.every((choice, index) => choice === b.choices[index])
);
}
+ if (a.kind === "relation" && b.kind === "relation") {
+ return a.inverseName === b.inverseName && a.symmetric === b.symmetric;
+ }
return (
a.kind === "template" &&
b.kind === "template" &&
diff --git a/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts b/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts
new file mode 100644
index 0000000..dc3d805
--- /dev/null
+++ b/modules/assets/src/api/domain/value-objects/asset-relationship-values.ts
@@ -0,0 +1,92 @@
+import { Name, UniqueID } from "@repo/rdx-ddd";
+import { Result } from "@repo/rdx-utils";
+import { z } from "zod/v4";
+
+import { AssetCatalogError } from "../asset-definition";
+
+export const RelationshipValuesSchema = z.strictObject({
+ sourceId: z.uuid(),
+ targetId: z.uuid(),
+ typeId: z.uuid(),
+ name: z.string().min(1).max(100),
+ inverseName: z.string().min(1).max(100),
+ symmetric: z.boolean(),
+ notes: z.string().max(10000),
+});
+export type RelationshipValuesData = z.infer;
+
+export class AssetRelationshipValues {
+ private constructor(
+ public readonly sourceId: UniqueID,
+ public readonly targetId: UniqueID,
+ public readonly typeId: UniqueID,
+ public readonly name: Name,
+ public readonly inverseName: Name,
+ public readonly symmetric: boolean,
+ public readonly notes: string
+ ) {
+ Object.freeze(this);
+ }
+ static create(input: RelationshipValuesData): Result {
+ const parsed = RelationshipValuesSchema.safeParse(input);
+ const source = UniqueID.create(input.sourceId);
+ const target = UniqueID.create(input.targetId);
+ const type = UniqueID.create(input.typeId);
+ const name = Name.create(input.name, 100);
+ const inverse = Name.create(input.symmetric ? input.name : input.inverseName, 100);
+ if (
+ !(
+ parsed.success &&
+ source.isSuccess &&
+ target.isSuccess &&
+ type.isSuccess &&
+ name.isSuccess &&
+ inverse.isSuccess
+ )
+ ) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ if (source.data.equals(target.data)) {
+ return Result.fail(new AssetCatalogError("SELF_RELATIONSHIP"));
+ }
+ return Result.ok(
+ new AssetRelationshipValues(
+ source.data,
+ target.data,
+ type.data,
+ name.data,
+ inverse.data,
+ input.symmetric,
+ input.notes.trim()
+ )
+ );
+ }
+ toPrimitive(): RelationshipValuesData {
+ return {
+ sourceId: this.sourceId.toPrimitive(),
+ targetId: this.targetId.toPrimitive(),
+ typeId: this.typeId.toPrimitive(),
+ name: this.name.toPrimitive(),
+ inverseName: this.inverseName.toPrimitive(),
+ symmetric: this.symmetric,
+ notes: this.notes,
+ };
+ }
+ equals(other: AssetRelationshipValues): boolean {
+ return (
+ this.key() === other.key() &&
+ this.name.equals(other.name) &&
+ this.inverseName.equals(other.inverseName) &&
+ this.symmetric === other.symmetric &&
+ this.notes === other.notes
+ );
+ }
+ /** Identifica un vínculo; las relaciones simétricas normalizan el orden de sus extremos. */
+ key(): string {
+ const ids = [this.sourceId.toPrimitive(), this.targetId.toPrimitive()];
+ if (this.symmetric) {
+ ids.sort();
+ }
+ return [this.typeId.toPrimitive(), ...ids].join(":");
+ }
+}
diff --git a/modules/assets/src/api/domain/value-objects/workstation-values.ts b/modules/assets/src/api/domain/value-objects/workstation-values.ts
new file mode 100644
index 0000000..e7ccc61
--- /dev/null
+++ b/modules/assets/src/api/domain/value-objects/workstation-values.ts
@@ -0,0 +1,67 @@
+import { Name, UniqueID } from "@repo/rdx-ddd";
+import { Maybe, Result } from "@repo/rdx-utils";
+
+import { AssetCatalogError } from "../asset-definition";
+import { type WorkstationInput, WorkstationInputSchema } from "../workstation-values";
+export class WorkstationValues {
+ private constructor(
+ public readonly name: Name,
+ public readonly active: boolean,
+ public readonly siteId: Maybe,
+ public readonly personId: Maybe,
+ public readonly assetIds: readonly UniqueID[]
+ ) {
+ Object.freeze(this);
+ }
+ static create(input: WorkstationInput): Result {
+ const parsed = WorkstationInputSchema.safeParse(input);
+ const name = Name.create(input.name, 100);
+ const site = optionalIdentity(input.siteId);
+ const person = optionalIdentity(input.personId);
+ if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ const ids: UniqueID[] = [];
+ for (const value of parsed.data.assetIds) {
+ const id = UniqueID.create(value);
+ if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
+ return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
+ }
+ ids.push(id.data);
+ }
+ return Result.ok(
+ new WorkstationValues(name.data, input.active, site.data, person.data, Object.freeze(ids))
+ );
+ }
+ toPrimitive(): WorkstationInput {
+ return {
+ name: this.name.toPrimitive(),
+ active: this.active,
+ siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
+ personId: this.personId.getOrUndefined()?.toPrimitive() ?? null,
+ assetIds: this.assetIds.map((id) => id.toPrimitive()),
+ };
+ }
+ equals(other: WorkstationValues) {
+ const a = this.toPrimitive();
+ const b = other.toPrimitive();
+ return (
+ a.name === b.name &&
+ a.active === b.active &&
+ a.siteId === b.siteId &&
+ a.personId === b.personId &&
+ a.assetIds.length === b.assetIds.length &&
+ a.assetIds.every((id) => b.assetIds.includes(id))
+ );
+ }
+}
+
+function optionalIdentity(value: string | null): Result, AssetCatalogError> {
+ if (!value) {
+ return Result.ok(Maybe.none());
+ }
+ const id = UniqueID.create(value);
+ return id.isSuccess
+ ? Result.ok(Maybe.some(id.data))
+ : Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+}
diff --git a/modules/assets/src/api/domain/workstation-revision.entity.ts b/modules/assets/src/api/domain/workstation-revision.entity.ts
new file mode 100644
index 0000000..00adc25
--- /dev/null
+++ b/modules/assets/src/api/domain/workstation-revision.entity.ts
@@ -0,0 +1,25 @@
+import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
+
+import type { WorkstationValues } from "./value-objects/workstation-values";
+export type WorkstationRevisionProps = Readonly<{
+ values: WorkstationValues;
+ actorId: UniqueID;
+ recordedAt: string;
+}>;
+export class WorkstationRevision extends DomainEntity {
+ static create(props: WorkstationRevisionProps) {
+ return new WorkstationRevision(Object.freeze({ ...props }));
+ }
+ static rehydrate(props: WorkstationRevisionProps, id: UniqueID) {
+ return new WorkstationRevision(Object.freeze({ ...props }), id);
+ }
+ get values() {
+ return this.props.values;
+ }
+ get actorId() {
+ return this.props.actorId;
+ }
+ get recordedAt() {
+ return this.props.recordedAt;
+ }
+}
diff --git a/modules/assets/src/api/domain/workstation-values.ts b/modules/assets/src/api/domain/workstation-values.ts
new file mode 100644
index 0000000..27f43a5
--- /dev/null
+++ b/modules/assets/src/api/domain/workstation-values.ts
@@ -0,0 +1,9 @@
+import { z } from "zod/v4";
+export const WorkstationInputSchema = z.strictObject({
+ name: z.string().min(1).max(100),
+ active: z.boolean(),
+ siteId: z.uuid().nullable(),
+ personId: z.uuid().nullable(),
+ assetIds: z.array(z.uuid()).max(200),
+});
+export type WorkstationInput = z.infer;
diff --git a/modules/assets/src/api/domain/workstation.aggregate.ts b/modules/assets/src/api/domain/workstation.aggregate.ts
new file mode 100644
index 0000000..181880b
--- /dev/null
+++ b/modules/assets/src/api/domain/workstation.aggregate.ts
@@ -0,0 +1,72 @@
+import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
+import { Result } from "@repo/rdx-utils";
+import { z } from "zod/v4";
+
+import { AssetCatalogError } from "./asset-definition";
+import type { WorkstationValues } from "./value-objects/workstation-values";
+import { WorkstationRevision } from "./workstation-revision.entity";
+
+type WorkstationProps = Readonly<{
+ companyId: UniqueID;
+ values: WorkstationValues;
+ history: readonly WorkstationRevision[];
+}>;
+export class Workstation extends AggregateRoot {
+ private constructor(props: WorkstationProps, id?: UniqueID) {
+ super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
+ }
+ static create(
+ companyId: UniqueID,
+ values: WorkstationValues,
+ actorId: UniqueID,
+ recordedAt: string
+ ): Result {
+ if (!z.iso.datetime().safeParse(recordedAt).success) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ return Result.ok(
+ new Workstation({
+ companyId,
+ values,
+ history: [WorkstationRevision.create({ values, actorId, recordedAt })],
+ })
+ );
+ }
+ static rehydrate(props: WorkstationProps, id: UniqueID) {
+ return new Workstation(props, id);
+ }
+ get companyId() {
+ return this.props.companyId;
+ }
+ get values() {
+ return this.props.values;
+ }
+ get history() {
+ return this.props.history;
+ }
+ update(
+ values: WorkstationValues,
+ actorId: UniqueID,
+ recordedAt: string
+ ): Result {
+ if (this.values.equals(values)) {
+ return Result.ok(this);
+ }
+ if (
+ !z.iso.datetime().safeParse(recordedAt).success ||
+ recordedAt < (this.history.at(-1)?.recordedAt ?? "")
+ ) {
+ return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
+ }
+ return Result.ok(
+ new Workstation(
+ {
+ ...this.props,
+ values,
+ history: [...this.history, WorkstationRevision.create({ values, actorId, recordedAt })],
+ },
+ this.id
+ )
+ );
+ }
+}
diff --git a/modules/assets/src/api/domain/workstation.test.ts b/modules/assets/src/api/domain/workstation.test.ts
new file mode 100644
index 0000000..b652500
--- /dev/null
+++ b/modules/assets/src/api/domain/workstation.test.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { UniqueID } from "@repo/rdx-ddd";
+
+import { WorkstationValues } from "./value-objects/workstation-values";
+import { Workstation } from "./workstation.aggregate";
+import type { WorkstationInput } from "./workstation-values";
+
+const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()];
+function input(): WorkstationInput {
+ return { name: " Puesto Ana ", active: true, siteId: null, personId: null, assetIds };
+}
+function values(data = input()) {
+ const result = WorkstationValues.create(data);
+ assert.ok(result.isSuccess);
+ return result.data;
+}
+test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => {
+ assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess);
+ assert.equal(values().name.toPrimitive(), "Puesto Ana");
+ assert.ok(Object.isFrozen(values().assetIds));
+ assert.ok(!WorkstationValues.create({ ...input(), name: " " }).isSuccess);
+ assert.ok(!WorkstationValues.create({ ...input(), personId: "invalid" }).isSuccess);
+ const duplicate = WorkstationValues.create({
+ ...input(),
+ assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""],
+ });
+ assert.ok(!duplicate.isSuccess);
+ assert.equal(duplicate.error.code, "INVALID_MEMBERS");
+ assert.ok(
+ !WorkstationValues.create({
+ ...input(),
+ assetIds: Array.from({ length: 201 }, () => UniqueID.generateNewID().toPrimitive()),
+ }).isSuccess
+ );
+ assert.ok(values().equals(values({ ...input(), assetIds: [...assetIds].reverse() })));
+});
+test("Puestos: UUID estable e historial de composición, persona y estado; guardado idéntico idempotente", () => {
+ const company = UniqueID.generateNewID();
+ const actor = UniqueID.generateNewID();
+ const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
+ assert.ok(created.isSuccess);
+ const item = created.data;
+ assert.equal(item.id.toPrimitive().charAt(14), "7");
+ assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7");
+ const same = item.update(
+ values({ ...input(), assetIds: [...assetIds].reverse() }),
+ actor,
+ "2026-10-04T11:00:00.000Z"
+ );
+ assert.ok(same.isSuccess);
+ assert.equal(same.data, item);
+ const changed = item.update(
+ values({ ...input(), personId: actor.toPrimitive(), active: false, assetIds: [] }),
+ actor,
+ "2026-10-04T11:00:00.000Z"
+ );
+ assert.ok(changed.isSuccess);
+ assert.ok(changed.data.id.equals(item.id));
+ assert.equal(changed.data.history.length, 2);
+ assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null);
+ assert.deepEqual(changed.data.history[0]?.values.toPrimitive().assetIds, assetIds);
+ assert.ok(!changed.data.update(values(), actor, "2026-10-03T10:00:00.000Z").isSuccess);
+ assert.equal(
+ Workstation.rehydrate(
+ { companyId: company, values: changed.data.values, history: changed.data.history },
+ changed.data.id
+ ).id.toPrimitive(),
+ item.id.toPrimitive()
+ );
+});
diff --git a/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts b/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts
new file mode 100644
index 0000000..b2add85
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/http/asset-relationship.routes.ts
@@ -0,0 +1,81 @@
+import type { Application, RequestHandler, Response } from "express";
+import { Router } from "express";
+import { z } from "zod/v4";
+
+import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory";
+import { RelationshipRequestSchema } from "../../../common/relationships";
+import type { AssetRelationshipUseCases } from "../../application/asset-relationship.use-cases";
+import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder";
+
+function fail(response: Response, code: string) {
+ response
+ .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
+ .json({ code, requestId: response.getHeader("x-request-id") });
+}
+export function registerRelationshipRoutes(
+ app: Application,
+ useCases: AssetRelationshipUseCases,
+ authenticate: RequestHandler
+) {
+ const router = Router();
+ router.post("/:companyId/relationships", (request, response, next) => {
+ const params = InventoryParamsSchema.safeParse(request.params);
+ const body = RelationshipRequestSchema.safeParse(request.body);
+ const actor = z
+ .object({ userId: z.uuid() })
+ .safeParse("session" in request ? request.session : undefined);
+ if (
+ !(
+ params.success &&
+ body.success &&
+ actor.success &&
+ z.strictObject({}).safeParse(request.query).success
+ )
+ ) {
+ fail(response, "INVALID_REQUEST");
+ return;
+ }
+ useCases
+ .save(params.data.companyId, body.data.values, actor.data.userId)
+ .then((result) => {
+ if (!result.isSuccess) {
+ fail(response, result.error.code);
+ return;
+ }
+ response.status(201).json({
+ relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames),
+ });
+ })
+ .catch(next);
+ });
+ router.post("/:companyId/relationships/:id/end", (request, response, next) => {
+ const params = AssetParamsSchema.safeParse(request.params);
+ const actor = z
+ .object({ userId: z.uuid() })
+ .safeParse("session" in request ? request.session : undefined);
+ if (
+ !(
+ params.success &&
+ actor.success &&
+ z.strictObject({}).safeParse(request.body).success &&
+ z.strictObject({}).safeParse(request.query).success
+ )
+ ) {
+ fail(response, "INVALID_REQUEST");
+ return;
+ }
+ useCases
+ .end(params.data.companyId, params.data.id, actor.data.userId)
+ .then((result) => {
+ if (!result.isSuccess) {
+ fail(response, result.error.code);
+ return;
+ }
+ response.json({
+ relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames),
+ });
+ })
+ .catch(next);
+ });
+ app.use("/api/assets", authenticate, router);
+}
diff --git a/modules/assets/src/api/infrastructure/http/asset.routes.ts b/modules/assets/src/api/infrastructure/http/asset.routes.ts
index 9521b1f..9974cfa 100644
--- a/modules/assets/src/api/infrastructure/http/asset.routes.ts
+++ b/modules/assets/src/api/infrastructure/http/asset.routes.ts
@@ -8,7 +8,9 @@ import {
SaveAssetRequestSchema,
} from "../../../common/inventory";
import type { AssetUseCases } from "../../application/asset.use-cases";
+import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder";
import { buildAssetResponse } from "../../application/asset-response.builder";
+import { buildWorkstationResponse } from "../../application/workstation-response.builder";
function fail(response: Response, code: string) {
response
@@ -35,6 +37,12 @@ export function registerAssetRoutes(
return;
}
response.json({
+ workstations: result.data.workstations.map((item) =>
+ buildWorkstationResponse(item, result.data.actorNames)
+ ),
+ relationships: result.data.relationships.map((item) =>
+ buildRelationshipResponse(item, result.data.actorNames)
+ ),
assets: result.data.assets.map((asset) =>
buildAssetResponse(asset, result.data.actorNames)
),
diff --git a/modules/assets/src/api/infrastructure/http/workstation.routes.ts b/modules/assets/src/api/infrastructure/http/workstation.routes.ts
new file mode 100644
index 0000000..40b6ca8
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/http/workstation.routes.ts
@@ -0,0 +1,58 @@
+import type { Application, RequestHandler, Response } from "express";
+import { Router } from "express";
+import { z } from "zod/v4";
+
+import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory";
+import { SaveWorkstationRequestSchema } from "../../../common/workstations";
+import type { WorkstationUseCases } from "../../application/workstation.use-cases";
+import { buildWorkstationResponse } from "../../application/workstation-response.builder";
+
+function fail(response: Response, code: string) {
+ response
+ .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
+ .json({ code, requestId: response.getHeader("x-request-id") });
+}
+export function registerWorkstationRoutes(
+ app: Application,
+ useCases: WorkstationUseCases,
+ authenticate: RequestHandler
+) {
+ const router = Router();
+ for (const path of ["/:companyId/workstations", "/:companyId/workstations/:id/update"]) {
+ router.post(path, (request, response, next) => {
+ const params = (
+ path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema
+ ).safeParse(request.params);
+ const body = SaveWorkstationRequestSchema.safeParse(request.body);
+ const actor = z
+ .object({ userId: z.uuid() })
+ .safeParse("session" in request ? request.session : undefined);
+ if (
+ !(
+ params.success &&
+ body.success &&
+ actor.success &&
+ z.strictObject({}).safeParse(request.query).success
+ )
+ ) {
+ fail(response, "INVALID_REQUEST");
+ return;
+ }
+ const id =
+ "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
+ useCases
+ .save(params.data.companyId, id, body.data.values, actor.data.userId)
+ .then((result) => {
+ if (!result.isSuccess) {
+ fail(response, result.error.code);
+ return;
+ }
+ response.status(id ? 200 : 201).json({
+ workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames),
+ });
+ })
+ .catch(next);
+ });
+ }
+ app.use("/api/assets", authenticate, router);
+}
diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts
new file mode 100644
index 0000000..f49ad05
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship-persistence.mapper.ts
@@ -0,0 +1,61 @@
+import { Maybe } from "@repo/rdx-utils";
+import { z } from "zod/v4";
+
+import { AssetRelationship } from "../../domain/asset-relationship.aggregate";
+import {
+ AssetRelationshipValues,
+ RelationshipValuesSchema,
+} from "../../domain/value-objects/asset-relationship-values";
+
+import { inventoryIdentity } from "./asset-persistence.mapper";
+import type { RelationshipModel } from "./asset-relationship.model";
+
+export class AssetRelationshipPersistenceMapper {
+ toDomain(row: RelationshipModel) {
+ const values = AssetRelationshipValues.create(RelationshipValuesSchema.parse(row.values));
+ if (
+ !(values.isSuccess && z.iso.datetime().safeParse(row.createdAt).success) ||
+ (row.endedAt !== null &&
+ (!z.iso.datetime().safeParse(row.endedAt).success || row.endedAt < row.createdAt)) ||
+ (row.endedAt === null) !== (row.endedBy === null)
+ ) {
+ throw new Error("Relación de activos persistida inconsistente.");
+ }
+ const item = AssetRelationship.rehydrate(
+ {
+ companyId: inventoryIdentity(row.companyId),
+ values: values.data,
+ createdBy: inventoryIdentity(row.createdBy),
+ createdAt: row.createdAt,
+ endedBy: row.endedBy ? Maybe.some(inventoryIdentity(row.endedBy)) : Maybe.none(),
+ endedAt: row.endedAt ? Maybe.some(row.endedAt) : Maybe.none(),
+ },
+ inventoryIdentity(row.id)
+ );
+ if (
+ row.sourceId !== item.values.sourceId.toPrimitive() ||
+ row.targetId !== item.values.targetId.toPrimitive() ||
+ row.typeId !== item.values.typeId.toPrimitive() ||
+ row.activeKey !== (item.active ? `${row.companyId}:${item.values.key()}` : null)
+ ) {
+ throw new Error("Referencias de relación persistidas inconsistentes.");
+ }
+ return item;
+ }
+ toPersistence(item: AssetRelationship) {
+ const values = item.values.toPrimitive();
+ return {
+ id: item.id.toPrimitive(),
+ companyId: item.companyId.toPrimitive(),
+ sourceId: values.sourceId,
+ targetId: values.targetId,
+ typeId: values.typeId,
+ values,
+ createdBy: item.createdBy.toPrimitive(),
+ createdAt: item.createdAt,
+ endedBy: item.endedBy.getOrUndefined()?.toPrimitive() ?? null,
+ endedAt: item.endedAt.getOrUndefined() ?? null,
+ activeKey: item.active ? `${item.companyId.toPrimitive()}:${item.values.key()}` : null,
+ };
+ }
+}
diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts
new file mode 100644
index 0000000..9fc49d0
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/persistence/asset-relationship.model.ts
@@ -0,0 +1,77 @@
+import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
+import { DataTypes, Model, QueryTypes } from "sequelize";
+export class RelationshipModel extends Model<
+ InferAttributes,
+ InferCreationAttributes
+> {
+ declare id: string;
+ declare companyId: string;
+ declare sourceId: string;
+ declare targetId: string;
+ declare typeId: string;
+ declare values: unknown;
+ declare createdBy: string;
+ declare createdAt: string;
+ declare endedBy: string | null;
+ declare endedAt: string | null;
+ declare activeKey: string | null;
+}
+export async function migrateRelationships(database: Sequelize) {
+ const query = database.getQueryInterface();
+ const tables = await database.query<{ name: string }>(
+ "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
+ { type: QueryTypes.SELECT }
+ );
+ const fk = (model: string, nullable = false) => ({
+ type: DataTypes.UUID,
+ allowNull: nullable,
+ references: { model, key: "id" },
+ onDelete: "RESTRICT",
+ });
+ const columns = {
+ id: { type: DataTypes.UUID, primaryKey: true },
+ companyId: fk("companies"),
+ sourceId: fk("assets"),
+ targetId: fk("assets"),
+ typeId: fk("asset_definitions"),
+ values: { type: DataTypes.JSON, allowNull: false },
+ createdBy: fk("users"),
+ createdAt: { type: DataTypes.STRING(30), allowNull: false },
+ endedBy: fk("users", true),
+ endedAt: { type: DataTypes.STRING(30), allowNull: true },
+ activeKey: { type: DataTypes.STRING(150), allowNull: true, unique: true },
+ };
+ if (!tables.some((table) => table.name === "asset_relationships")) {
+ await query.createTable("asset_relationships", columns);
+ }
+ const indexes = await database.query<{ name: string }>(
+ "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets'",
+ { type: QueryTypes.SELECT }
+ );
+ if (!indexes.some((index) => index.name === "assets_company_identity")) {
+ await query.addIndex("assets", ["companyId", "id"], {
+ unique: true,
+ name: "assets_company_identity",
+ });
+ }
+ const constraints = await database.query<{ name: string }>(
+ "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_relationships'",
+ { type: QueryTypes.SELECT }
+ );
+ // Las FK compuestas impiden vínculos cruzados también ante escrituras externas al dominio.
+ for (const [column, name] of [
+ ["sourceId", "relationship_source_company"],
+ ["targetId", "relationship_target_company"],
+ ] as const) {
+ if (!constraints.some((constraint) => constraint.name === name)) {
+ await database.query(
+ `ALTER TABLE asset_relationships ADD CONSTRAINT \`${name}\` FOREIGN KEY (companyId, \`${column}\`) REFERENCES assets(companyId,id)`
+ );
+ }
+ }
+ RelationshipModel.init(columns, {
+ sequelize: database,
+ tableName: "asset_relationships",
+ timestamps: false,
+ });
+}
diff --git a/modules/assets/src/api/infrastructure/persistence/load-workstations.ts b/modules/assets/src/api/infrastructure/persistence/load-workstations.ts
new file mode 100644
index 0000000..6074ba7
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/persistence/load-workstations.ts
@@ -0,0 +1,51 @@
+import type { UniqueID } from "@repo/rdx-ddd";
+import type { Transaction } from "sequelize";
+
+import type { Asset } from "../../domain/asset.aggregate";
+
+import {
+ WorkstationAssetModel,
+ WorkstationModel,
+ WorkstationRevisionModel,
+} from "./workstation.model";
+import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
+export async function loadWorkstations(
+ companyId: UniqueID,
+ transaction: Transaction,
+ assets: readonly Asset[],
+ sites: readonly { id: string }[],
+ people: readonly { id: string }[]
+) {
+ const rows = await WorkstationModel.findAll({
+ where: { companyId: companyId.toPrimitive() },
+ transaction,
+ });
+ const revisions = rows.length
+ ? await WorkstationRevisionModel.findAll({
+ where: { workstationId: rows.map((item) => item.id) },
+ transaction,
+ })
+ : [];
+ const references = await WorkstationAssetModel.findAll({
+ where: { companyId: companyId.toPrimitive() },
+ transaction,
+ });
+ return rows.map((row) => {
+ const item = new WorkstationPersistenceMapper().toDomain(
+ row,
+ revisions.filter((revision) => revision.workstationId === row.id),
+ references.filter((reference) => reference.workstationId === row.id)
+ );
+ for (const revision of item.history) {
+ const values = revision.values.toPrimitive();
+ if (
+ (values.siteId && !sites.some((site) => site.id === values.siteId)) ||
+ (values.personId && !people.some((person) => person.id === values.personId)) ||
+ values.assetIds.some((id) => !assets.some((asset) => asset.id.toPrimitive() === id))
+ ) {
+ throw new Error("Puesto de trabajo fuera de su compañía.");
+ }
+ }
+ return item;
+ });
+}
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 14ff851..1f3519b 100644
--- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts
+++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts
@@ -1,10 +1,11 @@
import type { UniqueID } from "@repo/rdx-ddd";
-import { Result } from "@repo/rdx-utils";
+import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
import { AssetCatalogError } from "../../domain/asset-definition";
+import type { Workstation } from "../../domain/workstation.aggregate";
import { AssetModel, AssetRevisionModel } from "./asset.model";
import {
@@ -15,6 +16,15 @@ import {
} from "./asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
+import { RelationshipModel } from "./asset-relationship.model";
+import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
+import { loadWorkstations } from "./load-workstations";
+import {
+ WorkstationAssetModel,
+ WorkstationModel,
+ WorkstationRevisionModel,
+} from "./workstation.model";
+import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
export class SequelizeAssetRepository implements IAssetRepository {
private readonly mapper = new AssetPersistenceMapper();
constructor(private readonly database: Sequelize) {}
@@ -106,7 +116,34 @@ export class SequelizeAssetRepository implements IAssetRepository {
}
return asset;
});
+ const relationships = (
+ await RelationshipModel.findAll({
+ where: { companyId: companyId.toPrimitive() },
+ transaction,
+ })
+ ).map((row) => new AssetRelationshipPersistenceMapper().toDomain(row));
+ for (const relationship of relationships) {
+ const values = relationship.values;
+ const definition = catalog
+ .visible(Maybe.some(companyId))
+ .find((item) => item.id.equals(values.typeId));
+ if (
+ !(
+ definition &&
+ definition.values.data.kind === "relation" &&
+ definition.values.data.symmetric === values.symmetric &&
+ [values.sourceId, values.targetId].every((id) =>
+ assets.some((asset) => asset.id.equals(id))
+ )
+ )
+ ) {
+ throw new Error("Relación de activos fuera de su compañía.");
+ }
+ }
+ const workstations = await loadWorkstations(companyId, transaction, assets, sites, people);
return Result.ok({
+ workstations,
+ relationships,
catalog,
assets,
sites: sites.map((site) => inventoryIdentity(site.id)),
@@ -117,6 +154,63 @@ export class SequelizeAssetRepository implements IAssetRepository {
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
+ saveWorkstation(companyId: UniqueID, action: Parameters[1]) {
+ return this.database.transaction(async (transaction) => {
+ const loaded = await this.load(companyId, transaction, true);
+ if (!loaded.isSuccess) {
+ return Result.fail(loaded.error);
+ }
+ const changed = action(loaded.data);
+ if (!changed.isSuccess) {
+ return Result.fail(changed.error);
+ }
+ const previous = loaded.data.workstations.find((item) => item.id.equals(changed.data.id));
+ if (previous !== changed.data) {
+ await this.persistWorkstation(changed.data, previous, transaction);
+ }
+ return Result.ok({ workstation: changed.data, actorNames: loaded.data.actorNames });
+ });
+ }
+ private async persistWorkstation(
+ item: Workstation,
+ previous: Workstation | undefined,
+ transaction: Transaction
+ ) {
+ const rows = new WorkstationPersistenceMapper().toPersistence(item);
+ await WorkstationModel.upsert(rows.workstation, { transaction });
+ const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()));
+ for (const revision of rows.revisions) {
+ if (!existing.has(revision.id)) {
+ await WorkstationRevisionModel.create(revision, { transaction });
+ }
+ }
+ for (const reference of rows.references) {
+ await WorkstationAssetModel.upsert(reference, { transaction });
+ }
+ }
+ saveRelationship(
+ companyId: UniqueID,
+ action: Parameters[1]
+ ) {
+ return this.database.transaction(async (transaction) => {
+ const loaded = await this.load(companyId, transaction, true);
+ if (!loaded.isSuccess) {
+ return Result.fail(loaded.error);
+ }
+ const changed = action(loaded.data);
+ if (!changed.isSuccess) {
+ return Result.fail(changed.error);
+ }
+ const previous = loaded.data.relationships.find((item) => item.id.equals(changed.data.id));
+ if (previous !== changed.data) {
+ await RelationshipModel.upsert(
+ new AssetRelationshipPersistenceMapper().toPersistence(changed.data),
+ { transaction }
+ );
+ }
+ return Result.ok({ relationship: changed.data, actorNames: loaded.data.actorNames });
+ });
+ }
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
diff --git a/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts b/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts
new file mode 100644
index 0000000..1c2f831
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/persistence/workstation-persistence.mapper.ts
@@ -0,0 +1,101 @@
+import { z } from "zod/v4";
+
+import { WorkstationValues } from "../../domain/value-objects/workstation-values";
+import { Workstation } from "../../domain/workstation.aggregate";
+import { WorkstationRevision } from "../../domain/workstation-revision.entity";
+import { WorkstationInputSchema } from "../../domain/workstation-values";
+
+import { inventoryIdentity } from "./asset-persistence.mapper";
+import type {
+ WorkstationAssetModel,
+ WorkstationModel,
+ WorkstationRevisionModel,
+} from "./workstation.model";
+
+function valuesOf(raw: unknown, siteId: string | null, personId: string | null) {
+ const values = WorkstationValues.create(WorkstationInputSchema.parse(raw));
+ if (
+ !values.isSuccess ||
+ values.data.toPrimitive().siteId !== siteId ||
+ values.data.toPrimitive().personId !== personId
+ ) {
+ throw new Error("Valores de puesto persistidos inconsistentes.");
+ }
+ return values.data;
+}
+export class WorkstationPersistenceMapper {
+ toDomain(
+ row: WorkstationModel,
+ revisions: readonly WorkstationRevisionModel[],
+ references: readonly WorkstationAssetModel[]
+ ) {
+ const values = valuesOf(row.values, row.siteId, row.personId);
+ const ordered = [...revisions].sort((a, b) => a.position - b.position);
+ const history = ordered.map((revision, position) => {
+ if (
+ revision.position !== position ||
+ !z.iso.datetime().safeParse(revision.recordedAt).success ||
+ revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "")
+ ) {
+ throw new Error("Historial de puesto inconsistente.");
+ }
+ return WorkstationRevision.rehydrate(
+ {
+ values: valuesOf(revision.values, revision.siteId, revision.personId),
+ actorId: inventoryIdentity(revision.actorId),
+ recordedAt: revision.recordedAt,
+ },
+ inventoryIdentity(revision.id)
+ );
+ });
+ if (!(history.length && history.at(-1)?.values.equals(values))) {
+ throw new Error("Última revisión de puesto inconsistente.");
+ }
+ const expected = new Set(history.flatMap((item) => item.values.toPrimitive().assetIds));
+ if (
+ references.length !== expected.size ||
+ references.some(
+ (item) =>
+ item.companyId !== row.companyId ||
+ !expected.has(item.assetId) ||
+ item.activeKey !==
+ (values.active && values.assetIds.some((id) => id.toPrimitive() === item.assetId)
+ ? `${row.companyId}:${item.assetId}`
+ : null)
+ )
+ ) {
+ throw new Error("Referencias de puesto inconsistentes.");
+ }
+ return Workstation.rehydrate(
+ { companyId: inventoryIdentity(row.companyId), values, history },
+ inventoryIdentity(row.id)
+ );
+ }
+ toPersistence(item: Workstation) {
+ const values = item.values.toPrimitive();
+ const companyId = item.companyId.toPrimitive();
+ const id = item.id.toPrimitive();
+ return {
+ workstation: { id, companyId, values, siteId: values.siteId, personId: values.personId },
+ revisions: item.history.map((revision, position) => ({
+ id: revision.id.toPrimitive(),
+ workstationId: id,
+ actorId: revision.actorId.toPrimitive(),
+ recordedAt: revision.recordedAt,
+ values: revision.values.toPrimitive(),
+ position,
+ siteId: revision.values.toPrimitive().siteId,
+ personId: revision.values.toPrimitive().personId,
+ })),
+ references: [
+ ...new Set(item.history.flatMap((revision) => revision.values.toPrimitive().assetIds)),
+ ].map((assetId) => ({
+ workstationId: id,
+ companyId,
+ assetId,
+ activeKey:
+ values.active && values.assetIds.includes(assetId) ? `${companyId}:${assetId}` : null,
+ })),
+ };
+ }
+}
diff --git a/modules/assets/src/api/infrastructure/persistence/workstation.model.ts b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts
new file mode 100644
index 0000000..a824320
--- /dev/null
+++ b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts
@@ -0,0 +1,119 @@
+import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
+import { DataTypes, Model, QueryTypes } from "sequelize";
+export class WorkstationModel extends Model<
+ InferAttributes,
+ InferCreationAttributes
+> {
+ declare id: string;
+ declare companyId: string;
+ declare siteId: string | null;
+ declare personId: string | null;
+ declare values: unknown;
+}
+export class WorkstationRevisionModel extends Model<
+ InferAttributes,
+ InferCreationAttributes
+> {
+ declare id: string;
+ declare workstationId: string;
+ declare actorId: string;
+ declare recordedAt: string;
+ declare values: unknown;
+ declare position: number;
+ declare siteId: string | null;
+ declare personId: string | null;
+}
+/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */
+export class WorkstationAssetModel extends Model<
+ InferAttributes,
+ InferCreationAttributes
+> {
+ declare workstationId: string;
+ declare companyId: string;
+ declare assetId: string;
+ declare activeKey: string | null;
+}
+export async function migrateWorkstations(database: Sequelize) {
+ const query = database.getQueryInterface();
+ const tables = await database.query<{ name: string }>(
+ "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
+ { type: QueryTypes.SELECT }
+ );
+ const fk = (model: string, nullable = false) => ({
+ type: DataTypes.UUID,
+ allowNull: nullable,
+ references: { model, key: "id" },
+ onDelete: "RESTRICT",
+ });
+ const root = {
+ id: { type: DataTypes.UUID, primaryKey: true },
+ companyId: fk("companies"),
+ siteId: fk("company_sites", true),
+ personId: fk("company_contacts", true),
+ values: { type: DataTypes.JSON, allowNull: false },
+ };
+ const revision = {
+ id: { type: DataTypes.UUID, primaryKey: true },
+ workstationId: fk("workstations"),
+ actorId: fk("users"),
+ siteId: fk("company_sites", true),
+ personId: fk("company_contacts", true),
+ values: { type: DataTypes.JSON, allowNull: false },
+ recordedAt: { type: DataTypes.STRING(30), allowNull: false },
+ position: { type: DataTypes.INTEGER, allowNull: false },
+ };
+ const reference = {
+ workstationId: { ...fk("workstations"), primaryKey: true },
+ companyId: fk("companies"),
+ assetId: { ...fk("assets"), primaryKey: true },
+ activeKey: { type: DataTypes.STRING(80), unique: true, allowNull: true },
+ };
+ for (const [table, columns] of [
+ ["workstations", root],
+ ["workstation_revisions", revision],
+ ["workstation_assets", reference],
+ ] as const) {
+ if (!tables.some((item) => item.name === table)) {
+ await query.createTable(table, columns);
+ }
+ }
+ const indexes = await database.query<{ name: string }>(
+ "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstations'",
+ { type: QueryTypes.SELECT }
+ );
+ if (!indexes.some((item) => item.name === "workstations_company_identity")) {
+ await query.addIndex("workstations", ["companyId", "id"], {
+ name: "workstations_company_identity",
+ unique: true,
+ });
+ }
+ const constraints = await database.query<{ name: string }>(
+ "SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstation_assets'",
+ { type: QueryTypes.SELECT }
+ );
+ if (!constraints.some((item) => item.name === "workstation_asset_company")) {
+ await database.query(
+ "ALTER TABLE workstation_assets ADD CONSTRAINT workstation_asset_company FOREIGN KEY (companyId, assetId) REFERENCES assets(companyId,id)"
+ );
+ }
+ if (!constraints.some((item) => item.name === "workstation_owner_company")) {
+ await database.query(
+ "ALTER TABLE workstation_assets ADD CONSTRAINT workstation_owner_company FOREIGN KEY (companyId, workstationId) REFERENCES workstations(companyId,id)"
+ );
+ }
+ WorkstationModel.init(root, {
+ sequelize: database,
+ tableName: "workstations",
+ timestamps: false,
+ });
+ WorkstationRevisionModel.init(revision, {
+ sequelize: database,
+ tableName: "workstation_revisions",
+ timestamps: false,
+ });
+ WorkstationAssetModel.init(reference, {
+ sequelize: database,
+ tableName: "workstation_assets",
+ timestamps: false,
+ });
+}
diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts
index 58c6157..a7ef178 100644
--- a/modules/assets/src/api/module.ts
+++ b/modules/assets/src/api/module.ts
@@ -5,17 +5,23 @@ import type { Sequelize } from "sequelize";
import { AssetUseCases } from "./application/asset.use-cases";
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
+import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
+import { WorkstationUseCases } from "./application/workstation.use-cases";
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
+import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
+import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
import { migrateInventory } from "./infrastructure/persistence/asset.model";
import {
CatalogModel,
migrateAssetCatalog,
} from "./infrastructure/persistence/asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
+import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
+import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
await migrateAssetCatalog(database);
await CatalogModel.findOrCreate({
@@ -33,12 +39,19 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
throw new Error("No se pudo inicializar el catálogo de activos.");
}
await migrateInventory(database);
- const inventory = new AssetUseCases(new SequelizeAssetRepository(database));
+ await migrateRelationships(database);
+ await migrateWorkstations(database);
+ const assetRepository = new SequelizeAssetRepository(database);
+ const inventory = new AssetUseCases(assetRepository);
+ const workstations = new WorkstationUseCases(assetRepository);
+ const relationships = new AssetRelationshipUseCases(assetRepository);
const useCases = new AssetCatalogUseCases(repository);
return {
register: (app: Parameters[0]) => {
registerAssetCatalogRoutes(app, useCases, authenticate);
registerAssetRoutes(app, inventory, authenticate);
+ registerRelationshipRoutes(app, relationships, authenticate);
+ registerWorkstationRoutes(app, workstations, authenticate);
},
};
}
diff --git a/modules/assets/src/common/index.ts b/modules/assets/src/common/index.ts
index 17e0732..ef98a05 100644
--- a/modules/assets/src/common/index.ts
+++ b/modules/assets/src/common/index.ts
@@ -28,3 +28,5 @@ export type CatalogResponseDTO = z.infer;
export type SaveDefinitionResponseDTO = z.infer;
export * from "./inventory";
+export * from "./relationships";
+export * from "./workstations";
diff --git a/modules/assets/src/common/inventory.ts b/modules/assets/src/common/inventory.ts
index d034092..ce59a90 100644
--- a/modules/assets/src/common/inventory.ts
+++ b/modules/assets/src/common/inventory.ts
@@ -7,6 +7,9 @@ import {
AssetValueSchema,
} from "../api/domain/asset-values";
+import { RelationshipSchema } from "./relationships";
+import { WorkstationSchema } from "./workstations";
+
export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values";
export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema };
@@ -27,7 +30,11 @@ export const AssetSchema = z.strictObject({
values: AssetInputSchema,
history: z.array(AssetRevisionSchema).min(1),
});
-export const InventoryResponseSchema = z.strictObject({ assets: z.array(AssetSchema) });
+export const InventoryResponseSchema = z.strictObject({
+ workstations: z.array(WorkstationSchema),
+ assets: z.array(AssetSchema),
+ relationships: z.array(RelationshipSchema),
+});
export const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
export type AssetDTO = z.infer;
export type AssetResponseDTO = z.infer;
diff --git a/modules/assets/src/common/relationships.ts b/modules/assets/src/common/relationships.ts
new file mode 100644
index 0000000..872a969
--- /dev/null
+++ b/modules/assets/src/common/relationships.ts
@@ -0,0 +1,25 @@
+import { z } from "zod/v4";
+
+import { RelationshipValuesSchema } from "../api/domain/value-objects/asset-relationship-values";
+
+export const RelationshipInputSchema = z.strictObject({
+ sourceId: z.uuid(),
+ targetId: z.uuid(),
+ typeId: z.uuid(),
+ notes: z.string().max(10000),
+});
+export type RelationshipInputDTO = z.infer;
+export const RelationshipRequestSchema = z.strictObject({ values: RelationshipInputSchema });
+export const RelationshipSchema = z.strictObject({
+ id: z.uuid(),
+ companyId: z.uuid(),
+ values: RelationshipValuesSchema,
+ createdBy: z.uuid(),
+ createdByName: z.string(),
+ createdAt: z.iso.datetime(),
+ endedBy: z.uuid().nullable(),
+ endedByName: z.string().nullable(),
+ endedAt: z.iso.datetime().nullable(),
+});
+export const RelationshipResponseSchema = z.strictObject({ relationship: RelationshipSchema });
+export type RelationshipDTO = z.infer;
diff --git a/modules/assets/src/common/workstations.ts b/modules/assets/src/common/workstations.ts
new file mode 100644
index 0000000..7cbb24f
--- /dev/null
+++ b/modules/assets/src/common/workstations.ts
@@ -0,0 +1,23 @@
+import { z } from "zod/v4";
+
+import { WorkstationInputSchema } from "../api/domain/workstation-values";
+
+export type { WorkstationInput } from "../api/domain/workstation-values";
+
+export { WorkstationInputSchema };
+export const SaveWorkstationRequestSchema = z.strictObject({ values: WorkstationInputSchema });
+export const WorkstationRevisionSchema = z.strictObject({
+ id: z.uuid(),
+ values: WorkstationInputSchema,
+ actorId: z.uuid(),
+ actorName: z.string().min(1),
+ recordedAt: z.iso.datetime(),
+});
+export const WorkstationSchema = z.strictObject({
+ id: z.uuid(),
+ companyId: z.uuid(),
+ values: WorkstationInputSchema,
+ history: z.array(WorkstationRevisionSchema).min(1),
+});
+export const WorkstationResponseSchema = z.strictObject({ workstation: WorkstationSchema });
+export type WorkstationDTO = z.infer;
diff --git a/modules/assets/src/web/asset-detail.tsx b/modules/assets/src/web/asset-detail.tsx
index e18b975..53b12bf 100644
--- a/modules/assets/src/web/asset-detail.tsx
+++ b/modules/assets/src/web/asset-detail.tsx
@@ -1,18 +1,30 @@
+import { useQuery } from "@tanstack/react-query";
+import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import type { InventoryLookups } from "./asset-form";
+import { inventoryApi, inventoryKeys } from "./inventory-api";
export function AssetDetail({
asset,
lookups,
compact = false,
+ children,
}: {
asset: AssetDTO;
lookups: InventoryLookups;
compact?: boolean;
+ children?: ReactNode;
}) {
const { t, i18n } = useTranslation("assets");
+ const inventory = useQuery({
+ queryKey: inventoryKeys.company(asset.companyId),
+ queryFn: () => inventoryApi.get(asset.companyId),
+ });
+ const workstation = inventory.data?.workstations.find(
+ (item) => item.values.active && item.values.assetIds.includes(asset.id)
+ );
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
const display = (value: string | number | boolean | undefined) =>
@@ -41,6 +53,10 @@ export function AssetDetail({
{t("person")}
{nameOf(asset.values.personId, lookups.people)}
+
+
{t("workstationInAsset")}
+ {workstation?.values.name ?? t("noWorkstation")}
+
{asset.specification.fields.map((field) => (
@@ -56,6 +72,7 @@ export function AssetDetail({
{asset.values.notes && (
{asset.values.notes}
)}
+ {children}
{!compact && (
<>
diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx
index 634d311..6e89a2a 100644
--- a/modules/assets/src/web/asset-form.tsx
+++ b/modules/assets/src/web/asset-form.tsx
@@ -39,6 +39,7 @@ export function AssetForm({
items,
lookups,
onSaved,
+ allowQuickCreate = true,
}: {
companyId: string;
csrfToken: string;
@@ -46,6 +47,7 @@ export function AssetForm({
items: readonly CatalogItemDTO[];
lookups: InventoryLookups;
onSaved: (id: string) => void;
+ allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("assets");
const prefix = useId();
@@ -127,7 +129,7 @@ export function AssetForm({
{t("template")}
setCreatingTemplate(true) }
}
@@ -170,15 +172,19 @@ export function AssetForm({
{t("site")}
- lookups.onCreateSite((id) => {
- if (mounted.current) {
- form.setValue("siteId", id);
+ action={
+ allowQuickCreate
+ ? {
+ label: t("newSite"),
+ onClick: () =>
+ lookups.onCreateSite((id) => {
+ if (mounted.current) {
+ form.setValue("siteId", id);
+ }
+ }),
}
- }),
- }}
+ : undefined
+ }
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => form.setValue("siteId", id || null)}
@@ -190,15 +196,19 @@ export function AssetForm({
{t("person")}
- lookups.onCreatePerson((id) => {
- if (mounted.current) {
- form.setValue("personId", id);
+ action={
+ allowQuickCreate
+ ? {
+ label: t("newPerson"),
+ onClick: () =>
+ lookups.onCreatePerson((id) => {
+ if (mounted.current) {
+ form.setValue("personId", id);
+ }
+ }),
}
- }),
- }}
+ : undefined
+ }
id={`${prefix}-person`}
label={t("person")}
onValueChange={(id) => form.setValue("personId", id || null)}
diff --git a/modules/assets/src/web/asset-relationships.tsx b/modules/assets/src/web/asset-relationships.tsx
new file mode 100644
index 0000000..191d658
--- /dev/null
+++ b/modules/assets/src/web/asset-relationships.tsx
@@ -0,0 +1,268 @@
+import { Button } from "@repo/shadcn-ui/components/button";
+import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
+import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
+import { Label } from "@repo/shadcn-ui/components/label";
+import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
+import { Textarea } from "@repo/shadcn-ui/components/textarea";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useId, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { RelationshipDTO, RelationshipInputDTO } from "../common";
+
+import { InventoryFeedback } from "./asset-form";
+import { catalogApi, catalogKeys } from "./catalog-api";
+import { CatalogRelationForm } from "./catalog-relation-form";
+import { inventoryApi, inventoryKeys } from "./inventory-api";
+
+export function AssetRelationships({
+ companyId,
+ csrfToken,
+ assetId,
+}: {
+ companyId: string;
+ csrfToken: string;
+ assetId: string;
+}) {
+ const { t, i18n } = useTranslation("assets");
+ const client = useQueryClient();
+ const query = useQuery({
+ queryKey: inventoryKeys.company(companyId),
+ queryFn: () => inventoryApi.get(companyId),
+ });
+ const [adding, setAdding] = useState(false);
+ const [ending, setEnding] = useState();
+ const mutation = useMutation({
+ mutationFn: (id: string) => inventoryApi.endRelationship(companyId, id, csrfToken),
+ onSuccess: async () => {
+ await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
+ setEnding(undefined);
+ },
+ });
+ const links = (query.data?.relationships ?? []).filter(
+ (link) => link.values.sourceId === assetId || link.values.targetId === assetId
+ );
+ const active = links.filter((link) => !link.endedAt);
+ const ended = links.filter((link) => link.endedAt);
+ const nameOf = (id: string) =>
+ query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
+ const dateOf = (date: string) =>
+ new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
+ new Date(date)
+ );
+ const render = (link: RelationshipDTO) => {
+ const source = link.values.sourceId === assetId;
+ return (
+
+
+ {source ? link.values.name : link.values.inverseName} ·{" "}
+ {nameOf(source ? link.values.targetId : link.values.sourceId)}
+
+ {link.values.notes && (
+ {link.values.notes}
+ )}
+
+ {t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })}
+
+ {link.endedAt ? (
+
+ {t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })}
+
+ ) : (
+ {
+ mutation.reset();
+ setEnding(link);
+ }}
+ size="sm"
+ variant="outline"
+ >
+ {t("endRelationship")}
+
+ )}
+
+ );
+ };
+ return (
+
+
+ {t("relations")}
+ setAdding(true)} size="sm" variant="outline">
+ {t("linkAsset")}
+
+
+ {active.length ? (
+
+ ) : (
+ {t("noRelationships")}
+ )}
+ {!!ended.length && (
+
+
+ {t("endedRelationships")} ({ended.length})
+
+
+
+ )}
+ {adding && (
+
+ setAdding(false)}
+ />
+
+ )}
+ {ending && (
+ {
+ if (!open) {
+ setEnding(undefined);
+ }
+ }}
+ open
+ title={t("endRelationship")}
+ >
+
+ {ending.values.name} · {nameOf(ending.values.sourceId)} →{" "}
+ {nameOf(ending.values.targetId)}
+
+ {t("endRelationshipConfirm")}
+
+
+ mutation.mutate(ending.id)}>
+ {t("endRelationship")}
+
+
+
+ )}
+
+ );
+}
+function RelationshipForm({
+ companyId,
+ csrfToken,
+ assetId,
+ onSaved,
+}: {
+ companyId: string;
+ csrfToken: string;
+ assetId: string;
+ onSaved: () => void;
+}) {
+ const { t } = useTranslation("assets");
+ const id = useId();
+ const client = useQueryClient();
+ const [typeId, setTypeId] = useState("");
+ const [targetId, setTargetId] = useState("");
+ const [notes, setNotes] = useState("");
+ const [quick, setQuick] = useState(false);
+ const catalog = useQuery({
+ queryKey: catalogKeys.scope(companyId),
+ queryFn: () => catalogApi.get(companyId),
+ });
+ const inventory = useQuery({
+ queryKey: inventoryKeys.company(companyId),
+ queryFn: () => inventoryApi.get(companyId),
+ });
+ const mutation = useMutation({
+ mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken),
+ onSuccess: async () => {
+ await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
+ onSaved();
+ },
+ });
+ return (
+ <>
+
+ {quick && (
+
+ {t("quickRelationNotice")}
+ {
+ setTypeId(savedId);
+ setQuick(false);
+ }}
+ scope={companyId}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/modules/assets/src/web/catalog-definition-dialog.tsx b/modules/assets/src/web/catalog-definition-dialog.tsx
index 1f5d8ac..127e34f 100644
--- a/modules/assets/src/web/catalog-definition-dialog.tsx
+++ b/modules/assets/src/web/catalog-definition-dialog.tsx
@@ -53,6 +53,11 @@ export function CatalogDefinitionDialog({
)}
+ ) : values.kind === "relation" ? (
+
+ {t("inverseName")}: {values.inverseName} ·{" "}
+ {t(values.symmetric ? "symmetric" : "directional")}
+
) : (
{values.fields.map((binding) => {
diff --git a/modules/assets/src/web/catalog-help.tsx b/modules/assets/src/web/catalog-help.tsx
index b98ad45..3ade50f 100644
--- a/modules/assets/src/web/catalog-help.tsx
+++ b/modules/assets/src/web/catalog-help.tsx
@@ -1,6 +1,12 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
-export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) {
+export function CatalogHelp({
+ kind,
+ scope,
+}: {
+ kind: "field" | "template" | "relation";
+ scope: string;
+}) {
const { t } = useTranslation("assets");
return (
("template");
+ const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState();
const [editor, setEditor] = useState();
@@ -61,11 +66,11 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
setEditor({ kind: tab })}>
- {t(tab === "field" ? "newField" : "newTemplate")}
+ {t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
- {(["template", "field"] as const).map((kind) => (
+ {(["template", "field", "relation"] as const).map((kind) => (
- {t(kind === "field" ? "fields" : "templates")}
+ {t({ field: "fields", template: "templates", relation: "relations" }[kind])}
))}
@@ -109,7 +114,9 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
{t("name")}
{t("origin")}
- {t(tab === "field" ? "type" : "fields")}
+
+ {t({ field: "type", template: "fields", relation: "inverseName" }[tab])}
+
{t("active")}
{t("actions")}
@@ -156,6 +163,13 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onSaved={() => setEditor(undefined)}
scope={scope}
/>
+ ) : editor.kind === "relation" ? (
+ setEditor(undefined)}
+ scope={scope}
+ />
) : (
{values.kind === "field"
? t(values.type)
- : t("fieldCount", { count: values.fields.length })}
+ : values.kind === "relation"
+ ? values.inverseName
+ : t("fieldCount", { count: values.fields.length })}
{t(item.effectiveActive ? "active" : "inactive")}
@@ -245,6 +261,9 @@ function editorTitle(editor: Editor) {
if (editor.kind === "field") {
return editor.item ? "editField" : "newField";
}
+ if (editor.kind === "relation") {
+ return editor.item ? "editRelation" : "newRelation";
+ }
return editor.item ? "editTemplate" : "newTemplate";
}
diff --git a/modules/assets/src/web/catalog-relation-form.tsx b/modules/assets/src/web/catalog-relation-form.tsx
new file mode 100644
index 0000000..4406db3
--- /dev/null
+++ b/modules/assets/src/web/catalog-relation-form.tsx
@@ -0,0 +1,97 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Button } from "@repo/shadcn-ui/components/button";
+import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
+import { Input } from "@repo/shadcn-ui/components/input";
+import { Label } from "@repo/shadcn-ui/components/label";
+import { Textarea } from "@repo/shadcn-ui/components/textarea";
+import { useId } from "react";
+import { useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+import type { z } from "zod/v4";
+
+import { AssetDefinitionSchema, type CatalogItemDTO } from "../common";
+
+import { CatalogError, type CatalogSaved, useCatalogSave } from "./catalog-form";
+import { CatalogHelp } from "./catalog-help";
+
+const schema = AssetDefinitionSchema.options[2];
+export function CatalogRelationForm({
+ scope,
+ csrfToken,
+ item,
+ onSaved,
+}: {
+ scope: string;
+ csrfToken: string;
+ item?: CatalogItemDTO | undefined;
+ onSaved: CatalogSaved;
+}) {
+ const { t } = useTranslation("assets");
+ const id = useId();
+ const current = item?.values.kind === "relation" ? item.values : undefined;
+ const form = useForm>({
+ resolver: zodResolver(schema),
+ defaultValues: current ?? {
+ kind: "relation",
+ name: "",
+ inverseName: "",
+ description: "",
+ symmetric: false,
+ },
+ });
+ const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
+ const symmetric = form.watch("symmetric");
+ return (
+ mutation.mutate(values))}>
+
+ {t("name")}
+ {
+ form.setValue("name", event.target.value);
+ if (symmetric) {
+ form.setValue("inverseName", event.target.value);
+ }
+ }}
+ />
+
+
+ {
+ form.setValue("symmetric", event.target.checked);
+ if (event.target.checked) {
+ form.setValue("inverseName", form.getValues("name"));
+ }
+ }}
+ type="checkbox"
+ />
+ {t("symmetric")}
+
+ {!symmetric && (
+
+ {t("inverseName")}
+
+
+ )}
+
+ {t("description")}
+
+
+ 0}
+ />
+
+
+
+ {t(mutation.isPending ? "saving" : "save")}
+
+
+
+ );
+}
diff --git a/modules/assets/src/web/i18n.ts b/modules/assets/src/web/i18n.ts
index ec19bfe..f227390 100644
--- a/modules/assets/src/web/i18n.ts
+++ b/modules/assets/src/web/i18n.ts
@@ -1,10 +1,14 @@
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
+import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
+import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
export const assetsSpanish = {
...inventorySpanish,
+ ...workstationsSpanish,
+ ...relationshipsSpanish,
yes: "Sí",
no: "No",
title: "Catálogo de activos",
- companyTitle: "Plantillas de activos",
+ companyTitle: "Catálogo de activos",
introduction: "Define las características y plantillas que utilizarás al registrar activos.",
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
fields: "Campos",
@@ -84,6 +88,7 @@ export const assetsSpanish = {
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
errors: {
+ RELATION_DIRECTION_FIXED: "La dirección de una relación no se puede cambiar.",
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHOICES:
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
@@ -102,10 +107,12 @@ export const assetsSpanish = {
};
export const assetsEnglish = {
...inventoryEnglish,
+ ...workstationsEnglish,
+ ...relationshipsEnglish,
yes: "Yes",
no: "No",
title: "Asset catalog",
- companyTitle: "Asset templates",
+ companyTitle: "Asset catalog",
introduction: "Define the characteristics and templates used when registering assets.",
companyIntroduction: "Use global definitions and add those needed by this company.",
fields: "Fields",
@@ -183,6 +190,7 @@ export const assetsEnglish = {
"This template contains inactive fields. You can activate them in the catalog before using it.",
quickNotice: "The field remains saved if you later cancel the template.",
errors: {
+ RELATION_DIRECTION_FIXED: "Relationship direction cannot be changed.",
INVALID_REQUEST: "Check the form data.",
INVALID_CHOICES:
"A list needs unique non-empty values. Other data types do not accept list values.",
diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts
index da172f0..00f22ba 100644
--- a/modules/assets/src/web/index.ts
+++ b/modules/assets/src/web/index.ts
@@ -3,3 +3,4 @@ export { AssetCatalogPage } from "./catalog-page";
export { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryKeys } from "./inventory-api";
export { AssetInventoryPage } from "./inventory-page";
+export { WorkstationsPage } from "./workstations-page";
diff --git a/modules/assets/src/web/inventory-api.ts b/modules/assets/src/web/inventory-api.ts
index 885ab38..c0fecb3 100644
--- a/modules/assets/src/web/inventory-api.ts
+++ b/modules/assets/src/web/inventory-api.ts
@@ -2,6 +2,8 @@ import { z } from "zod/v4";
import type { AssetInput } from "../common/inventory";
import { AssetResponseSchema, InventoryResponseSchema } from "../common/inventory";
+import { type RelationshipInputDTO, RelationshipResponseSchema } from "../common/relationships";
+import { type WorkstationInput, WorkstationResponseSchema } from "../common/workstations";
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
@@ -26,6 +28,21 @@ async function request(path: string, body?: unknown, csrfToken?: string) {
return data;
}
export const inventoryApi = {
+ async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) {
+ return WorkstationResponseSchema.parse(
+ await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token)
+ );
+ },
+ async link(companyId: string, values: RelationshipInputDTO, token: string) {
+ return RelationshipResponseSchema.parse(
+ await request(`${companyId}/relationships`, { values }, token)
+ );
+ },
+ async endRelationship(companyId: string, id: string, token: string) {
+ return RelationshipResponseSchema.parse(
+ await request(`${companyId}/relationships/${id}/end`, {}, token)
+ );
+ },
async get(companyId: string) {
return InventoryResponseSchema.parse(await request(companyId));
},
diff --git a/modules/assets/src/web/inventory-i18n.ts b/modules/assets/src/web/inventory-i18n.ts
index 3cee41a..2f3bcbf 100644
--- a/modules/assets/src/web/inventory-i18n.ts
+++ b/modules/assets/src/web/inventory-i18n.ts
@@ -6,7 +6,7 @@ export const inventorySpanish = {
inventory: "Activos",
chooseValue: "Selecciona una opción",
relatedNotice:
- "El elemento se guarda por separado y se conserva aunque después canceles el alta del activo.",
+ "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
newAsset: "Nuevo activo",
editAsset: "Editar activo",
viewAsset: "Ficha e historial",
@@ -38,6 +38,14 @@ export const inventorySpanish = {
"El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
saveAssetError: "No se pudo guardar el activo.",
inventoryErrors: {
+ SELF_RELATIONSHIP: "Un activo no puede relacionarse consigo mismo.",
+ RELATIONSHIP_EXISTS: "Ya existe esa relación entre estos activos.",
+ RELATIONSHIP_NOT_FOUND: "La relación no existe en esta compañía.",
+ RELATION_TYPE_UNAVAILABLE: "El tipo de relación debe estar activo en esta compañía.",
+ INVALID_MEMBERS: "Selecciona activos distintos y válidos.",
+ WORKSTATION_NOT_FOUND: "El puesto no existe en esta compañía.",
+ ASSET_IN_WORKSTATION:
+ "Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.",
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
@@ -61,7 +69,7 @@ export const inventoryEnglish = {
inventory: "Assets",
chooseValue: "Choose an option",
relatedNotice:
- "This item is saved separately and remains available if you later cancel the asset.",
+ "This item is saved separately and remains available if you later cancel the main form.",
newAsset: "New asset",
editAsset: "Edit asset",
viewAsset: "Details and history",
@@ -93,6 +101,14 @@ export const inventoryEnglish = {
"The asset belongs to this company. Site and person are optional and must belong to it.",
saveAssetError: "The asset could not be saved.",
inventoryErrors: {
+ SELF_RELATIONSHIP: "An asset cannot link to itself.",
+ RELATIONSHIP_EXISTS: "This relationship already exists between these assets.",
+ RELATIONSHIP_NOT_FOUND: "Relationship not found in this company.",
+ RELATION_TYPE_UNAVAILABLE: "The relationship type must be active for this company.",
+ INVALID_MEMBERS: "Select distinct valid assets.",
+ WORKSTATION_NOT_FOUND: "Workstation not found in this company.",
+ ASSET_IN_WORKSTATION:
+ "An asset belongs to another active workstation. Remove it there before assigning it.",
INVALID_REQUEST: "Check the form data.",
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",
diff --git a/modules/assets/src/web/inventory-page.tsx b/modules/assets/src/web/inventory-page.tsx
index 8c08231..8d91807 100644
--- a/modules/assets/src/web/inventory-page.tsx
+++ b/modules/assets/src/web/inventory-page.tsx
@@ -11,6 +11,7 @@ import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form";
+import { AssetRelationships } from "./asset-relationships";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogError } from "./catalog-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
@@ -159,6 +160,8 @@ export function AssetInventoryPage({
setDetailOpen(false)}
onEdit={setEditor}
@@ -204,7 +207,9 @@ export function AssetInventoryPage({
title={viewed.values.name}
wide
>
-
+
+
+
)}
@@ -216,6 +221,8 @@ function selectedRowClass(id: string, selectedId: string | undefined, detailOpen
}
function AssetSelectionPanel({
+ companyId,
+ csrfToken,
asset,
open,
lookups,
@@ -224,6 +231,8 @@ function AssetSelectionPanel({
onView,
onEdit,
}: {
+ companyId: string;
+ csrfToken: string;
asset: AssetDTO | undefined;
open: boolean;
lookups: InventoryLookups;
@@ -244,7 +253,9 @@ function AssetSelectionPanel({
×
-
+
+
+
onView(asset.id)} variant="outline">
{t("viewAsset")}
diff --git a/modules/assets/src/web/inventory.test.tsx b/modules/assets/src/web/inventory.test.tsx
index 04a5403..a8c08c2 100644
--- a/modules/assets/src/web/inventory.test.tsx
+++ b/modules/assets/src/web/inventory.test.tsx
@@ -12,13 +12,18 @@ 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 { AssetForm } from "./asset-form";
+import { AssetRelationships } from "./asset-relationships";
import { catalogKeys } from "./catalog-api";
+import { CatalogRelationForm } from "./catalog-relation-form";
import { CatalogTemplateForm } from "./catalog-template-form";
import { assetsEnglish, assetsSpanish } from "./i18n";
import { inventoryKeys } from "./inventory-api";
import { AssetInventoryPage } from "./inventory-page";
+import { WorkstationForm } from "./workstation-form";
+import { WorkstationsPage } from "./workstations-page";
const companyId = UniqueID.generateNewID().toPrimitive();
const lookups = {
@@ -27,14 +32,18 @@ const lookups = {
onCreateSite: () => undefined,
onCreatePerson: () => undefined,
};
-async function render(language: string, node: ReactNode) {
+async function render(language: string, node: ReactNode, relationships: RelationshipDTO[] = []) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
});
const client = new QueryClient();
- client.setQueryData(inventoryKeys.company(companyId), { assets: [] });
+ client.setQueryData(inventoryKeys.company(companyId), {
+ assets: [],
+ relationships,
+ workstations: [],
+ });
client.setQueryData(
catalogKeys.scope(companyId),
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
@@ -94,3 +103,119 @@ test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una terc
);
assert.equal(child.includes('aria-label="Nuevo campo"'), false);
});
+
+test("Las fichas leen ambos sentidos y conservan la auditoría de relaciones retiradas", async () => {
+ const sourceId = UniqueID.generateNewID().toPrimitive();
+ const targetId = UniqueID.generateNewID().toPrimitive();
+ const link: RelationshipDTO = {
+ id: UniqueID.generateNewID().toPrimitive(),
+ companyId,
+ values: {
+ sourceId,
+ targetId,
+ typeId: UniqueID.generateNewID().toPrimitive(),
+ name: "Alojado en",
+ inverseName: "Aloja",
+ symmetric: false,
+ notes: "Rack 1",
+ },
+ createdBy: UniqueID.generateNewID().toPrimitive(),
+ createdByName: "Técnico",
+ createdAt: "2026-10-04T10:00:00.000Z",
+ endedBy: null,
+ endedByName: null,
+ endedAt: null,
+ };
+ const source = await render(
+ "es",
+ ,
+ [link]
+ );
+ const target = await render(
+ "es",
+ ,
+ [link]
+ );
+ assert.ok(source.includes("Alojado en"));
+ assert.ok(target.includes("Aloja"));
+ assert.ok(source.includes("Creada por Técnico"));
+ assert.ok(source.includes("Retirar relación"));
+ const ended = await render(
+ "es",
+ ,
+ [
+ {
+ ...link,
+ endedBy: link.createdBy,
+ endedByName: "Técnico",
+ endedAt: "2026-10-04T11:00:00.000Z",
+ },
+ ]
+ );
+ assert.ok(ended.includes("Relaciones retiradas"));
+ assert.ok(ended.includes("Retirada por Técnico"));
+ assert.ok(!ended.includes(">Retirar relación<"));
+});
+test("El formulario de tipo de relación presenta ayuda justo antes de sus operaciones", async () => {
+ for (const language of ["es", "en"]) {
+ const html = await render(
+ language,
+ undefined} scope={companyId} />
+ );
+ assert.ok(html.indexOf('name="description"') < html.indexOf(" {
+ for (const language of ["es", "en"]) {
+ const html = await render(
+ language,
+
+ );
+ assert.ok(html.includes(language === "es" ? "Nuevo puesto" : "New workstation"));
+ assert.ok(
+ html.includes(
+ language === "es"
+ ? "Esta compañía todavía no tiene puestos de trabajo."
+ : "This company has no workstations yet."
+ )
+ );
+ assert.equal(html.includes(" {
+ const html = await render(
+ "es",
+ undefined}
+ />
+ );
+ for (const label of ["Nueva sede", "Nueva persona", "Nuevo activo"]) {
+ assert.ok(html.includes(`aria-label="${label}"`));
+ }
+ assert.ok(html.indexOf('name="active"') < html.indexOf(" undefined}
+ />
+ );
+ for (const label of ["Nueva sede", "Nueva persona", "Nueva plantilla"]) {
+ assert.ok(!nested.includes(`aria-label="${label}"`));
+ }
+});
diff --git a/modules/assets/src/web/relationship-i18n.ts b/modules/assets/src/web/relationship-i18n.ts
new file mode 100644
index 0000000..5f2290f
--- /dev/null
+++ b/modules/assets/src/web/relationship-i18n.ts
@@ -0,0 +1,64 @@
+export const relationshipsSpanish = {
+ relations: "Relaciones",
+ newRelation: "Nuevo tipo de relación",
+ editRelation: "Editar tipo de relación",
+ inverseName: "Nombre visto desde el otro activo",
+ symmetric: "Mismo nombre en ambos sentidos",
+ directional: "Relación con dirección",
+ relationDescription:
+ "Un tipo de relación describe cómo se vinculan dos activos de una misma compañía.",
+ relationExamples:
+ "Portátil → Conectado a → Monitor. Máquina virtual → Alojado en → Servidor; desde el servidor se lee «Aloja».",
+ relationGuidance:
+ "Puedes desactivar tipos sin perder los vínculos existentes. La dirección queda fija tras el alta. Las relaciones conservan los nombres que tenían al crearse.",
+ linkAsset: "Relacionar activo",
+ relationshipType: "Tipo de relación",
+ targetAsset: "Otro activo",
+ chooseRelation: "Selecciona una relación",
+ chooseAsset: "Selecciona un activo",
+ noRelationships: "Este activo no tiene relaciones vigentes.",
+ endedRelationships: "Relaciones retiradas",
+ endRelationship: "Retirar relación",
+ endRelationshipConfirm: "Se conservará el historial de esta relación.",
+ relationshipHelp:
+ "Vincula equipos que trabajan juntos o dependen de otros. Solo se muestran activos de esta compañía.",
+ relationshipExample: "Por ejemplo: este portátil está conectado a un monitor externo.",
+ relationshipGuidance:
+ "Selecciona primero el tipo y después el otro activo. Retirar una relación no elimina los activos.",
+ quickRelationNotice:
+ "El tipo se guardará para esta compañía aunque después canceles la relación.",
+ relationCreated: "Creada por {{name}} · {{date}}",
+ relationEnded: "Retirada por {{name}} · {{date}}",
+};
+export const relationshipsEnglish = {
+ relations: "Relationships",
+ newRelation: "New relationship type",
+ editRelation: "Edit relationship type",
+ inverseName: "Name shown from the other asset",
+ symmetric: "Same name in both directions",
+ directional: "Directional relationship",
+ relationDescription:
+ "A relationship type describes how two assets in the same company are linked.",
+ relationExamples:
+ "Laptop → Connected to → Monitor. Virtual machine → Hosted on → Server; from the server it reads «Hosts».",
+ relationGuidance:
+ "Disable types without losing existing links. Direction is fixed after creation. Relationships retain the names they had when created.",
+ linkAsset: "Link asset",
+ relationshipType: "Relationship type",
+ targetAsset: "Other asset",
+ chooseRelation: "Select a relationship",
+ chooseAsset: "Select an asset",
+ noRelationships: "This asset has no current relationships.",
+ endedRelationships: "Ended relationships",
+ endRelationship: "End relationship",
+ endRelationshipConfirm: "The relationship history will be retained.",
+ relationshipHelp:
+ "Link devices that work together or depend on others. Only assets in this company are shown.",
+ relationshipExample: "For example: this laptop is connected to an external monitor.",
+ relationshipGuidance:
+ "Select the type and then the other asset. Ending a relationship does not delete assets.",
+ quickRelationNotice:
+ "The type is saved for this company even if you later cancel the relationship.",
+ relationCreated: "Created by {{name}} · {{date}}",
+ relationEnded: "Ended by {{name}} · {{date}}",
+};
diff --git a/modules/assets/src/web/workstation-form.tsx b/modules/assets/src/web/workstation-form.tsx
new file mode 100644
index 0000000..9a6e442
--- /dev/null
+++ b/modules/assets/src/web/workstation-form.tsx
@@ -0,0 +1,254 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Button } from "@repo/shadcn-ui/components/button";
+import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
+import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
+import { Input } from "@repo/shadcn-ui/components/input";
+import { Label } from "@repo/shadcn-ui/components/label";
+import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useId, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+import { type WorkstationDTO, type WorkstationInput, WorkstationInputSchema } from "../common";
+
+import { AssetForm, InventoryFeedback, type InventoryLookups } from "./asset-form";
+import { catalogApi, catalogKeys } from "./catalog-api";
+import { inventoryApi, inventoryKeys } from "./inventory-api";
+export function WorkstationForm({
+ companyId,
+ csrfToken,
+ lookups,
+ workstation,
+ onSaved,
+}: {
+ companyId: string;
+ csrfToken: string;
+ lookups: InventoryLookups;
+ workstation?: WorkstationDTO | undefined;
+ onSaved: (id: string) => void;
+}) {
+ const { t } = useTranslation("assets");
+ const prefix = useId();
+ const client = useQueryClient();
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+ const form = useForm({
+ resolver: zodResolver(WorkstationInputSchema),
+ defaultValues: workstation?.values ?? {
+ name: "",
+ active: true,
+ siteId: null,
+ personId: null,
+ assetIds: [],
+ },
+ });
+ const values = form.watch();
+ const [quick, setQuick] = useState(false);
+ const inventory = useQuery({
+ queryKey: inventoryKeys.company(companyId),
+ queryFn: () => inventoryApi.get(companyId),
+ });
+ const catalog = useQuery({
+ queryKey: catalogKeys.scope(companyId),
+ queryFn: () => catalogApi.get(companyId),
+ });
+ const assets = inventory.data?.assets ?? [];
+ const stations = inventory.data?.workstations ?? [];
+ const occupied = new Set(
+ stations
+ .filter((item) => item.values.active && item.id !== workstation?.id)
+ .flatMap((item) => item.values.assetIds)
+ );
+ const add = (id: string) => {
+ if (id && mounted.current && !form.getValues("assetIds").includes(id)) {
+ form.setValue("assetIds", [...form.getValues("assetIds"), id]);
+ }
+ };
+ const mutation = useMutation({
+ mutationFn: (input: WorkstationInput) =>
+ inventoryApi.saveWorkstation(companyId, input, csrfToken, workstation?.id),
+ onSuccess: async (response) => {
+ await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
+ if (mounted.current) {
+ onSaved(response.workstation.id);
+ }
+ },
+ });
+ return (
+ <>
+ mutation.mutate(input))}>
+
+
+ {t("name")}
+
+
+
+
+ {t("active")}
+
+
+
+ {t("site")}
+
+ lookups.onCreateSite((id) => {
+ if (mounted.current) {
+ form.setValue("siteId", id);
+ }
+ }),
+ }}
+ id={`${prefix}-site`}
+ label={t("site")}
+ onValueChange={(id) => form.setValue("siteId", id || null)}
+ options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
+ placeholder={t("noAssignment")}
+ value={values.siteId ?? ""}
+ />
+
+
+ {t("workstationPerson")}
+
+ lookups.onCreatePerson((id) => {
+ if (mounted.current) {
+ form.setValue("personId", id);
+ }
+ }),
+ }}
+ id={`${prefix}-person`}
+ label={t("workstationPerson")}
+ onValueChange={(id) => form.setValue("personId", id || null)}
+ options={lookups.people
+ .filter((item) => item.active || item.id === workstation?.values.personId)
+ .map((item) => ({ value: item.id, label: item.name }))}
+ placeholder={t("noAssignment")}
+ value={values.personId ?? ""}
+ />
+
+
+
+ {t("addWorkstationAsset")}
+ setQuick(true),
+ disabled: !catalog.data,
+ }}
+ disabled={inventory.isPending || values.assetIds.length >= 200}
+ id={`${prefix}-assets`}
+ label={t("addWorkstationAsset")}
+ onValueChange={add}
+ options={assets
+ .filter((item) => !values.assetIds.includes(item.id))
+ .map((item) => ({
+ value: item.id,
+ label: occupied.has(item.id)
+ ? t("workstationOccupied", { name: item.values.name })
+ : item.values.name,
+ disabled: values.active && occupied.has(item.id),
+ }))}
+ placeholder={t("chooseAsset")}
+ value=""
+ />
+
+ {values.assetIds.length ? (
+
+ {values.assetIds.map((id) => (
+
+
+
{assets.find((item) => item.id === id)?.values.name ?? id}
+ {values.active && occupied.has(id) && (
+
+ {t("inventoryErrors.ASSET_IN_WORKSTATION")}
+
+ )}
+
+ item.id === id)?.values.name ?? id,
+ })}
+ onClick={() =>
+ form.setValue(
+ "assetIds",
+ values.assetIds.filter((item) => item !== id)
+ )
+ }
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ ×
+
+
+ ))}
+
+ ) : (
+ {t("noWorkstationAssets")}
+ )}
+
+ {Object.keys(form.formState.errors).length > 0 && (
+
+ {t("inventoryErrors.INVALID_REQUEST")}
+
+ )}
+
+
+
+ occupied.has(id)))
+ }
+ type="submit"
+ >
+ {t(mutation.isPending ? "saving" : "save")}
+
+
+
+ {quick && catalog.data && (
+
+ {t("relatedNotice")}
+ {
+ add(id);
+ setQuick(false);
+ }}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/modules/assets/src/web/workstation-i18n.ts b/modules/assets/src/web/workstation-i18n.ts
new file mode 100644
index 0000000..a341c4f
--- /dev/null
+++ b/modules/assets/src/web/workstation-i18n.ts
@@ -0,0 +1,52 @@
+export const workstationsSpanish = {
+ workstations: "Puestos de trabajo",
+ newWorkstation: "Nuevo puesto",
+ editWorkstation: "Editar puesto",
+ workstationPerson: "Persona del puesto",
+ workstationHistory: "Historial del puesto",
+ workstationAssets: "Activos del puesto",
+ noWorkstations: "Esta compañía todavía no tiene puestos de trabajo.",
+ noWorkstationAssets: "Este puesto todavía no tiene activos.",
+ addWorkstationAsset: "Añadir un activo",
+ workstationOccupied: "{{name}} · ocupado en otro puesto",
+ removeWorkstationAsset: "Retirar {{name}} del puesto",
+ workstationCount: "{{count}} activos",
+ workstationCount_one: "{{count}} activo",
+ workstationCount_other: "{{count}} activos",
+ workstationDescription:
+ "Un puesto reúne los activos que una persona utiliza habitualmente. Puede quedar sin persona o sede y puede crearse sin activos.",
+ workstationExamples:
+ "Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
+ workstationGuidance:
+ "Cada activo solo puede estar en un puesto activo. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
+ workstationScope:
+ "Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
+ workstationInAsset: "Puesto de trabajo",
+ noWorkstation: "Sin puesto activo",
+};
+export const workstationsEnglish = {
+ workstations: "Workstations",
+ newWorkstation: "New workstation",
+ editWorkstation: "Edit workstation",
+ workstationPerson: "Workstation assignee",
+ workstationHistory: "Workstation history",
+ workstationAssets: "Workstation assets",
+ noWorkstations: "This company has no workstations yet.",
+ noWorkstationAssets: "This workstation has no assets yet.",
+ addWorkstationAsset: "Add an asset",
+ workstationOccupied: "{{name}} · used in another workstation",
+ removeWorkstationAsset: "Remove {{name}} from workstation",
+ workstationCount: "{{count}} assets",
+ workstationCount_one: "{{count}} asset",
+ workstationCount_other: "{{count}} assets",
+ workstationDescription:
+ "A workstation groups assets a person uses regularly. Person and site are optional and it can be created without assets.",
+ workstationExamples:
+ "Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
+ workstationGuidance:
+ "Each asset can belong to one active workstation. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
+ workstationScope:
+ "All elements must belong to this company. Person, site and composition changes retain history.",
+ workstationInAsset: "Workstation",
+ noWorkstation: "No active workstation",
+};
diff --git a/modules/assets/src/web/workstations-page.tsx b/modules/assets/src/web/workstations-page.tsx
new file mode 100644
index 0000000..d7b5c0e
--- /dev/null
+++ b/modules/assets/src/web/workstations-page.tsx
@@ -0,0 +1,167 @@
+import { Button } from "@repo/shadcn-ui/components/button";
+import { Card, CardContent } from "@repo/shadcn-ui/components/card";
+import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
+import { Input } from "@repo/shadcn-ui/components/input";
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { WorkstationDTO } from "../common";
+
+import { InventoryFeedback, type InventoryLookups } from "./asset-form";
+import { inventoryApi, inventoryKeys } from "./inventory-api";
+import { WorkstationForm } from "./workstation-form";
+export function WorkstationsPage({
+ companyId,
+ csrfToken,
+ lookups,
+}: {
+ companyId: string;
+ csrfToken: string;
+ lookups: InventoryLookups;
+}) {
+ const { t, i18n } = useTranslation("assets");
+ const [search, setSearch] = useState("");
+ const [editor, setEditor] = useState();
+ const [view, setView] = useState();
+ const query = useQuery({
+ queryKey: inventoryKeys.company(companyId),
+ queryFn: () => inventoryApi.get(companyId),
+ });
+ const stations = query.data?.workstations ?? [];
+ const assets = query.data?.assets ?? [];
+ const edited = stations.find((item) => item.id === editor);
+ const viewed = stations.find((item) => item.id === view);
+ const personName = (id: string | null) =>
+ lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment");
+ const siteName = (id: string | null) =>
+ lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment");
+ const members = (ids: readonly string[]) =>
+ ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") ||
+ t("noWorkstationAssets");
+ const filtered = stations.filter((item) =>
+ [item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some(
+ (value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
+ )
+ );
+ const summary = (item: WorkstationDTO) => (
+ <>
+
+ {item.values.name} · {t(item.values.active ? "active" : "inactive")}
+
+
+ {siteName(item.values.siteId)} · {personName(item.values.personId)} ·{" "}
+ {t("workstationCount", { count: item.values.assetIds.length })}
+
+ {members(item.values.assetIds)}
+ >
+ );
+ return (
+
+
+ {t("workstations")}
+ setEditor("new")}>
+ {t("newWorkstation")}
+
+
+
setSearch(event.target.value)}
+ placeholder={t("search")}
+ value={search}
+ />
+ {query.isPending &&
{t("loading")}
}
+
+ {query.isError && (
+
{
+ query.refetch().catch(() => undefined);
+ }}
+ variant="outline"
+ >
+ {t("retry")}
+
+ )}
+ {query.data && (
+
+
+ {!filtered.length && (
+
+ {t(stations.length ? "noResults" : "noWorkstations")}
+
+ )}
+ {filtered.map((item) => (
+
+
{summary(item)}
+
+ setView(item.id)} size="sm" variant="outline">
+ {t("workstationHistory")}
+
+ setEditor(item.id)} size="sm" variant="outline">
+ {t("edit")}
+
+
+
+ ))}
+
+
+ )}
+ {editor && (
+
{
+ if (!open) {
+ setEditor(undefined);
+ }
+ }}
+ open
+ title={t(edited ? "editWorkstation" : "newWorkstation")}
+ wide
+ >
+ setEditor(undefined)}
+ workstation={edited}
+ />
+
+ )}
+ {viewed && (
+
{
+ if (!open) {
+ setView(undefined);
+ }
+ }}
+ open
+ title={t("workstationHistory")}
+ wide
+ >
+
+ {[...viewed.history].reverse().map((revision) => (
+
+
+ {new Intl.DateTimeFormat(i18n.language, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(revision.recordedAt))}{" "}
+ · {t("recordedBy", { name: revision.actorName })}
+
+
+ {revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
+
+
+ {siteName(revision.values.siteId)} · {personName(revision.values.personId)}
+
+ {members(revision.values.assetIds)}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx
index 01bcb74..12ecdbb 100644
--- a/modules/companies/src/web/components/company-detail.tsx
+++ b/modules/companies/src/web/components/company-detail.tsx
@@ -99,7 +99,14 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
);
}
-type CompanyTab = "people" | "departments" | "sites" | "access" | "assets" | "inventory";
+type CompanyTab =
+ | "people"
+ | "departments"
+ | "sites"
+ | "access"
+ | "assets"
+ | "inventory"
+ | "workstations";
export function CompanyDetail({
id,
@@ -120,7 +127,7 @@ export function CompanyDetail({
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<
- "people" | "departments" | "sites" | "access" | "assets" | "inventory"
+ "people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations"
>(embedded ? "inventory" : "people");
const company = query.data?.company;
const layout = embedded
@@ -189,6 +196,7 @@ export function CompanyDetail({
{(
[
["inventory", assetTranslation("inventory"), BoxIcon],
+ ["workstations", assetTranslation("workstations"), BriefcaseIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
"departments",
@@ -242,6 +250,8 @@ function CompanySection({
return (
);
+ case "workstations":
+ return ;
case "inventory":
return ;
case "assets":
diff --git a/modules/companies/src/web/components/company-inventory.tsx b/modules/companies/src/web/components/company-inventory.tsx
index 04ddf14..71a53c5 100644
--- a/modules/companies/src/web/components/company-inventory.tsx
+++ b/modules/companies/src/web/components/company-inventory.tsx
@@ -1,4 +1,4 @@
-import { AssetInventoryPage } from "@metis/assets/client";
+import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -10,9 +10,11 @@ import { SiteForm } from "./site-form";
export function CompanyInventory({
company,
csrfToken,
+ workstations = false,
}: {
company: CompanyDTO;
csrfToken: string;
+ workstations?: boolean;
}) {
const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
@@ -20,9 +22,10 @@ export function CompanyInventory({
kind: "site" | "person";
onCreated: (id: string) => void;
}>();
+ const Page = workstations ? WorkstationsPage : AssetInventoryPage;
return (
<>
-