diff --git a/CHANGELOG.md b/CHANGELOG.md
index 952e8ee..d82b067 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net
+## [4.15.0] - 2026-10-30
+### Added
+- Equipos → detalle: pestaña "Licenciamiento" (antes de Historial) con Windows (edición, Product ID,
+ clave parcial, canal OEM o Retail/Volumen) y Office (licencias que reporta el agente con canal,
+ Product ID y clave parcial; aviso si es MSDN) más el Office instalado. La clave se guarda solo con
+ los últimos 5 caracteres. Los equipos ya inventariados se completan desde su último reporte crudo
+ al abrir el detalle. No indica si la licencia está activada.
+
## [4.14.0] - 2026-10-29
### Added
- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
diff --git a/VERSION b/VERSION
index c412a4e..5c517bf 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.14.0
+4.15.0
diff --git a/backend/src/modules/devices/ingest.js b/backend/src/modules/devices/ingest.js
index 913f217..95bc4db 100644
--- a/backend/src/modules/devices/ingest.js
+++ b/backend/src/modules/devices/ingest.js
@@ -60,7 +60,7 @@ async function saveRaw(deviceid, text, format) {
return name;
}
-function parseInWorker(text, format) {
+export function parseInWorker(text, format) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
diff --git a/backend/src/modules/devices/normalizer.js b/backend/src/modules/devices/normalizer.js
index 024d1bf..d1b54e9 100644
--- a/backend/src/modules/devices/normalizer.js
+++ b/backend/src/modules/devices/normalizer.js
@@ -7,7 +7,7 @@ import { XMLParser } from 'fast-xml-parser';
const XML_ARRAY_SECTIONS = new Set([
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
- 'users', 'local_users', 'local_groups', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
+ 'users', 'local_users', 'local_groups', 'licenseinfos', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
]);
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
@@ -74,6 +74,38 @@ function isComponent(name, category) {
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
}
+// Licenciamiento de Windows y Office. La clave de producto NUNCA se guarda completa en la BD: solo
+// el ultimo bloque (la completa sigue solo en el reporte crudo, de descarga restringida).
+const maskKey = (v) => {
+ const key = str(v, 64);
+ if (!key) return null;
+ return key.replace(/[A-Z0-9]/gi, '*').slice(0, -5) + key.slice(-5);
+};
+
+export function extractLicenses(c) {
+ const hw = c.hardware || {};
+ const os = c.operatingsystem || {};
+ const winPid = str(hw.winprodid, 64);
+ const isWindows = /windows/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`);
+ const windows = isWindows ? {
+ edition: str(os.full_name ?? hw.osname),
+ version: str(os.version ?? hw.osversion, 120),
+ product_id: winPid,
+ key: maskKey(hw.winprodkey),
+ oem: winPid ? /OEM/i.test(winPid) : null,
+ owner: str(hw.winowner, 190),
+ company: str(hw.wincompany, 190),
+ } : null;
+ const office = asArray(c.licenseinfos)
+ .filter((x) => /office/i.test(`${x.name || ''} ${x.fullname || ''}`))
+ .map((x) => ({
+ name: str(x.name), full_name: str(x.fullname), product_id: str(x.productid, 64), key: maskKey(x.key),
+ oem: x.oem == null || x.oem === '' ? null : String(x.oem) === '1',
+ msdn: /msdn/i.test(`${x.name || ''} ${x.fullname || ''}`),
+ }));
+ return { windows, office };
+}
+
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
@@ -197,6 +229,7 @@ export function normalizeInventory(parsed) {
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
}
+ extra.licenses = extractLicenses(c);
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
}
diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js
index 047f939..6ecf1d1 100644
--- a/backend/src/modules/devices/repository.js
+++ b/backend/src/modules/devices/repository.js
@@ -229,6 +229,8 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n
export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, id]);
+export const saveExtra = (id, extra) => pool.query('UPDATE devices SET extra_json = ? WHERE id = ?', [JSON.stringify(extra), id]);
+
export async function findRawFile(id) {
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
return row?.raw_file || null;
diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js
index 089bd85..0c24221 100644
--- a/backend/src/modules/devices/service.js
+++ b/backend/src/modules/devices/service.js
@@ -8,6 +8,7 @@ import { logAudit } from '../../utils/audit.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { askStructured } from '../../utils/ai.js';
import * as repo from './repository.js';
+import { parseInWorker } from './ingest.js';
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
const STATUSES = ['active', 'retired'];
@@ -24,9 +25,24 @@ export const listDevices = (filters) => repo.findAll({
export async function getDevice(id) {
const device = await repo.findById(id);
if (!device) throw new HttpError(404, 'Equipo no encontrado');
+ if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device);
return device;
}
+// Equipos inventariados antes de la pestaña Licenciamiento: se sacan del ultimo reporte crudo una
+// sola vez (si falla queda vacio y se completa con el proximo reporte del agente).
+async function backfillLicenses(device) {
+ let licenses = { windows: null, office: [] };
+ try {
+ const full = path.join(env.storageRoot, 'inventories', path.basename(device.raw_file));
+ const text = await fs.promises.readFile(full, 'utf8');
+ const parsed = await parseInWorker(text, path.extname(full) === '.json' ? 'json' : 'xml');
+ licenses = parsed.normalized?.extra?.licenses || licenses;
+ } catch { /* sin crudo legible: queda vacio */ }
+ device.extra = { ...(device.extra || {}), licenses };
+ await repo.saveExtra(device.id, device.extra);
+}
+
export async function updateDevice(id, data, actor) {
const current = await getDevice(id);
const status = data.status ?? current.status;
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index ad76bc4..171a9df 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -497,6 +497,21 @@ export const messages = {
eq_before: 'Antes',
eq_after: 'Después',
eq_history_empty: 'Sin cambios registrados entre reportes.',
+ eq_tab_licenses: 'Licenciamiento',
+ eq_lic_windows: 'Windows',
+ eq_lic_office: 'Office',
+ eq_lic_office_installed: 'Office instalado',
+ eq_lic_edition: 'Edición',
+ eq_lic_product_id: 'Product ID',
+ eq_lic_key: 'Clave (parcial)',
+ eq_lic_channel: 'Canal',
+ eq_lic_owner: 'Registrado a',
+ eq_lic_oem: 'OEM',
+ eq_lic_not_oem: 'Retail / Volumen',
+ eq_lic_msdn: 'MSDN: solo desarrollo y pruebas',
+ eq_lic_no_windows: 'El agente no reportó licencia de Windows (equipo sin Windows o reporte anterior).',
+ eq_lic_no_office: 'El agente no reportó licencias de Office.',
+ eq_lic_hint: 'Datos que reporta el agente desde el registro: no indican si la licencia está activada. Microsoft 365 por suscripción se licencia por usuario y no aparece aquí. La clave se muestra parcial.',
eq_change_field: 'Dato modificado',
eq_change_disk_added: 'Disco agregado',
eq_change_disk_removed: 'Disco retirado',
@@ -1264,6 +1279,21 @@ export const messages = {
eq_before: 'Before',
eq_after: 'After',
eq_history_empty: 'No changes recorded between reports.',
+ eq_tab_licenses: 'Licensing',
+ eq_lic_windows: 'Windows',
+ eq_lic_office: 'Office',
+ eq_lic_office_installed: 'Installed Office',
+ eq_lic_edition: 'Edition',
+ eq_lic_product_id: 'Product ID',
+ eq_lic_key: 'Key (partial)',
+ eq_lic_channel: 'Channel',
+ eq_lic_owner: 'Registered to',
+ eq_lic_oem: 'OEM',
+ eq_lic_not_oem: 'Retail / Volume',
+ eq_lic_msdn: 'MSDN: development and testing only',
+ eq_lic_no_windows: 'The agent did not report a Windows license (non-Windows device or older report).',
+ eq_lic_no_office: 'The agent did not report Office licenses.',
+ eq_lic_hint: 'Data reported by the agent from the registry: it does not tell whether the license is activated. Microsoft 365 subscriptions are licensed per user and do not show here. The key is shown partially.',
eq_change_field: 'Field changed',
eq_change_disk_added: 'Disk added',
eq_change_disk_removed: 'Disk removed',
diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx
index 49cff07..2ed1d9f 100644
--- a/frontend/src/pages/Equipos/EquiposPage.jsx
+++ b/frontend/src/pages/Equipos/EquiposPage.jsx
@@ -125,6 +125,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
{ key: 'hardware', label: t('eq_tab_hardware') },
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
+ { key: 'licenses', label: t('eq_tab_licenses'), count: (device.extra?.licenses?.windows ? 1 : 0) + (device.extra?.licenses?.office?.length || 0) },
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
];
@@ -193,6 +194,17 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]);
+ const win = device.extra?.licenses?.windows;
+ const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
+ const winRows = win ? [
+ [t('eq_lic_edition'), val([win.edition, win.version].filter(Boolean).join(' ')), `${win.edition || ''} ${win.version || ''}`],
+ [t('eq_lic_product_id'), val(win.product_id), win.product_id],
+ [t('eq_lic_key'), {val(win.key)}, win.key],
+ [t('eq_lic_channel'), val(channel(win.oem)), channel(win.oem)],
+ [t('eq_lic_owner'), val([win.owner, win.company].filter(Boolean).join(' · ')), `${win.owner || ''} ${win.company || ''}`],
+ ] : [];
+ const officeLicenses = only(device.extra?.licenses?.office || [], (r) => [r.name, r.full_name, r.product_id, r.key]);
+ const officeInstalled = only(device.software.filter((r) => !r.is_component && /^microsoft (office|365)\b/i.test(r.name)), (r) => [r.name, r.version]);
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
return (
@@ -297,6 +309,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
]} />
)}
+ {tab === 'licenses' && (
+ <>
+
{dash(r.key)} },
+ ])}
+ {hwSection(t('eq_lic_office_installed'), officeInstalled, [
+ { key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') },
+ ])}
+