vincular una licencia al software del ordenador.

This commit is contained in:
David Arranz 2026-10-13 09:30:28 +02:00
parent 3ceac47cd6
commit 7acf31f144
42 changed files with 645 additions and 44 deletions

View File

@ -93,6 +93,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM printer_configurations");
await database.query("DELETE FROM application_configuration_revisions");
await database.query("DELETE FROM application_configurations");
await database.query("DELETE FROM software_installation_revisions");
await database.query("DELETE FROM software_installations");
await database.query("DELETE FROM certificate_configuration_revisions");
await database.query("DELETE FROM certificate_configurations");
await database.query("DELETE FROM person_process_revisions");

View File

@ -1,12 +1,14 @@
import assert from "node:assert/strict";
import { InventoryResponseSchema } from "@metis/assets/common";
import type { SoftwareInstallationInput } from "@metis/companies/common";
import {
SoftwareInstallationResponseSchema,
SoftwareInstallationsResponseSchema,
} from "@metis/companies/common";
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { migrateSoftwareInstallations } from "../../../modules/companies/src/api/infrastructure/persistence/software-installation-migration";
@ -31,10 +33,16 @@ export async function verifySoftwareInstallations(
);
const template = catalog.items.find((item) => item.builtinKey === "template:os");
const application = catalog.items.find((item) => item.builtinKey === "template:application");
const licenseTemplate = catalog.items.find(
(item) => item.builtinKey === "template:software_license"
);
const certificate = catalog.items.find((item) => item.builtinKey === "template:certificate");
assert.ok(host && otherHost && template && application && certificate);
assert.ok(host && otherHost && template && application && certificate && licenseTemplate);
assert.ok(template.values.kind === "template" && template.values.usage === "os");
assert.ok(
licenseTemplate.values.kind === "template" && licenseTemplate.values.usage === "license"
);
const addResource = async (templateId: string, name: string) => {
const response = await request(`/resources/${companyId}`, {
@ -46,13 +54,15 @@ export async function verifySoftwareInstallations(
return ResourceResponseSchema.parse(await response.json()).resource;
};
const license = await addResource(licenseTemplate.id, "Licencia equipo test");
const os = await addResource(template.id, "Windows 11 Pro test");
const app = await addResource(application.id, "Office equipo test");
const cert = await addResource(certificate.id, "Certificado no instalable como software");
const path = `/software-installations/${companyId}`;
const values = {
const values: SoftwareInstallationInput = {
assetId: host.id,
resourceId: os.id,
licenseResourceId: null,
version: "24H2",
state: "configured",
notes: null,
@ -61,6 +71,27 @@ export async function verifySoftwareInstallations(
assert.equal(foreign.status, 404);
assert.equal((await request(path, { values: { ...values, resourceId: cert.id } })).status, 404);
assert.equal(
(await request(path, { values: { ...values, licenseResourceId: cert.id } })).status,
404
);
const foreignLicenseResponse = await request(`/resources/${otherId}`, {
values: {
name: "Licencia ajena",
templateId: licenseTemplate.id,
active: true,
notes: "",
characteristics: {},
},
});
assert.equal(foreignLicenseResponse.status, 201);
const foreignLicense = ResourceResponseSchema.parse(await foreignLicenseResponse.json()).resource;
assert.equal(
(await request(path, { values: { ...values, licenseResourceId: foreignLicense.id } })).status,
404
);
assert.equal((await request(path, { values: { ...values, profileId: host.id } })).status, 400);
assert.equal((await request(path, { values }, "https://other.example")).status, 403);
assert.equal((await request(path, { values }, undefined, "a".repeat(64))).status, 403);
@ -86,6 +117,40 @@ export async function verifySoftwareInstallations(
assert.equal(updated.history.length, 2);
assert.equal(updated.history[0]?.values.version, "24H2");
assert.equal(updated.history[1]?.values.version, "25H2");
const linkedValues = { ...values, version: "25H2", licenseResourceId: license.id };
const linked = await save(linkedValues);
assert.equal(linked.history.length, 3);
assert.equal(linked.history[0]?.values.licenseResourceId, null);
assert.equal(linked.history[2]?.values.licenseResourceId, license.id);
assert.deepEqual(await save(linkedValues), linked);
const deactivateLicense = await request(`/resources/${companyId}/${license.id}/update`, {
values: { ...license.values, active: false },
});
assert.equal(deactivateLicense.status, 200);
const licenseReview = SoftwareInstallationsResponseSchema.parse(
await (await request(path)).json()
);
assert.equal(
licenseReview.configurations.find((item) => item.id === created.id)?.needsReview,
true
);
assert.equal(
(
await request(`${path}/${created.id}/update`, {
values: { ...linkedValues, version: "26H2" },
})
).status,
409
);
await save({ ...linkedValues, notes: "Revisar licencia" });
const detached = await save({ ...values, version: "25H2" });
assert.equal(detached.needsReview, false);
assert.equal(detached.values.licenseResourceId, null);
assert.equal(detached.history[2]?.values.licenseResourceId, license.id);
assert.equal((await request(path, { values: { ...values, resourceId: app.id } })).status, 201);
assert.equal(
(await request(`/software-installations/${otherId}/${created.id}/update`, { values })).status,
@ -97,6 +162,36 @@ export async function verifySoftwareInstallations(
).configurations.length,
0
);
const expiryField = catalog.items.find((item) => item.builtinKey === "field:expires");
assert.ok(expiryField);
const expiredResponse = await request(`/resources/${companyId}`, {
values: {
name: "Licencia caducada",
templateId: licenseTemplate.id,
active: true,
notes: "",
characteristics: { [expiryField.id]: "2000-01-01" },
},
});
assert.equal(expiredResponse.status, 201);
const expired = ResourceResponseSchema.parse(await expiredResponse.json()).resource;
const rejectedExpiry = await request(`${path}/${created.id}/update`, {
values: { ...values, version: "25H2", licenseResourceId: expired.id },
});
assert.equal(rejectedExpiry.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await rejectedExpiry.json()).code,
"SOFTWARE_REFERENCE_UNAVAILABLE"
);
await migrateSoftwareInstallations(database);
const persisted = SoftwareInstallationsResponseSchema.parse(
await (await request(path)).json()
).configurations.find((item) => item.id === created.id);
assert.deepEqual(persisted, detached);
const deactivate = await request(`/resources/${companyId}/${os.id}/update`, {
values: { ...os.values, active: false },
});

View File

@ -87,7 +87,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie
1. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
Este orden se concreta antes de cada entrega. El registro de licencias como recursos está implementado; sus asignaciones y relaciones con cuentas, personas, equipos y aplicaciones quedan pendientes.
Este orden se concreta antes de cada entrega. El registro de licencias como recursos y el vínculo informativo con instalaciones del ordenador están implementados. Las asignaciones por cuenta/persona y el cálculo de capacidad quedan pendientes. Véase [Software del ordenador](software-del-ordenador.md).
## Inventario: implementado
@ -105,7 +105,7 @@ Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo reali
## Licencias de software: registro implementado
Plantilla global protegida, siete campos nuevos y cuatro reutilizados del catálogo de recursos. Registro por compañía, características opcionales, modalidades, criterio de licenciamiento, cantidad contratada, caducidad, renovación e historial. Modales con ayuda específica. Pendientes las asignaciones, capacidad disponible, vínculos con aplicaciones y avisos. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
Plantilla global protegida, siete campos nuevos y cuatro reutilizados del catálogo de recursos. Registro por compañía, características opcionales, modalidades, criterio de licenciamiento, cantidad contratada, caducidad, renovación e historial. Modales con ayuda específica. Implementado el vínculo opcional con las instalaciones de aplicaciones y sistemas operativos del ordenador, con historial y revisión por inactividad/caducidad. Pendientes las asignaciones por cuenta/persona y la capacidad disponible. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
## Servicios y documentación: pendiente

View File

@ -29,3 +29,18 @@ Migración explícita e idempotente al inicializar el módulo. Las escrituras se
## Verificación
Pruebas de dominio y persistencia: referencias, SO/aplicaciones, duplicados, ámbito, identidad fija, idempotencia, versiones, revisión, transiciones e historial corrupto. Pruebas HTTP con MariaDB: migración repetida, concurrencia, CSRF/origen, compañía ajena, versiones históricas, recursos inactivos, retirada y eliminación. Pruebas de interfaz es/en: filtrado por ordenador, enlaces, versiones, ausencia de formularios incrustados y ayuda.
## Licencia vinculada
Cada instalación admite una referencia opcional a un recurso de uso **Licencia de software** de la misma compañía. Se elige en el formulario y se muestra como enlace en el listado y en cada revisión del historial. Puede añadirse, sustituirse o quitarse mientras la instalación no esté cerrada. La pareja ordenador/software sigue fija. Repetir los mismos valores no añade historial.
Este vínculo documenta la licencia indicada por el técnico. No reserva plazas, valida el contrato del fabricante, demuestra cobertura ni deduce que dos productos con nombres parecidos sean compatibles. El software gratuito o una instalación cuya licencia aún no se conoce pueden quedar sin vínculo. Las asignaciones por persona/cuenta y el cálculo de capacidad disponible siguen pendientes.
Una licencia nueva o un cambio de configuración requieren licencia activa y no caducada. Una instalación abierta con licencia desactivada o caducada aparece para revisión; se pueden completar notas, desvincular la licencia y registrar la retirada. No se elimina ni retira software automáticamente. La caducidad es inclusiva y se consulta mediante el campo de serie de vigencia, usando la fecha UTC igual que el resto del backend.
El uso `license` permite reconocer también plantillas propias y copias de la plantilla de serie, sin depender del nombre. Una migración idempotente clasifica la plantilla de serie existente; no reclasifica copias anteriores sin uso explícito. El tipo de uso se fija al crear la plantilla, igual que en aplicaciones o certificados.
`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.

View File

@ -25,6 +25,7 @@ export interface IAssetRepository {
company: UniqueID,
id: UniqueID
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
removeWorkstation(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation(
companyId: UniqueID,

View File

@ -11,6 +11,35 @@ import { mapWorkstationInput } from "./workstation-input.mapper";
export class WorkstationUseCases {
constructor(private readonly repository: IAssetRepository) {}
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.removeWorkstation(company.data, identity.data);
}
decommission(companyId: string, id: string, actorId: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
const actor = UniqueID.create(actorId);
if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.saveWorkstation(company.data, (context) => {
const workstation = context.workstations.find((item) => item.id.equals(identity.data));
return workstation
? workstation.decommission(actor.data, new Date().toISOString())
: Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
});
}
save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { AssetCatalogError } from "./asset-definition";
import type { WorkstationValues } from "./value-objects/workstation-values.vo";
import { WorkstationValues } from "./value-objects/workstation-values.vo";
import { WorkstationRevision } from "./workstation-revision.entity";
type WorkstationProps = Readonly<{
@ -53,6 +53,19 @@ export class Workstation extends AggregateRoot<WorkstationProps> {
return this.props.history;
}
decommission(actorId: UniqueID, recordedAt: string): Result<Workstation, AssetCatalogError> {
const values = WorkstationValues.create({
...this.values.toPrimitive(),
active: false,
computerAssetId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
});
return values.isSuccess ? this.update(values.data, actorId, recordedAt) : values;
}
update(
values: WorkstationValues,
actorId: UniqueID,

View File

@ -60,5 +60,37 @@ export function registerWorkstationRoutes(
.catch(next);
});
}
for (const action of ["decommission", "delete"] as const) {
router.post(`/:companyId/workstations/:id/${action}`, (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params);
const actor = z.object({ userId: z.uuid() }).safeParse("session" in request ? request.session : undefined);
if (!(params.success && actor.success && z.strictObject({}).safeParse(request.body).success && z.strictObject({}).safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
if (action === "delete") {
useCases.remove(params.data.companyId, params.data.id).then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
}).catch(next);
return;
}
useCases.decommission(params.data.companyId, params.data.id, actor.data.userId).then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames) });
}).catch(next);
});
}
app.use("/api/assets", authenticate, router);
}

View File

@ -172,6 +172,45 @@ export class SequelizeAssetRepository implements IAssetRepository {
});
}
removeWorkstation(companyId: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
if (!loaded.data.workstations.some((item) => item.id.equals(id))) {
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
}
const references = await this.database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='workstations' AND REFERENCED_COLUMN_NAME='id'",
{ type: QueryTypes.SELECT, transaction }
);
const owned = new Set(["workstation_assets", "workstation_members", "workstation_revisions"]);
const replacements = { id: id.toPrimitive(), companyId: companyId.toPrimitive() };
for (const reference of references) {
if (owned.has(reference.tableName)) {
continue;
}
const rows = await this.database.query<{ value: number }>(
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`,
{ replacements, transaction, type: QueryTypes.SELECT }
);
if (rows.length) {
return Result.fail(new AssetCatalogError("WORKSTATION_IN_USE"));
}
}
// Las asociaciones de composición se eliminan; sus activos no se borran.
await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", { replacements, transaction });
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", { replacements, transaction });
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", { replacements, transaction });
return Result.ok(undefined);
});
}
private async persistWorkstation(
item: Workstation,
previous: Workstation | undefined,

View File

@ -361,5 +361,20 @@
"missingAsset": "Asset unavailable."
},
"workstationComputer": "Main computer",
"workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers."
"workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers.",
"workstationLifecycle": {
"decommission": {
"title": "Decommission",
"description": "Decommission “{{name}}” and unlink its responsible person, computer, exclusive and shared assets. History is retained. Assets, profiles and computer software retain their records.",
"pending": "Decommissioning…",
"error": "Could not decommission the workstation. Please try again."
},
"delete": {
"title": "Delete permanently",
"description": "Delete “{{name}}”, its history and its associations. Assets, people, profiles and software are retained. This cannot be undone. If tickets or interventions refer to it, use Decommission.",
"pending": "Deleting…",
"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."
}
}

View File

@ -361,5 +361,20 @@
"missingAsset": "Activo no disponible."
},
"workstationComputer": "Ordenador principal",
"workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos."
"workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos.",
"workstationLifecycle": {
"decommission": {
"title": "Dar de baja",
"description": "Se dará de baja «{{name}}» y se desvincularán su responsable, ordenador y activos exclusivos y compartidos. Se conservará el historial. Los activos, perfiles y software del ordenador conservarán sus registros.",
"pending": "Dando de baja…",
"error": "No se pudo dar de baja el puesto. Inténtalo de nuevo."
},
"delete": {
"title": "Eliminar definitivamente",
"description": "Se eliminarán «{{name}}», su historial y sus asociaciones. Los activos, personas, perfiles y software se conservarán. Esta acción no se puede deshacer. Si hay tickets o intervenciones vinculados, utiliza Dar de baja.",
"pending": "Eliminando…",
"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."
}
}

View File

@ -7,3 +7,4 @@ export { inventoryApi } from "./shared/api";
export { inventoryKeys } from "./shared/queries";
export { AssetSupport } from "./support-connections";
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";

View File

@ -7,8 +7,10 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog";
import { InventoryFeedback } from "../../../shared";
import { WorkstationHistoryDialog } from "../../history";
import { WorkstationEditorDialog } from "../../shared";
@ -17,6 +19,7 @@ import type { WorkstationsPageProps } from "../types";
export function WorkstationsPage(props: WorkstationsPageProps) {
const { companyId, lookups } = props;
const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>();
const {
search,
setSearch,
@ -33,6 +36,8 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
} = useWorkstationsPageController(props);
const { t, i18n } = useTranslation("assets");
const targeted = query.data?.workstations.find((item) => item.id === operation?.id);
return (
<div className="space-y-5">
<EntityListHeader
@ -135,6 +140,8 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
{t("workstationHistory")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
{(item.values.active || item.values.assetIds.length > 0 || item.values.sharedAssetIds.length > 0 || item.values.personId) && <EntityAction onClick={() => setOperation({ id: item.id, action: "decommission" })}>{t("workstationLifecycle.decommission.title")}</EntityAction>}
<EntityAction className="text-destructive" onClick={() => setOperation({ id: item.id, action: "delete" })}>{t("workstationLifecycle.delete.title")}</EntityAction>
</div>{" "}
</EntityRowActions>
),
@ -149,6 +156,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
title={t("workstations")}
/>
)}
{targeted && operation && <WorkstationLifecycleDialog workstation={targeted} action={operation.action} onClose={() => setOperation(undefined)} />}
{editor && (
<WorkstationEditorDialog
companyId={companyId}

View File

@ -0,0 +1,51 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { WorkstationDTO } from "../../../../common/workstations";
import { WorkstationResponseSchema } from "../../../../common/workstations";
import { InventoryApiError } from "../../../shared/api/inventory-api-error";
import { inventoryRequest } from "../../../shared/api/inventory-request";
import { inventoryKeys } from "../../../shared/queries";
export function WorkstationLifecycleDialog({ workstation, action, onClose, onCompleted }: {
workstation: WorkstationDTO;
action: "decommission" | "delete";
onClose: () => void;
onCompleted?: (() => void) | undefined;
}) {
const { t } = useTranslation("assets");
const client = useQueryClient();
const [errorCode, setErrorCode] = useState<string>();
const prefix = `workstationLifecycle.${action}`;
return <ConfirmDeleteDialog
open
title={t(`${prefix}.title`)}
description={t(`${prefix}.description`, { name: workstation.values.name })}
cancelLabel={t("cancel")}
confirmLabel={t(`${prefix}.title`)}
pendingLabel={t(`${prefix}.pending`)}
errorLabel={t(errorCode === "WORKSTATION_IN_USE" ? "workstationLifecycle.inUse" : `${prefix}.error`)}
onOpenChange={(open) => { if (!open) { onClose(); } }}
onConfirm={async () => {
setErrorCode(undefined);
try {
const body = await inventoryRequest(`${workstation.companyId}/workstations/${workstation.id}/${action}`, {});
if (action === "delete") {
z.strictObject({ deleted: z.literal(true) }).parse(body);
} else {
WorkstationResponseSchema.parse(body);
}
await client.invalidateQueries({ queryKey: inventoryKeys.company(workstation.companyId) });
onCompleted?.();
} catch (error) {
setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR");
throw error;
}
}}
/>;
}

View File

@ -13,7 +13,7 @@ export type ApplicationContext = Readonly<{
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | "os" | null;
usage: "application" | "certificate" | "support" | "os" | "license" | null;
}[];
actors: ReadonlyMap<string, string>;
}>;

View File

@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | "os" | null;
usage: "application" | "certificate" | "support" | "os" | "license" | null;
expiresOn: string | null;
}[];
actors: ReadonlyMap<string, string>;

View File

@ -6,7 +6,11 @@ import { SoftwareInstallationError } from "../domain/software-installation-value
import { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo";
import type { SoftwareContext } from "./software-installation.repository";
import { softwareAvailable, softwareReferences } from "./software-installation-policy";
import {
softwareAvailable,
softwareLicenseAvailable,
softwareReferences,
} from "./software-installation-policy";
export function mapSoftwareInput(
input: SoftwareInstallationInput,
@ -30,6 +34,13 @@ export function mapSoftwareInput(
!previous ||
previous.values.state !== values.state ||
previous.values.version.getOrUndefined() !== values.version.getOrUndefined();
const licenseChanged =
previous?.values.licenseResourceId.getOrUndefined()?.toPrimitive() !==
values.licenseResourceId.getOrUndefined()?.toPrimitive();
if (licenseChanged && !softwareLicenseAvailable(values, context)) {
return Result.fail(new SoftwareInstallationError("SOFTWARE_REFERENCE_UNAVAILABLE"));
}
if (
["pending", "configured"].includes(values.state) &&

View File

@ -3,11 +3,34 @@ import type { SoftwareInstallationValues } from "../domain/value-objects/softwar
import type { SoftwareContext } from "./software-installation.repository";
export function softwareReferences(values: SoftwareInstallationValues, context: SoftwareContext) {
const licenseId = values.licenseResourceId.getOrUndefined();
return (
context.assets.some((item) => item.id.equals(values.assetId)) &&
context.resources.some(
(item) =>
item.id.equals(values.resourceId) && (item.usage === "application" || item.usage === "os")
) &&
(!licenseId ||
context.resources.some((item) => item.id.equals(licenseId) && item.usage === "license"))
);
}
export function softwareLicenseAvailable(
values: SoftwareInstallationValues,
context: SoftwareContext
) {
const licenseId = values.licenseResourceId.getOrUndefined();
const today = new Date().toISOString().slice(0, 10);
return (
!licenseId ||
context.resources.some(
(item) =>
item.id.equals(licenseId) &&
item.usage === "license" &&
item.active &&
(!item.expiresOn || item.expiresOn >= today)
)
);
}
@ -20,6 +43,7 @@ export function softwareAvailable(values: SoftwareInstallationValues, context: S
item.id.equals(values.resourceId) &&
item.active &&
(item.usage === "application" || item.usage === "os")
)
) &&
softwareLicenseAvailable(values, context)
);
}

View File

@ -10,7 +10,8 @@ export type SoftwareContext = Readonly<{
resources: readonly {
id: UniqueID;
active: boolean;
usage: "application" | "certificate" | "support" | "os" | null;
expiresOn: string | null;
usage: "application" | "certificate" | "support" | "os" | "license" | null;
}[];
actors: ReadonlyMap<string, string>;
}>;

View File

@ -5,6 +5,7 @@ import { ConfigurationSchema } from "./access/access-definition";
export const SoftwareInstallationInputSchema = z.strictObject({
assetId: z.uuid(),
resourceId: z.uuid(),
licenseResourceId: z.uuid().nullable(),
version: z.string().trim().max(100).nullable(),
state: ConfigurationSchema,
notes: z.string().trim().max(4000).nullable(),

View File

@ -21,6 +21,7 @@ const date = "2026-10-12T10:00:00.000Z";
const input: SoftwareInstallationInput = {
assetId: asset.toPrimitive(),
resourceId: resource.toPrimitive(),
licenseResourceId: null,
version: "24H2",
state: "pending",
notes: null,
@ -28,7 +29,7 @@ const input: SoftwareInstallationInput = {
const context: SoftwareContext = {
configurations: [],
assets: [{ id: asset, available: true }],
resources: [{ id: resource, active: true, usage: "os" }],
resources: [{ id: resource, active: true, expiresOn: null, usage: "os" }],
actors: new Map([[actor.toPrimitive(), "David"]]),
};
@ -53,14 +54,14 @@ test("instalaciones: pertenecen al equipo, admiten SO/aplicaciones y rechazan re
assert.ok(
mapSoftwareInput(input, {
...context,
resources: [{ id: resource, active: true, usage: "application" }],
resources: [{ id: resource, active: true, expiresOn: null, usage: "application" }],
}).isSuccess
);
assert.ok(!mapSoftwareInput(input, { ...context, assets: [] }).isSuccess);
assert.ok(
!mapSoftwareInput(input, {
...context,
resources: [{ id: resource, active: true, usage: "certificate" }],
resources: [{ id: resource, active: true, expiresOn: null, usage: "certificate" }],
}).isSuccess
);
assert.equal(
@ -131,3 +132,85 @@ test("instalaciones: versiones, idempotencia, retirada, revisión y persistencia
)
);
});
test("licencia del software: opcional, misma compañía y tipo, vigencia, desvinculación e historial idempotente", () => {
const license = UniqueID.generateNewID();
const licensedInput = { ...input, licenseResourceId: license.toPrimitive() };
const licenseItem = { id: license, active: true, usage: "license" as const, expiresOn: null };
const licensedContext = { ...context, resources: [...context.resources, licenseItem] };
assert.ok(mapSoftwareInput(licensedInput, licensedContext).isSuccess);
assert.ok(!mapSoftwareInput(licensedInput, context).isSuccess);
assert.ok(
!mapSoftwareInput({ ...input, licenseResourceId: resource.toPrimitive() }, context).isSuccess
);
assert.equal(
SoftwareInstallationInputSchema.safeParse({ ...input, licenseResourceId: "invalid" }).success,
false
);
const previous = installation();
const linked = previous.update(values(licensedInput), actor, date);
assert.ok(linked.isSuccess);
assert.equal(linked.data.history.length, 2);
assert.equal(linked.data.history[0]?.values.toPrimitive().licenseResourceId, null);
assert.equal(
linked.data.history[1]?.values.toPrimitive().licenseResourceId,
license.toPrimitive()
);
const same = linked.data.update(values(licensedInput), actor, date);
assert.ok(same.isSuccess);
assert.equal(same.data, linked.data);
for (const unavailable of [
{ ...licenseItem, active: false },
{ ...licenseItem, expiresOn: "2000-01-01" },
]) {
const unavailableContext = { ...context, resources: [...context.resources, unavailable] };
assert.ok(!mapSoftwareInput(licensedInput, unavailableContext).isSuccess);
assert.ok(buildSoftwareResponse(linked.data, unavailableContext).needsReview);
assert.ok(
mapSoftwareInput(
{ ...licensedInput, notes: "Revisar contrato" },
unavailableContext,
linked.data
).isSuccess
);
assert.ok(mapSoftwareInput(input, unavailableContext, linked.data).isSuccess);
assert.ok(
mapSoftwareInput(
input,
{ ...unavailableContext, assets: [{ id: asset, available: false }] },
linked.data
).isSuccess
);
assert.ok(
!mapSoftwareInput(
{ ...licensedInput, state: "removal_pending" },
unavailableContext,
previous
).isSuccess
);
assert.ok(
mapSoftwareInput(
{ ...licensedInput, state: "never_configured" },
unavailableContext,
linked.data
).isSuccess
);
}
const detached = linked.data.update(values(input), actor, date);
assert.ok(detached.isSuccess);
assert.equal(detached.data.history.length, 3);
const mapper = new SoftwareInstallationPersistenceMapper();
const stored = mapper.toPersistence(detached.data);
const restored = mapper.toDomain(stored.root, stored.revisions);
assert.equal(restored.values.toPrimitive().licenseResourceId, null);
assert.equal(restored.history[1]?.values.toPrimitive().licenseResourceId, license.toPrimitive());
assert.throws(() =>
mapper.toDomain({ ...stored.root, licenseResourceId: license.toPrimitive() }, stored.revisions)
);
});

View File

@ -11,6 +11,7 @@ export class SoftwareInstallationValues {
private constructor(
public readonly assetId: UniqueID,
public readonly resourceId: UniqueID,
public readonly licenseResourceId: Maybe<UniqueID>,
public readonly version: Maybe<string>,
public readonly state: SoftwareInstallationInput["state"],
public readonly notes: Maybe<string>
@ -30,7 +31,9 @@ export class SoftwareInstallationValues {
const asset = UniqueID.create(data.assetId);
const resource = UniqueID.create(data.resourceId);
if (!(asset.isSuccess && resource.isSuccess)) {
const license = data.licenseResourceId ? UniqueID.create(data.licenseResourceId) : undefined;
if (!(asset.isSuccess && resource.isSuccess) || (license && !license.isSuccess)) {
return Result.fail(new SoftwareInstallationError("INVALID_REQUEST"));
}
@ -38,6 +41,7 @@ export class SoftwareInstallationValues {
new SoftwareInstallationValues(
asset.data,
resource.data,
license?.isSuccess ? Maybe.some(license.data) : Maybe.none(),
data.version ? Maybe.some(data.version) : Maybe.none(),
data.state,
data.notes ? Maybe.some(data.notes) : Maybe.none()
@ -57,6 +61,8 @@ export class SoftwareInstallationValues {
return (
this.sameTarget(other) &&
this.state === other.state &&
this.licenseResourceId.getOrUndefined()?.toPrimitive() ===
other.licenseResourceId.getOrUndefined()?.toPrimitive() &&
this.version.getOrUndefined() === other.version.getOrUndefined() &&
this.notes.getOrUndefined() === other.notes.getOrUndefined()
);
@ -66,6 +72,7 @@ export class SoftwareInstallationValues {
return {
assetId: this.assetId.toPrimitive(),
resourceId: this.resourceId.toPrimitive(),
licenseResourceId: this.licenseResourceId.getOrUndefined()?.toPrimitive() ?? null,
version: this.version.getOrUndefined() ?? null,
state: this.state,
notes: this.notes.getOrUndefined() ?? null,

View File

@ -122,14 +122,14 @@ export class SequelizeSoftwareInstallationRepository implements ISoftwareInstall
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO software_installations (id,companyId,assetId,resourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)",
"INSERT INTO software_installations (id,companyId,assetId,resourceId,licenseResourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:licenseResourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE licenseResourceId=VALUES(licenseResourceId),version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:version,:state,:notes)",
"INSERT INTO software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,licenseResourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:licenseResourceId,:version,:state,:notes)",
{ replacements: revision, transaction }
);
}

View File

@ -35,6 +35,14 @@ export async function migrateSoftwareInstallations(database: Sequelize) {
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
});
}
// Las instalaciones anteriores conservan su historial y no presuponen una licencia.
for (const table of ["software_installations", "software_installation_revisions"]) {
const columns = await q.describeTable(table);
if (!("licenseResourceId" in columns)) {
await q.addColumn(table, "licenseResourceId", softwareSnapshot.columns.licenseResourceId);
}
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='software_installation_revisions'",
{ type: QueryTypes.SELECT }

View File

@ -17,6 +17,7 @@ export type SoftwareRow = {
companyId: string;
assetId: string;
resourceId: string;
licenseResourceId: string | null;
currentKey: string | null;
};

View File

@ -19,6 +19,12 @@ export const softwareSnapshot = {
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
},
licenseResourceId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: "resources", key: "id" },
onDelete: "RESTRICT",
},
version: { type: DataTypes.STRING(100), allowNull: true },
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: true },

View File

@ -951,14 +951,14 @@
"help": "The installation belongs to the computer. Profile applications describe each person’s requirements and availability. The factory operating system remains on the asset. Programs are not installed and licenses are not consumed automatically.",
"noComputer": "Designate the workstation main computer first.",
"empty": "No installations recorded.",
"review": "Needs review",
"review": "Review: removal pending, computer or software unavailable, or linked license inactive or expired.",
"loadError": "Unable to load installations and resources.",
"errors": {
"INVALID_REQUEST": "Check the installation details.",
"COMPANY_NOT_FOUND": "Company not found.",
"SOFTWARE_CONFIGURATION_NOT_FOUND": "Installation not found.",
"SOFTWARE_REFERENCE_NOT_FOUND": "The computer or resource does not belong to this company, or the resource is not software.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired or the resource is inactive.",
"SOFTWARE_REFERENCE_NOT_FOUND": "The computer, software or license does not belong to this company, or the resource type is invalid.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired, the software is inactive, or the license is inactive or expired.",
"SOFTWARE_ALREADY_OPEN": "An open installation of this software already exists on this computer.",
"SOFTWARE_TARGET_IMMUTABLE": "The computer and resource cannot change.",
"INVALID_SOFTWARE_TRANSITION": "This state transition is not allowed.",
@ -966,7 +966,12 @@
},
"examples": "Examples: Windows 11 Pro 24H2, Thunderbird 140 or Microsoft 365 Apps.",
"showClosed": "Include removed and never configured installations",
"newOs": "New operating system"
"newOs": "New operating system",
"license": "Linked license (optional)",
"licenseColumn": "License",
"noLicense": "No linked license",
"licenseHelp": "Informational reference to a license belonging to this company. It does not reserve seats or confirm coverage for this product or device. Leave it empty for free software or when the license is not yet known.",
"licenseUnavailable": "Unavailable"
}
},
"access": {

View File

@ -951,14 +951,14 @@
"help": "La instalación pertenece al ordenador. Las aplicaciones de cada perfil documentan las necesidades y disponibilidad de cada persona. El sistema operativo de fábrica permanece en el activo. No se instalan programas ni se consumen licencias automáticamente.",
"noComputer": "Designa primero el ordenador principal del puesto.",
"empty": "No hay instalaciones registradas.",
"review": "Requiere revisión",
"review": "Revisar: retirada pendiente, equipo o software no disponibles, o licencia vinculada inactiva o caducada.",
"loadError": "No se pudieron consultar las instalaciones y los recursos.",
"errors": {
"INVALID_REQUEST": "Revisa los datos de la instalación.",
"COMPANY_NOT_FOUND": "La compañía no existe.",
"SOFTWARE_CONFIGURATION_NOT_FOUND": "La instalación no existe.",
"SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador o el recurso no pertenecen a esta compañía, o el recurso no es software.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado o el recurso está inactivo.",
"SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador, software o licencia no pertenecen a esta compañía, o el tipo de recurso no es válido.",
"SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado, el software está inactivo o la licencia está inactiva o caducada.",
"SOFTWARE_ALREADY_OPEN": "Ya hay una instalación abierta de este software en el ordenador.",
"SOFTWARE_TARGET_IMMUTABLE": "El ordenador y el recurso no pueden cambiar.",
"INVALID_SOFTWARE_TRANSITION": "Este cambio de estado no está permitido.",
@ -966,7 +966,12 @@
},
"examples": "Ejemplos: Windows 11 Pro 24H2, Thunderbird 140 o Microsoft 365 Apps.",
"showClosed": "Mostrar también las retiradas y nunca configuradas",
"newOs": "Nuevo sistema operativo"
"newOs": "Nuevo sistema operativo",
"license": "Licencia vinculada (opcional)",
"licenseColumn": "Licencia",
"noLicense": "Sin licencia vinculada",
"licenseHelp": "Referencia informativa a una licencia de esta compañía. No reserva plazas ni confirma que la licencia cubra este producto o equipo. Puedes dejarla vacía para software gratuito o si aún no se conoce.",
"licenseUnavailable": "No disponible"
}
},
"access": {

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
@ -6,9 +7,11 @@ import type { SoftwareInstallationDTO } from "../../../../common";
export function SoftwareHistoryDialog({
configuration,
onClose,
resourceName,
}: {
onClose: () => void;
configuration: SoftwareInstallationDTO;
resourceName: (id: string) => string;
}) {
const { t, i18n } = useTranslation("companies");
@ -38,6 +41,18 @@ export function SoftwareHistoryDialog({
{revision.values.version && (
<p>{t("applications.versionValue", { version: revision.values.version })}</p>
)}
<p>
{t("software.licenseColumn")}:{" "}
{revision.values.licenseResourceId ? (
<BrowserLink
href={`/companies/${configuration.companyId}/resources/${revision.values.licenseResourceId}`}
>
{resourceName(revision.values.licenseResourceId)}
</BrowserLink>
) : (
"—"
)}
</p>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}

View File

@ -62,6 +62,9 @@ export function SoftwareInstallationsPanel({
(template) => template.id === item?.values.templateId
);
if (template?.builtinKey === "template:software_license") {
return "license";
}
if (template?.builtinKey === "template:os") {
return "os";
}
@ -97,6 +100,28 @@ export function SoftwareInstallationsPanel({
label: `${item.values.name} · ${t(`software.${usage(item.id) === "os" ? "os" : "application"}`)}`,
}));
const expiryFieldId = catalog.data?.items.find((item) => item.builtinKey === "field:expires")?.id;
const today = new Date().toISOString().slice(0, 10);
const licenseOptions = (resources.data?.resources ?? [])
.filter(
(item) =>
usage(item.id) === "license" &&
(item.values.active || item.id === edited?.values.licenseResourceId)
)
.map((item) => {
const expiresOn = expiryFieldId ? item.values.characteristics[expiryFieldId] : undefined;
const unavailable =
!item.values.active || (typeof expiresOn === "string" && expiresOn < today);
return {
value: item.id,
label: unavailable
? `${item.values.name} · ${t("software.licenseUnavailable")}`
: item.values.name,
disabled: unavailable,
};
});
return (
<section aria-label={t("software.title")} className="space-y-4">
<EntityListHeader
@ -160,6 +185,22 @@ export function SoftwareInstallationsPanel({
value: (item) => item.values.version,
render: (item) => item.values.version || "—",
},
{
id: "license",
label: t("software.licenseColumn"),
value: (item) =>
item.values.licenseResourceId ? name(item.values.licenseResourceId) : "",
render: (item) =>
item.values.licenseResourceId ? (
<BrowserLink
href={`/companies/${companyId}/resources/${item.values.licenseResourceId}`}
>
{name(item.values.licenseResourceId)}
</BrowserLink>
) : (
"—"
),
},
{
id: "state",
label: t("status"),
@ -229,13 +270,18 @@ export function SoftwareInstallationsPanel({
assetId={assetId}
companyId={companyId}
installation={edited}
licenseOptions={licenseOptions}
onSaved={() => setEditor(undefined)}
options={options}
/>
</FormDialog>
)}
{viewed && (
<SoftwareHistoryDialog configuration={viewed} onClose={() => setView(undefined)} />
<SoftwareHistoryDialog
configuration={viewed}
onClose={() => setView(undefined)}
resourceName={name}
/>
)}
<DeleteRecordDialog
companyId={companyId}

View File

@ -19,7 +19,14 @@ const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")
const companyId = id(1);
const assetId = id(2);
const values = { assetId, resourceId: id(3), version: "24H2", state: "configured", notes: null };
const values = {
assetId,
resourceId: id(3),
licenseResourceId: id(8),
version: "24H2",
state: "configured",
notes: null,
};
const item = SoftwareInstallationSchema.parse({
id: id(4),
companyId,
@ -54,10 +61,20 @@ async function render(language: string, node: ReactNode) {
],
});
client.setQueryData(inventoryKeys.company(companyId), {
resources: [{ id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } }],
resources: [
{ id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } },
{ id: id(8), values: { name: "Licencia Windows", active: true, templateId: id(9) } },
],
});
client.setQueryData(catalogKeys.scope(companyId), {
items: [{ id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } }],
items: [
{ id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } },
{
id: id(9),
builtinKey: "template:software_license",
values: { kind: "template", usage: "license" },
},
],
});
try {
return renderToStaticMarkup(
@ -79,6 +96,8 @@ test("software del puesto es/en: SO actual, versión, enlaces y separación por
assert.ok(html.includes("Windows 11 Pro"));
assert.ok(html.includes("24H2"));
assert.ok(html.includes("Licencia Windows"));
assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(8)}"`));
assert.ok(html.includes(language === "es" ? "Sistema operativo" : "Operating system"));
assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(3)}"`));
assert.equal(html.includes("OTHER-COMPUTER"), false);
@ -100,6 +119,7 @@ test("formulario software: recurso fijo en edición, estados permitidos, ayuda y
assetId={assetId}
companyId={companyId}
installation={item}
licenseOptions={[{ value: id(8), label: "Licencia Windows" }]}
onSaved={() => undefined}
options={[{ value: id(3), label: "Windows 11 Pro" }]}
/>
@ -107,6 +127,9 @@ test("formulario software: recurso fijo en edición, estados permitidos, ayuda y
assert.ok(html.includes("La instalación pertenece al ordenador"));
assert.ok(html.includes("Guardar"));
assert.ok(html.includes("Licencia vinculada (opcional)"));
assert.ok(html.includes("No reserva plazas"));
assert.equal(html.includes("software.licenseHelp"), false);
assert.equal(html.includes("software.examples"), false);
assert.equal(html.includes('name="profileId"'), false);
assert.equal(html.includes('name="personId"'), false);

View File

@ -21,12 +21,14 @@ export function SoftwareInstallationForm({
assetId,
installation,
options,
licenseOptions,
onSaved,
}: {
companyId: string;
assetId: string;
installation: SoftwareInstallationDTO | undefined;
options: readonly { value: string; label: string }[];
licenseOptions: readonly { value: string; label: string; disabled?: boolean }[];
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
@ -38,6 +40,7 @@ export function SoftwareInstallationForm({
defaultValues: installation?.values ?? {
assetId,
resourceId: "",
licenseResourceId: null,
version: null,
state: "pending",
notes: null,
@ -110,6 +113,21 @@ export function SoftwareInstallationForm({
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-license`}>{t("software.license")}</Label>
<SelectWithAction
describedBy={`${prefix}-license-help`}
id={`${prefix}-license`}
label={t("software.license")}
onValueChange={(value) => form.setValue("licenseResourceId", value || null)}
options={licenseOptions}
placeholder={t("software.noLicense")}
value={form.watch("licenseResourceId") ?? ""}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-license-help`}>
{t("software.licenseHelp")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea id={`${prefix}-notes`} maxLength={4000} {...form.register("notes")} />

View File

@ -1,9 +1,10 @@
import { WorkstationEditorDialog } from "@metis/assets/client";
import { BrowserLink } from "@metis/core/client";
import { WorkstationEditorDialog, WorkstationLifecycleDialog } from "@metis/assets/client";
import { BrowserLink, navigateTo } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
@ -24,6 +25,7 @@ export function WorkstationDetailPage({
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
}) {
const { t } = useTranslation("assets");
const [operation, setOperation] = useState<"decommission" | "delete">();
const { t: profileTranslation } = useTranslation("companies");
const {
creatingProfile,
@ -96,9 +98,11 @@ export function WorkstationDetailPage({
</div>
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
</div>
<Button onClick={() => setEditing(true)} variant="outline">
{t("editWorkstation")}
</Button>
<div className="flex flex-wrap gap-2">
<Button onClick={() => setEditing(true)} variant="outline">{t("editWorkstation")}</Button>
{(workstation.values.active || workstation.values.assetIds.length > 0 || workstation.values.sharedAssetIds.length > 0 || workstation.values.personId) && <Button onClick={() => setOperation("decommission")} variant="outline">{t("workstationLifecycle.decommission.title")}</Button>}
<Button onClick={() => setOperation("delete")} variant="destructive">{t("workstationLifecycle.delete.title")}</Button>
</div>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)]">
<div className="space-y-6">
@ -226,6 +230,15 @@ export function WorkstationDetailPage({
</section>
</div>
</div>
{operation && <WorkstationLifecycleDialog
workstation={workstation}
action={operation}
onClose={() => setOperation(undefined)}
onCompleted={() => {
if (operation === "delete") { navigateTo(`${root}/workstations`, { replace: true }); }
else { profiles.refetch().catch(() => undefined); }
}}
/>}
{creatingProfile && (
<WorkProfileEditorDialog
company={company}

View File

@ -10,7 +10,7 @@ export interface IResourceInventoryService {
id: UniqueID;
name: string;
active: boolean;
usage: "application" | "certificate" | "support" | "os" | null;
usage: "application" | "certificate" | "support" | "os" | "license" | null;
expiresOn: string | null;
}[]
>;
@ -57,6 +57,8 @@ function resourceUsage(key: string | undefined, definition: ResourceDefinitionSp
return "application";
case "template:certificate":
return "certificate";
case "template:software_license":
return "license";
case "template:os":
return "os";
case "template:support":

View File

@ -210,7 +210,11 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
add(`template:${key}`, {
kind: "template",
...(key === "support" || key === "os" ? { usage: key } : {}),
...(key === "software_license"
? { usage: "license" as const }
: key === "support" || key === "os"
? { usage: key }
: {}),
name,
description: "",
fields: references,

View File

@ -234,6 +234,7 @@ test("licencias: ampliar un catálogo previo conserva identidades y disponibilid
assert.ok(license && license.values.data.kind === "template");
assert.ok(license.values.data.fields.every((binding) => !binding.required));
assert.equal(license.values.data.usage, "license");
const editedTemplate = license.update(values({ ...license.values.data, name: "Otra" }));
assert.ok(editedTemplate.isSuccess);

View File

@ -34,7 +34,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),
sections: z.array(TemplateSectionSchema).max(30).optional(),
usage: z.enum(["application", "certificate", "support", "os"]).optional(),
usage: z.enum(["application", "certificate", "support", "os", "license"]).optional(),
})
.refine((template) => validTemplateSections(template.sections ?? [], template.fields), {
path: ["sections"],

View File

@ -106,6 +106,10 @@ export async function migrateResourceCatalog(database: Sequelize) {
}
}
await initializeResourceCatalogSchema(database);
// Clasificación explícita de la plantilla de serie existente, sin alterar copias ni campos.
await database.query(
"UPDATE resource_definitions SET `usage`='license' WHERE stockKey='template:software_license' AND kind='template' AND `usage` IS NULL"
);
ResourceCatalogModel.init(catalog, {
sequelize: database,
tableName: "resource_catalogs",

View File

@ -58,13 +58,14 @@
"editField": "Edit field",
"newTemplate": "New template",
"templateUsage": "Template use",
"templateUsageHelp": "Allows resources to be used as profile applications, operating systems, certificates or remote assistance tools. Usage is fixed after template creation.",
"templateUsageHelp": "Allows these resources to be used as profile applications, operating systems, certificates, remote support tools or software licenses. Usage is fixed when the template is created.",
"usage": {
"general": "General",
"application": "Application",
"certificate": "Digital certificate",
"support": "Remote assistance",
"os": "Operating system"
"os": "Operating system",
"license": "Software license"
},
"editTemplate": "Edit template",
"name": "Name",

View File

@ -58,13 +58,14 @@
"editField": "Editar campo",
"newTemplate": "Nueva plantilla",
"templateUsage": "Uso de la plantilla",
"templateUsageHelp": "Permite utilizar estos recursos como aplicaciones de perfiles, sistemas operativos, certificados o herramientas de asistencia remota. El uso se fija al crear la plantilla.",
"templateUsageHelp": "Permite utilizar estos recursos como aplicaciones de perfiles, sistemas operativos, certificados, herramientas de asistencia remota o licencias de software. El uso se fija al crear la plantilla.",
"usage": {
"general": "General",
"application": "Aplicación",
"certificate": "Certificado digital",
"support": "Asistencia remota",
"os": "Sistema operativo"
"os": "Sistema operativo",
"license": "Licencia de software"
},
"editTemplate": "Editar plantilla",
"name": "Nombre",

View File

@ -8,6 +8,10 @@ export function templateUsage(source: CatalogItemDTO | undefined) {
return "certificate";
}
if (source?.builtinKey === "template:software_license") {
return "license";
}
if (source?.builtinKey === "template:os") {
return "os";
}

View File

@ -54,14 +54,15 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
value === "application" ||
value === "certificate" ||
value === "support" ||
value === "os"
value === "os" ||
value === "license"
) {
form.setValue("usage", value);
} else {
form.setValue("usage", undefined);
}
}}
options={["general", "application", "certificate", "support", "os"].map(
options={["general", "application", "certificate", "support", "os", "license"].map(
(value) => ({
value,
label: t(`usage.${value}`),