/** * Entity Resolver Service * Résout un nom d'entité (Name de l'AD ou TableName, insensible à la casse) * vers le TableName attendu par Context.{...} dans les requêtes LINQ (D21). * * Le mapping n'est PAS une pluralisation (Container -> Containers, mais * Alias -> Alias) : seul le TableName de l'API Metadata fait foi. Le contexte * de lecture étant commun au tenant, la table agrège le Metadata de toutes * les applications installées. */ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); // Cache state — même TTL que les autres caches (D10) let resolutionMap = null; // Map lower(Name | TableName) -> TableName let tableNames = null; // TableName[] triés (suggestions + comptage) let cacheTimestamp = null; const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // La table de résolution est par tenant — invalidée à chaque bascule (D8). profileManager.onSwitch(() => invalidateCache()); function isCacheValid() { if (!resolutionMap || !cacheTimestamp) return false; return Date.now() - cacheTimestamp < CACHE_TTL; } /** * Charge la table de résolution depuis l'API Metadata, agrégée sur toutes * les applications installées. * GET /configuration/applications ne liste que les applications déployées * avec une version — les applications EasyBuilder sans contexte requêtable * (CustomApp...) n'y figurent pas et ne fournissent de toute façon aucune * entité Metadata. */ async function loadResolutionMap() { if (isCacheValid()) return; console.error('[EntityResolver] Cache expired or empty, fetching Metadata...'); const apps = await apiService.get('/configuration/applications'); const appNames = (Array.isArray(apps) ? apps : []) .map(a => a.Name || a.name) .filter(Boolean); if (appNames.length === 0) { throw new Error('GET /configuration/applications returned no application'); } const map = new Map(); const names = new Set(); for (const app of appNames) { const entities = await apiService.getMetadataEntities(app); for (const e of (Array.isArray(entities) ? entities : [])) { const tableName = e.TableName || e.tableName; const name = e.Name || e.name; if (!tableName) continue; names.add(tableName); map.set(tableName.toLowerCase(), tableName); if (name) map.set(name.toLowerCase(), tableName); } } if (names.size === 0) { throw new Error('Metadata API returned no entity for any application'); } resolutionMap = map; tableNames = Array.from(names).sort(); cacheTimestamp = Date.now(); console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${appNames.length} application(s)`); } /** * Distance de Levenshtein — uniquement pour suggérer des noms proches. */ function levenshtein(a, b) { const m = a.length; const n = b.length; let prev = Array.from({ length: n + 1 }, (_, j) => j); for (let i = 1; i <= m; i++) { const curr = [i]; for (let j = 1; j <= n; j++) { curr[j] = Math.min( prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) ); } prev = curr; } return prev[n]; } /** * Suggère les TableName les plus proches d'un nom inconnu : * correspondances par sous-chaîne d'abord, puis distance d'édition. */ function suggestClosest(input, limit = 5) { const lower = input.toLowerCase(); const scored = tableNames.map(tn => { const l = tn.toLowerCase(); const score = (l.includes(lower) || lower.includes(l)) ? Math.abs(l.length - lower.length) // sous-chaîne : quasi-match : 100 + levenshtein(lower, l); // sinon : distance d'édition return { tn, score }; }); scored.sort((a, b) => a.score - b.score || a.tn.localeCompare(b.tn)); const maxEditDistance = Math.max(3, Math.floor(lower.length / 2)); return scored .filter(s => s.score < 100 + maxEditDistance) .slice(0, limit) .map(s => s.tn); } /** * Résout un nom d'entité vers son TableName. * * @param {string} entityType - Name AD ou TableName, insensible à la casse * @param {object} [options] * @param {boolean} [options.allowUnknown=false] - Un nom inconnu du Reading * passe tel quel avec un warning au lieu d'échouer. Utilisé quand * query_type != 0 (D25) : la table est construite sur le Metadata Reading, * or le modèle Writing/Metrics peut contenir des entités hors Reading. * @returns {Promise<{tableName: string, warning?: string}>} * - nom connu : { tableName } (le TableName exact) * - Metadata injoignable : { tableName: entityType, warning } — on laisse * passer le nom tel quel (comportement historique) plutôt que de tout * bloquer, et on le dit dans la réponse * @throws {Error} nom inconnu du modèle Reading (sauf allowUnknown) — AVANT * tout appel réseau de requête, avec suggestions proches et renvoi vers * get_entity_metadata */ async function resolveEntityType(entityType, options = {}) { const { allowUnknown = false } = options; if (!entityType || typeof entityType !== 'string' || entityType.trim() === '') { throw new Error('entity_type est requis. Utilisez get_entity_metadata pour la liste des entités interrogeables.'); } const trimmed = entityType.trim(); try { await loadResolutionMap(); } catch (err) { console.error(`[EntityResolver] Metadata unreachable, passing "${trimmed}" through as-is: ${err.message}`); return { tableName: trimmed, warning: `Le nom d'entité "${trimmed}" n'a pas pu être validé (API Metadata injoignable : ${err.message}). Il est transmis tel quel au WMS.`, }; } const tableName = resolutionMap.get(trimmed.toLowerCase()); if (tableName) { return { tableName }; } const suggestions = suggestClosest(trimmed); const closest = suggestions.length > 0 ? ` Proches : ${suggestions.join(', ')}.` : ''; if (allowUnknown) { console.error(`[EntityResolver] "${trimmed}" unknown to Reading metadata, passing through (allowUnknown)`); return { tableName: trimmed, warning: `"${trimmed}" est inconnu du modèle Reading (Metadata) ; il est transmis tel quel car query_type != 0 — le contexte demandé peut contenir des entités hors Reading.${closest}`, }; } throw new Error( `"${trimmed}" n'existe pas dans le modèle Reading.${closest} ` + `${tableNames.length} entités disponibles — utilisez get_entity_metadata pour la liste.` ); } /** * Invalide la table de résolution (bascule de profil). */ function invalidateCache() { resolutionMap = null; tableNames = null; cacheTimestamp = null; console.error('[EntityResolver] Cache cleared'); } /** * État du cache (exposé par get_application_summary si besoin). */ function getCacheStatus() { return { cached: resolutionMap !== null, count: tableNames ? tableNames.length : 0, timestamp: cacheTimestamp, age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null, valid: isCacheValid(), }; } module.exports = { resolveEntityType, invalidateCache, getCacheStatus, };