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); + } +});