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