diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts
index e507389..bb936ad 100644
--- a/apps/server/src/resources.integration.ts
+++ b/apps/server/src/resources.integration.ts
@@ -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)));
diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md
index 6e4625d..921a201 100644
--- a/docs/plan-de-desarrollo.md
+++ b/docs/plan-de-desarrollo.md
@@ -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
diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md
index 165fbf0..a730cea 100644
--- a/docs/recursos-campos-y-plantillas.md
+++ b/docs/recursos-campos-y-plantillas.md
@@ -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.
diff --git a/modules/resources/src/api/domain/resource-catalog-seed.ts b/modules/resources/src/api/domain/resource-catalog-seed.ts
index 671f61f..e9b0b78 100644
--- a/modules/resources/src/api/domain/resource-catalog-seed.ts
+++ b/modules/resources/src/api/domain/resource-catalog-seed.ts
@@ -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) => {
diff --git a/modules/resources/src/api/domain/resource-catalog.test.ts b/modules/resources/src/api/domain/resource-catalog.test.ts
index 2a3a3ad..a88ee27 100644
--- a/modules/resources/src/api/domain/resource-catalog.test.ts
+++ b/modules/resources/src/api/domain/resource-catalog.test.ts
@@ -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);
+});
diff --git a/modules/resources/src/web/inventory-i18n.ts b/modules/resources/src/web/inventory-i18n.ts
index a797957..fb9537c 100644
--- a/modules/resources/src/web/inventory-i18n.ts
+++ b/modules/resources/src/web/inventory-i18n.ts
@@ -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.",
diff --git a/modules/resources/src/web/resource-form.tsx b/modules/resources/src/web/resource-form.tsx
index 7690a88..dadae39 100644
--- a/modules/resources/src/web/resource-form.tsx
+++ b/modules/resources/src/web/resource-form.tsx
@@ -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({
)}
diff --git a/modules/resources/src/web/resources.test.tsx b/modules/resources/src/web/resources.test.tsx
index 0a9c9a6..b71bfcc 100644
--- a/modules/resources/src/web/resources.test.tsx
+++ b/modules/resources/src/web/resources.test.tsx
@@ -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,
+ 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);
+ }
+});