Licencias de software

This commit is contained in:
David Arranz 2026-10-07 13:26:59 +02:00
parent 1989bbff89
commit 5144073481
8 changed files with 291 additions and 18 deletions

View File

@ -43,14 +43,104 @@ export async function verifyResources(
return ResourceResponseSchema.parse(await response.json()).resource;
}
const initial = await read(global);
assert.equal(initial.items.length, 18);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 5);
assert.equal(initial.items.length, 26);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
assert.ok(
initial.items.every((item) =>
item.bindings.every((binding) => !binding.required && binding.id[14] === "7")
)
);
const licenseTemplate = initial.items.find(
(item) => item.builtinKey === "template:software_license"
);
assert.ok(licenseTemplate && licenseTemplate.values.kind === "template");
const licenseFieldId = (key: string) => {
const field = initial.items.find((item) => item.builtinKey === `field:${key}`);
assert.ok(field && field.values.kind === "field");
return field.id;
};
const licenseInput = {
name: "Microsoft 365 Business Standard",
templateId: licenseTemplate.id,
active: true,
notes: "",
characteristics: {
[licenseFieldId("software_product")]: "Microsoft 365",
[licenseFieldId("software_edition")]: "Business Standard",
[licenseFieldId("license_model")]: "Suscripción",
[licenseFieldId("license_metric")]: "Por usuario",
[licenseFieldId("license_quantity")]: 25,
[licenseFieldId("expires")]: "2027-10-07",
[licenseFieldId("renews")]: "2027-09-30",
[licenseFieldId("auto_renew")]: false,
},
};
const license = await resource(inventory, licenseInput);
assert.equal(license.companyId, companyId);
assert.equal(license.specification.templateName, "Licencia de software");
assert.equal(license.values.characteristics[licenseFieldId("auto_renew")], false);
assert.equal(license.id[14], "7");
const licenseUpdated = await resource(`${inventory}/${license.id}/update`, {
...licenseInput,
characteristics: { ...licenseInput.characteristics, [licenseFieldId("license_quantity")]: 30 },
});
assert.equal(licenseUpdated.history.length, 2);
assert.equal(
licenseUpdated.history[0]?.values.characteristics[licenseFieldId("license_quantity")],
25
);
await error(
inventory,
{
values: {
...licenseInput,
name: "Fecha inválida",
characteristics: { [licenseFieldId("renews")]: "2027-02-30" },
},
},
"INVALID_CHARACTERISTICS"
);
await error(
inventory,
{
values: {
...licenseInput,
name: "Modalidad inválida",
characteristics: { [licenseFieldId("license_model")]: "Desconocida" },
},
},
"INVALID_CHARACTERISTICS"
);
const minimal = await resource(inventory, {
...licenseInput,
name: "ESET empresa",
characteristics: {},
});
assert.deepEqual(minimal.values.characteristics, {});
await error(
`/resources/${otherId}/${license.id}/update`,
{ values: licenseInput },
"RESOURCE_NOT_FOUND",
404
);
assert.equal(
(await request(`${company}/${licenseTemplate.id}/status`, { active: false })).status,
200
);
await error(
inventory,
{ values: { ...licenseInput, name: "No disponible" } },
"TEMPLATE_UNAVAILABLE"
);
await resource(`${inventory}/${license.id}/update`, {
...licenseUpdated.values,
notes: "Conservada",
});
assert.equal(
(await request(`${company}/${licenseTemplate.id}/status`, { active: null })).status,
200
);
const assetsResponse = await request("/asset-catalog/global");
const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json());
assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id)));

View File

@ -49,7 +49,7 @@ Buzones independientes corporativos o externos, cuenta informática opcional, pe
## Recursos con campos y plantillas: base implementada
Catálogo independiente de activos, 13 campos y cinco plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
Catálogo independiente de activos, 20 campos y seis plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
## Impresoras por perfil: implementado
@ -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. Las licencias y su relación con cuentas se explorarán posteriormente.
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.
## Inventario: implementado
@ -103,9 +103,13 @@ Tickets por compañía y consulta global con búsqueda/filtros, referencia corta
Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo realizado, resultado y elementos afectados. Varias intervenciones por ticket o actuaciones internas sin ticket. Pasos opcionales, procedimientos versionados, comprobaciones obligatorias al finalizar, historial, idempotencia y control de concurrencia. Consultar [Intervenciones y plantillas](intervenciones-y-plantillas.md).
## 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).
## Servicios y documentación: pendiente
Software, licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación.
Gestión de asignaciones de licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación.
Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre.
## Decisiones pendientes

View File

@ -20,7 +20,7 @@ Cada compañía puede activar o desactivar una definición global para su ámbit
## Datos de serie
Se incluyen 13 campos y cinco plantillas:
Se incluyen 20 campos y seis plantillas:
| Plantilla | Campos |
| --- | --- |
@ -28,6 +28,7 @@ Se incluyen 13 campos y cinco plantillas:
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
| Aplicación | Proveedor, URL y descripción |
| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad |
| Licencia de software | Producto, edición o plan, proveedor, modalidad, criterio de licenciamiento, cantidad contratada, referencia, caducidad, fecha de renovación, renovación automática y descripción |
| Recurso de acceso | Referencia; contexto estructurado de tipo de acceso, sistema y entorno |
Las características de serie son opcionales salvo la dirección válida exigida al registrar un buzón. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo.
@ -77,3 +78,13 @@ POP/IMAP pertenece a la configuración de correo por perfil; letras de unidad pe
Las pruebas con MariaDB de test comprueban UUID y revisiones originales, repetición idempotente, edición canónica y actualización de proyecciones, aislamiento por compañía y rechazo de escrituras antiguas. Se mantienen las pruebas de múltiples correos, permisos, necesidades, cobertura y perfiles. Las pruebas de dominio cubren correo inválido o ausente, normalización, cuenta opcional, cambio auditado y tipo fijo.
La comprobación en navegador usa los datos existentes de desarrollo, sin crear registros ficticios: ficha, historial, personas vinculadas, edición modal y selectores.
## Licencias de software: registro implementado
Una licencia es un Resource de la compañía creado con la plantilla de serie Licencia de software. Se registra en Recursos → Nuevo recurso, seleccionando esa plantilla; no constituye un activo ni una instalación de aplicación. Ejemplos: Microsoft 365 Business Standard, ESET Endpoint contratado para la empresa o licencia perpetua de ERP.
Nombre, plantilla y estado siguen el contrato común. Todas las características de esta plantilla son opcionales. Modalidad ofrece Suscripción, Perpetua, OEM y Otra; criterio ofrece Por usuario, Por dispositivo, Por servidor, Concurrente, Corporativa y Otro. Cantidad contratada es una característica numérica documental; no representa plazas libres ni impone aún reglas de asignación. Caducidad y fecha de renovación se registran independientemente. Renovación automática distingue sí, no y sin datos. No almacenar claves de activación.
Se reutilizan proveedor, referencia, caducidad y descripción del catálogo de recursos. Los siete campos nuevos y la plantilla se incorporan de forma idempotente al arrancar, sin cambiar identidades, fichas existentes ni disponibilidad configurada. La plantilla de serie está protegida y se puede copiar para personalizar; se puede desactivar globalmente o para una compañía. La ayuda específica aparece al final del modal, antes de Guardar y Cancelar.
La ficha captura su especificación y conserva revisiones como cualquier recurso. Registrar una licencia no crea asignaciones, vínculos con aplicaciones/cuentas/equipos, avisos de vencimiento ni cálculos de capacidad. Estas relaciones y avisos quedan pendientes de una entrega posterior.

View File

@ -2,7 +2,7 @@ import type { ResourceCatalog } from "./resource-catalog.aggregate";
import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
const FIELDS: readonly [string, string, ResourceFieldType][] = [
const FIELDS: readonly [string, string, ResourceFieldType, (readonly string[])?][] = [
["email", "Dirección de correo", "text"],
["provider", "Proveedor", "text"],
["context", "Contexto", "text"],
@ -16,6 +16,18 @@ const FIELDS: readonly [string, string, ResourceFieldType][] = [
["fingerprint", "Huella digital", "text"],
["purpose", "Finalidad", "text"],
["reference", "Referencia", "text"],
["software_product", "Producto de software", "text"],
["software_edition", "Edición o plan", "text"],
["license_model", "Modalidad de licencia", "choice", ["Suscripción", "Perpetua", "OEM", "Otra"]],
[
"license_metric",
"Criterio de licenciamiento",
"choice",
["Por usuario", "Por dispositivo", "Por servidor", "Concurrente", "Corporativa", "Otro"],
],
["license_quantity", "Cantidad contratada", "number"],
["renews", "Fecha de renovación", "date"],
["auto_renew", "Renovación automática", "boolean"],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
@ -23,6 +35,23 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
["application", "Aplicación", ["provider", "url", "description"]],
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
[
"software_license",
"Licencia de software",
[
"software_product",
"software_edition",
"provider",
"license_model",
"license_metric",
"license_quantity",
"reference",
"expires",
"renews",
"auto_renew",
"description",
],
],
];
/** Solo alimenta definiciones; no crea recursos ni migra registros anteriores. */
export function seedResourceCatalog(catalog: ResourceCatalog) {
@ -38,8 +67,15 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
}
current = result.data;
};
for (const [key, name, type] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit: "", choices: [] });
for (const [key, name, type, choices] of FIELDS) {
add(`field:${key}`, {
kind: "field",
name,
description: "",
type,
unit: "",
choices: [...(choices ?? [])],
});
}
for (const [key, name, fields] of TEMPLATES) {
const references = fields.map((field) => {

View File

@ -24,7 +24,7 @@ const field = {
} as const;
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
assert.equal(catalog.definitions.length, 18);
assert.equal(catalog.definitions.length, 26);
assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
@ -110,3 +110,62 @@ test("ámbitos, tipos, listas, campos externos y disponibilidad por compañía",
assert.ok(reset.isSuccess);
assert.equal(reset.data.active(stock, Maybe.some(company)), true);
});
test("licencias: ampliar un catálogo previo conserva identidades y disponibilidad", () => {
const seeded = seedResourceCatalog(ResourceCatalog.create());
const originalKeys = new Set([
"field:email",
"field:provider",
"field:context",
"field:unc",
"field:server",
"field:description",
"field:url",
"field:holder",
"field:issuer",
"field:expires",
"field:fingerprint",
"field:purpose",
"field:reference",
"template:mailbox",
"template:access",
"template:shared_folder",
"template:application",
"template:certificate",
]);
const previous = ResourceCatalog.rehydrate(
{
definitions: seeded.definitions.filter((item) =>
originalKeys.has(item.stockKey.getOrUndefined() ?? "")
),
availability: [],
},
seeded.id
);
const original = previous.definitions[0];
assert.ok(original);
const disabled = previous.setActive(Maybe.none(), original.id, false);
assert.ok(disabled.isSuccess);
const upgraded = seedResourceCatalog(disabled.data);
assert.equal(upgraded.definitions.length, 26);
for (const existing of disabled.data.definitions) {
assert.equal(
upgraded.definitions.find((item) => item.id.equals(existing.id)),
existing
);
}
const license = upgraded.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:software_license"
);
assert.ok(license && license.values.data.kind === "template");
assert.ok(license.values.data.fields.every((binding) => !binding.required));
const protectedTemplate = license.update(values({ ...license.values.data, name: "Otra" }));
assert.ok(!protectedTemplate.isSuccess);
assert.equal(protectedTemplate.error.code, "BUILTIN_PROTECTED");
const company = UniqueID.generateNewID();
const locallyDisabled = upgraded.setActive(Maybe.some(company), license.id, false);
assert.ok(locallyDisabled.isSuccess);
assert.equal(locallyDisabled.data.active(license, Maybe.some(company)), false);
assert.equal(locallyDisabled.data.active(license, Maybe.none()), true);
assert.equal(seedResourceCatalog(locallyDisabled.data), locallyDisabled.data);
});

View File

@ -23,13 +23,21 @@ export const inventorySpanish = {
snapshotNotice:
"La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
resourceDescription:
"Registra un buzón, carpeta compartida, aplicación o certificado disponible en el contexto de esta compañía.",
"Registra un buzón, carpeta compartida, aplicación, certificado o licencia de software disponible en el contexto de esta compañía.",
resourceExamples:
"Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo o certificado de firma.",
"Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo, certificado de firma o suscripción Microsoft 365.",
resourceGuidance:
"Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.",
resourceScope:
"Este registro identifica el recurso de la compañía. Su disponibilidad y su configuración en los equipos se gestionan por separado.",
licenseDescription:
"Registra una licencia o suscripción de software contratada por esta compañía.",
licenseExamples:
"Ejemplos: Microsoft 365 Business Standard por usuario, ESET Endpoint por dispositivo o una licencia perpetua de ERP.",
licenseGuidance:
"Todos los campos son opcionales. Cantidad contratada indica las plazas o unidades del contrato. Caducidad y renovación pueden ser distintas; una licencia perpetua puede no tener caducidad. No registres claves de activación.",
licenseScope:
"La aplicación utilizada y la licencia contratada son recursos distintos. Este registro no asigna plazas ni calcula cuántas quedan disponibles.",
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
inventoryErrors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
@ -73,13 +81,20 @@ export const inventoryEnglish = {
recordedBy: "Recorded by {{name}}",
snapshotNotice: "This record retains the template fields captured when the resource was created.",
resourceDescription:
"Register a mailbox, shared folder, application or certificate used in this company's context.",
"Register a mailbox, shared folder, application, certificate or software license used in this company's context.",
resourceExamples:
"Examples: admin@company.com, Administration folder, corporate Zoom or signing certificate.",
"Examples: admin@company.com, Administration folder, corporate Zoom, signing certificate or Microsoft 365 subscription.",
resourceGuidance:
"Characteristics are optional unless the template requires them. Never store passwords or private keys.",
resourceScope:
"This record identifies the company resource. Availability and device configuration are managed separately.",
licenseDescription: "Register a software license or subscription contracted by this company.",
licenseExamples:
"Examples: Microsoft 365 Business Standard per user, ESET Endpoint per device or a perpetual ERP license.",
licenseGuidance:
"All fields are optional. Contracted quantity records seats or units covered by the contract. Expiration and renewal dates may differ; perpetual licenses may have no expiration. Do not enter activation keys.",
licenseScope:
"The application used and its contracted license are separate resources. This record does not allocate seats or calculate remaining capacity.",
relatedNotice: "The template remains saved if you later cancel the resource.",
inventoryErrors: {
INVALID_REQUEST: "Check the form data.",

View File

@ -84,6 +84,10 @@ export function ResourceForm({
);
const template = templates.find((item) => item.id === values.templateId);
const fields = resourceFields(resource, template, items);
const isSoftwareLicense = items.some(
(item) => item.id === values.templateId && item.builtinKey === "template:software_license"
);
const helpPrefix = isSoftwareLicense ? "license" : "resource";
const mutation = useMutation({
mutationFn: (input: ResourceInput) =>
inventoryApi.save(companyId, input, csrfToken, resource?.id),
@ -234,11 +238,11 @@ export function ResourceForm({
)}
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
<ContextHelp
description={t("resourceDescription")}
examples={t("resourceExamples")}
description={t(`${helpPrefix}Description`)}
examples={t(`${helpPrefix}Examples`)}
examplesLabel={t("examplesLabel")}
guidance={t("resourceGuidance")}
scopeNote={t("resourceScope")}
guidance={t(`${helpPrefix}Guidance`)}
scopeNote={t(`${helpPrefix}Scope`)}
title={t("helpTitle")}
/>
<FormActions>

View File

@ -75,3 +75,57 @@ test("recurso: ayuda inferior, plantilla con alta rápida y sin asignaciones de
);
assert.equal(child.includes('aria-label="Nueva plantilla"'), false);
});
test("licencia: formulario reutilizable con campos opcionales y ayuda antes de guardar", async () => {
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
const template = catalog.items.find((item) => item.builtinKey === "template:software_license");
assert.ok(template && template.values.kind === "template");
const fields = template.values.fields.map((binding) => {
const field = catalog.items.find((item) => item.id === binding.fieldId);
assert.ok(field && field.values.kind === "field");
return {
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
};
});
const resource = {
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: { templateId: template.id, templateName: template.values.name, fields },
values: {
name: "Microsoft 365",
templateId: template.id,
active: true,
notes: "",
characteristics: {},
},
history: [],
};
for (const [language, help] of [
["es", "Registra una licencia o suscripción"],
["en", "Register a software license or subscription"],
]) {
assert.ok(language && help);
const html = await render(
language,
<ResourceForm
companyId={companyId}
csrfToken="test"
items={catalog.items}
onSaved={() => undefined}
resource={resource}
/>
);
assert.ok(html.includes("Cantidad contratada"));
assert.ok(html.includes("Fecha de renovación"));
assert.ok(html.includes("Renovación automática"));
assert.ok(html.includes(help));
assert.ok(html.indexOf(help) < html.indexOf('type="submit"'));
assert.equal(html.includes("Cantidad contratada *"), false);
assert.equal(html.includes("licenseDescription"), false);
}
});