diff --git a/CLAUDE.md b/CLAUDE.md index 3aa6b76..6686729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ src/ │ └── logs.js logs://guide — patterns d'erreur et scénarios de debug ├── services/ Logique métier │ ├── api-service.js OAuth + client HTTP + helpers de requête (singleton) +│ ├── entity-resolver.js Résolution Name|TableName -> TableName (D21) │ ├── workflow-service.js Workflows, lazy loading + cache │ ├── ad-service.js Application Dictionary, 20 types, cache par type │ ├── wms-query-service.js Construction d'expressions LINQ @@ -219,11 +220,14 @@ Autres règles : ## Entités et éléments AD -**Entités interrogeables** (Query API) : `Products`, `Containers`, `Accounts`, -`Suppliers`, `Kits`, `Aliases`, `Tasks`, `Stocks`, `ProductLocations`, -`InboundOrders`, `Receptions`, `OutboundOrders`. La liste faisant foi s'obtient -par `get_entity_metadata` (API Metadata) — le catalogue de la resource -`wms://entities` est un raccourci de confort, pas la référence. +**Entités interrogeables** (Query API) : `entity_type` accepte le nom d'entité +AD (`Container`) ou le `TableName` (`Containers`), insensible à la casse — la +résolution passe par `entity-resolver.js` (D21). Courantes : `Products`, +`Containers`, `Accounts`, `Suppliers`, `Kits`, `Alias` (invariant, pas de +pluriel), `Tasks`, `Stocks`, `ProductLocations`, `InboundOrders`, `Receptions`, +`OutboundOrders`. La liste faisant foi (288 entités, toutes applications +confondues) s'obtient par `get_entity_metadata` (API Metadata) — le catalogue +de la resource `wms://entities` est un raccourci de confort, pas la référence. **Application Dictionary** : 20 types, ~38 800 éléments. `Resource` (29 374) est de loin le plus lourd ; 3 types sont valides mais vides (`Dashboard`, @@ -278,14 +282,5 @@ printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{" ## Points ouverts -Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés, cause racine -commune (résolution `Name` -> `TableName` des entités), et propositions +Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés et propositions explicitement écartées. - -⚠️ Piège connu et non encore corrigé, à garder en tête en attendant le lot 2 : - -- `entity_type` est interpolé sans validation dans `Context.{entity_type}`. Le - nom attendu est le `TableName` de l'API Metadata, pas le nom d'entité de l'AD - (`Container` -> `Containers`, mais `Alias` -> `Alias`). Un mauvais nom donne un - HTTP 500 — dont le détail (erreur de compilation LINQ) remonte désormais dans - la réponse de l'outil (L1.1). diff --git a/DECISIONS.md b/DECISIONS.md index cd548d6..223b978 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -353,6 +353,41 @@ publication du dépôt. --- +## D21 — `Context.{...}` attend le `TableName` du Metadata, résolu par service + +**Piège.** Les expressions LINQ de `QueryExecute` référencent les entités par +le `TableName` de l'API Metadata, **pas** par le nom d'entité de l'Application +Dictionary. Ce n'est pas une pluralisation : `Container` -> `Containers`, mais +`Alias` -> `Alias` (invariant), et `Item` n'existe pas. Un nom faux part en +HTTP 500 (erreur de compilation `'ApplicationReadingContext' ne contient pas +de définition pour '...'`). Seul `TableName` fait foi — **ne réinventez pas de +règle grammaticale**. + +**Décision.** `src/services/entity-resolver.js` construit une table +`Name | TableName (insensible à la casse) -> TableName` et tous les points +d'interpolation (`wms-query-service`, `call_query_api`) passent par elle. +Mesures du 24/08/2026 (`LIMAGRAI2512`) : + +- Le contexte de lecture est **commun au tenant** : la table agrège le + Metadata de toutes les applications. La liste vient de + `GET /configuration/applications` (5 applications déployées avec version) — + les applications EasyBuilder sans contexte requêtable (`CustomApp`…) n'y + figurent pas et ne fournissent de toute façon **0 entité** Metadata. +- 288 `TableName` distincts, aucun conflit `Name -> TableName` entre + applications. + +Comportements : + +- **Nom inconnu** : échec avant tout appel réseau de requête, message avec + suggestions proches et renvoi vers `get_entity_metadata`. +- **Metadata injoignable** : le nom passe tel quel (comportement historique) + et la réponse porte un `warning` — on ne bloque pas tout le serveur pour un + cache irrécupérable. +- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux, + invalidation par abonnement `onSwitch()` (D8, D10). + +--- + ## D22 — Routage des outils par table explicite, plus par préfixe de nom **Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom diff --git a/ROADMAP.md b/ROADMAP.md index abfb7fe..f6724a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,51 +12,10 @@ contient que ce qui reste à faire. --- -## Cause racine commune - -Le MCP interpole `entity_type` dans `Context.{entity_type}` **sans aucune -validation** (vérifié : aucune liste blanche dans le code). Or le nom attendu -par le contexte de lecture n'est pas le nom d'entité de l'Application -Dictionary. - -L'API Metadata (`GET /Metadata/Entities`, **232 entités**) donne la -correspondance exacte : - -| `Name` (renvoyé par `search_ad_elements`) | `TableName` (attendu par `Context.`) | -|---|---| -| `Container` | `Containers` | -| `Product` | `Products` | -| `ContainerType` | `ContainerTypes` | -| `Alias` | `Alias` — **invariant, pas de pluriel** | -| `Item` | *n'existe pas dans le modèle Reading* | - -Ce n'est donc pas une règle de pluralisation : c'est un mapping, et seul -`TableName` fait foi. `TableName` est unique sur les 232 entités. - -Conséquences déjà constatées : -- une session utilisant les noms de l'AD (singuliers) déclenche un **HTTP 500** - sur chaque requête ; -- la liste d'entités documentée était fausse (`Aliases` n'existe pas, c'est - `Alias`) ; -- le MCP n'expose que 12 entités figées là où l'API en connaît 232. - ---- - ## Lot 2 — Correctif de fond -### L2.1 — Résolution des entités via l'API Metadata - -Accepter `entity_type` au nom d'entité (`Container`) ou au nom de jeu -(`Containers`), insensible à la casse, et émettre `Context.{TableName}`. Cache -identique aux autres (TTL partagé, invalidation au changement de profil). - -Sur nom inconnu, échouer **avant tout appel réseau**, avec un message -actionnable : - -> « Item » n'existe pas dans le modèle Reading. Proches : ItemGroup, StockItem. -> 232 entités disponibles — utilisez `get_entity_metadata` pour la liste. - -Supprime la cause des 500 et débloque 232 entités au lieu de 12. +La cause racine commune du lot (résolution `Name` -> `TableName` via l'API +Metadata) est livrée — la règle et ses mesures vivent en **D21**. ### L2.2 — Rejeter les paramètres inconnus @@ -114,14 +73,6 @@ diagnostic exact, invisible depuis les outils comme depuis le smoke test. Faire remonter `error.response.data` dans le message, comme L1.1 l'a fait pour les outils. Même contrainte : ne jamais logguer les credentials. -### L3.3 — Documentation - -- DECISIONS.md : **D21** la règle `TableName` (D22, le routage par table - explicite, a été livrée avec le lot 1). -- CLAUDE.md : corriger la liste d'entités (`Aliases` → `Alias`) et renvoyer vers - `get_entity_metadata` comme source de vérité. -- `wms://query-examples` : un exemple singulier/pluriel commenté. - --- ## Lot 4 — Modèle de données et applications @@ -186,9 +137,8 @@ applications). `Application: "AGV"` qu'avec `Application: "EasyWMS"`. Le contexte de lecture est **commun au tenant** : toutes les applications y déversent leurs entités. -Conséquence pour L2.1 : la table de résolution doit **agréger le Metadata de -toutes les applications** (`GET /Metadata/Entities?applicationName=…` par -application, 232 + 20 + 6 + …), et non se limiter à `EasyWMS`. Inutile en +Conséquence — traitée : la table de résolution (D21) **agrège le Metadata de +toutes les applications** déployées, et non le seul `EasyWMS`. Inutile en revanche d'ajouter un paramètre `application` à `QueryExecute` : il ne changerait rien. diff --git a/src/resources/query-examples.js b/src/resources/query-examples.js index 1cfa771..6be893b 100644 --- a/src/resources/query-examples.js +++ b/src/resources/query-examples.js @@ -42,6 +42,20 @@ function getQueryExamples() { > (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers. > Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering. +## Entity names — singular AD name or TableName, both accepted + +\`entity_type\` is resolved case-insensitively against the Metadata API: the AD +entity name (singular) and the TableName both work. The mapping is **not** a +pluralisation rule — only the Metadata \`TableName\` is authoritative: + +\`\`\` +query_wms_entities(entity_type="Container") # AD name -> resolved to Containers +query_wms_entities(entity_type="Containers") # TableName -> used as-is +query_wms_entities(entity_type="Alias") # invariant: TableName IS "Alias" (no plural) +query_wms_entities(entity_type="Item") # fails fast: not in the Reading model, + # error lists close matches + get_entity_metadata +\`\`\` + --- ## Diagnostic Recipes diff --git a/src/services/entity-resolver.js b/src/services/entity-resolver.js new file mode 100644 index 0000000..bc8bc2a --- /dev/null +++ b/src/services/entity-resolver.js @@ -0,0 +1,186 @@ +/** + * 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.size} 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 + * @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 — AVANT tout appel réseau de + * requête, avec suggestions proches et renvoi vers get_entity_metadata + */ +async function resolveEntityType(entityType) { + 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(', ')}.` : ''; + 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, +}; diff --git a/src/services/wms-query-service.js b/src/services/wms-query-service.js index f251070..7d360ce 100644 --- a/src/services/wms-query-service.js +++ b/src/services/wms-query-service.js @@ -4,6 +4,7 @@ */ const apiService = require('./api-service').getInstance(); +const entityResolver = require('./entity-resolver'); /** * Build a LINQ select expression @@ -20,13 +21,17 @@ const apiService = require('./api-service').getInstance(); * @param {number} limit - Result limit */ async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) { + // Résolution Name/TableName -> TableName (D21). Un nom inconnu échoue ici, + // avant tout appel réseau de requête — l'erreur porte les suggestions. + const { tableName, warning } = await entityResolver.resolveEntityType(entityType); + try { // Enforce max limit const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000; const actualLimit = Math.min(limit, maxLimit); // Build expression: Context + optional Where + OrderBy (required by EF when Take is used) - let expression = `Context.${entityType}`; + let expression = `Context.${tableName}`; if (filter) { const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`; expression += `.Where(${whereExpr})`; @@ -43,6 +48,8 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n return { entityType, + resolvedTableName: tableName, + ...(warning ? { warning } : {}), expression, limit: actualLimit, count: Array.isArray(result) ? result.length : 0, @@ -144,9 +151,13 @@ async function getEntitySchema(entityType) { * @param {string|null} filter - Optional filter */ async function countEntities(entityType, filter = null) { + // Résolution Name/TableName -> TableName (D21) — échec avant appel réseau + // sur nom inconnu. + const { tableName, warning } = await entityResolver.resolveEntityType(entityType); + try { // Build: Context.Entity.Where(...).Count() - const parts = [`Context.${entityType}`]; + const parts = [`Context.${tableName}`]; if (filter) { const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`; parts.push(`Where(${whereExpr})`); @@ -160,6 +171,8 @@ async function countEntities(entityType, filter = null) { return { entityType, + resolvedTableName: tableName, + ...(warning ? { warning } : {}), filter, count }; diff --git a/src/tools/api-tools.js b/src/tools/api-tools.js index 57f646d..5fcd000 100644 --- a/src/tools/api-tools.js +++ b/src/tools/api-tools.js @@ -1,4 +1,5 @@ const apiService = require('../services/api-service').getInstance(); +const entityResolver = require('../services/entity-resolver'); /** * Tools MCP pour interagir avec les APIs WMS @@ -18,7 +19,7 @@ function listTools() { properties: { entity_type: { type: 'string', - description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)', + description: 'Type d\'entité — nom AD (Container) ou TableName (Containers), insensible à la casse, résolu via l\'API Metadata. Ex: Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Alias, InboundOrders, Receptions, OutboundOrders. Liste complète via get_entity_metadata.', }, expression: { type: 'string', @@ -85,8 +86,12 @@ async function callQueryAPI(args) { const { entity_type, expression = 'z => z', filter, limit = 100 } = args; try { + // Résolution Name/TableName -> TableName (D21) — échec avant appel réseau + // sur nom inconnu. + const { tableName, warning } = await entityResolver.resolveEntityType(entity_type); + // Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used) - let linqExpression = `Context.${entity_type}`; + let linqExpression = `Context.${tableName}`; if (filter) { const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`; linqExpression += `.Where(${whereExpr})`; @@ -106,6 +111,8 @@ async function callQueryAPI(args) { { success: true, entityType: entity_type, + resolvedTableName: tableName, + ...(warning ? { warning } : {}), result, }, null, diff --git a/src/tools/wms-query-tools.js b/src/tools/wms-query-tools.js index ed9fbdd..7c08f19 100644 --- a/src/tools/wms-query-tools.js +++ b/src/tools/wms-query-tools.js @@ -14,7 +14,8 @@ function listTools() { name: 'query_wms_entities', description: `Query WMS entities using LINQ expressions. Returns rows (up to 1000). Uses QueryExecute with QueryType=Reading — status fields are STRINGS (enum names, not integers). -Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Location, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Aliases. +Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Locations, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Alias. Full list via get_entity_metadata. +entity_type accepts the AD entity name (Container) or the TableName (Containers), case-insensitive — resolved via the Metadata API. IMPORTANT — before building a filter with a status/enum field: 1. Check docs first: read resource docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders) @@ -26,7 +27,7 @@ Never guess enum string values — they differ between Reading and Writing model properties: { entity_type: { type: 'string', - description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)', + description: 'Entity type — AD name (Container) or TableName (Containers), case-insensitive, resolved via the Metadata API. E.g. Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Alias, Receptions.', }, select_expression: { type: 'string',