706e628715
QueryType était figé à 0 en dur dans executeQuery/executeScalarQuery : le modèle Writing, opérationnel et mesuré, était inatteignable. Paramètre query_type (défaut 0) sur call_query_api, query_wms_entities, count_wms_entities — get_entity_schema et search_wms_data restent des raccourcis Reading. D3 reste la règle par défaut : la bascule est un opt-in, avertie dans les descriptions d'outils (statuts en énumérations en Writing). Garde de valeur assertValidQueryType dans le code (le wrapper D23 ne valide pas les valeurs), avant tout réseau. En query_type != 0, un nom inconnu du Metadata Reading passe tel quel avec warning (allowUnknown du resolver) — conservé aussi dans la réponse d'erreur si le WMS échoue ensuite. Acté en D25 ; CLAUDE.md nuancé, L4.1 retiré de la ROADMAP (reliquat : exploration Metrics, rapport à part). Vérifications rejouées via le protocole (LIMAGRAIN / LIMAGRAI2512) : - call_query_api(Products, query_type:1, limit:1) -> success, 1 ligne, queryType:1 dans la réponse. - call_query_api(Products, query_type:3) -> erreur structurée contenant 'ApplicationMetricDataContext' ne contient pas de définition pour 'Products'. - call_query_api(Products, query_type:7) -> erreur locale nommant les 4 contextes, aucun [API] POST/GET dans stderr. - query_wms_entities(Container, limit:1) sans query_type -> comportement inchangé (resolvedTableName Containers, Reading, pas de champ queryType). - call_query_api(FooBar123, query_type:1) -> transmis tel quel (payload QueryType:1 Expression Context.FooBar123...), erreur WMS ApplicationWritingRepository + warning de résolution dans la réponse. - count_wms_entities(Product, query_type:1) -> count 51145 (~51160). Baseline : tools/list 23, resources 6, rejet D23 d'un paramètre inconnu OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
7.0 KiB
JavaScript
203 lines
7.0 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|