diff --git a/CLAUDE.md b/CLAUDE.md index f182e18..d1647f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,8 +205,10 @@ la plus fréquente : Autres règles : -- **`QueryType: 0` (Reading)**, jamais 1 : les statuts sont alors des chaînes - (D3). +- **`QueryType: 0` (Reading) par défaut** : les statuts sont alors des chaînes + (D3). La bascule vers Writing/Metrics passe par le paramètre `query_type` + des outils de requête — un opt-in documenté (D25), jamais un défaut : ne + recopiez aucun exemple en `QueryType: 1`. - **Pas de date relative.** `DateTime.Now`, `DateTime.Today`, `AddDays()` ne sont pas traduisibles : écrire `new DateTime(2026, 8, 1)` (D12). - **`select_expression` est instable** : les projections via le paramètre diff --git a/DECISIONS.md b/DECISIONS.md index 604287a..beceace 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -480,3 +480,42 @@ Deux garde-fous de cadrage : Au passage, `totalParameters` a changé de sens : c'était le nombre brut d'entités `Parameter` chargées, c'est désormais le total correspondant aux filtres avant pagination (identique sans filtre). + +--- + +## D25 — `query_type` : opt-in explicite, D3 reste la règle par défaut + +**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** `QueryType` était +figé à `0` en dur dans `executeQuery()` et `executeScalarQuery()`, rendant le +modèle Writing — opérationnel et mesuré (`Context.Products` en `QueryType: 1` +répond) — inatteignable. + +**Décision.** Un paramètre `query_type` (entier, défaut `0`) est exposé sur +**trois outils** : `call_query_api`, `query_wms_entities`, +`count_wms_entities`. `get_entity_schema` et `search_wms_data` restent des +raccourcis Reading, sans paramètre. + +**Rapport à D3.** D3 n'est pas révisée : le Reading reste la règle par défaut, +car en Writing les statuts sont des **énumérations** — les comparaisons de +chaînes (`== "Release"`), cas le plus courant en debug, y échouent. La bascule +est un opt-in explicite et les descriptions d'outils portent l'avertissement. + +Modalités : + +- **Garde de valeur dans le code de l'outil**, pas dans le wrapper : D23 valide + les noms de paramètres, pas les valeurs. Hors `0..3` (ou non entier) → + erreur locale via `assertValidQueryType()` (`wms-query-service.js`), **avant + tout appel réseau**, nommant les quatre contextes. +- **`2` et `3` sont transmis tels quels** : le WMS répond et son diagnostic + remonte entier (L1.1). Sur `LIMAGRAI2512` : `2` = DataWarehouse non configuré + (`Could not resolve serviceType 'IDataWarehouse…'`), `3` = Metrics, contexte + présent mais modèle distinct (`'ApplicationMetricDataContext' ne contient pas + de définition pour 'Products'`). +- **Interaction avec le resolver (D21)** : la table de résolution est + construite sur le Metadata **Reading**. Quand `query_type != 0`, un nom qui + se résout se résout normalement (`Products` marche en Writing, mesuré) ; un + nom **inconnu** du Reading n'est **pas** bloqué — il passe tel quel avec un + `warning` dans la réponse (`allowUnknown` du resolver, même mécanique que le + repli « Metadata injoignable »), car le modèle Writing/Metrics peut contenir + des entités hors Reading. Le `warning` est conservé aussi dans la réponse + d'erreur si le WMS échoue ensuite. diff --git a/ROADMAP.md b/ROADMAP.md index 59a9c48..37a519f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,25 +17,13 @@ contient que ce qui reste à faire. Deux angles morts constatés le 24/08/2026, plus larges que les lots 2 et 3. Les chiffres ci-dessous sont mesurés sur le tenant `LIMAGRAI2512`. -### L4.1 — Le modèle Writing est inatteignable +### L4.1 (reliquat) — Explorer le contexte Metrics -`QueryType` est figé à `0` (Reading) en dur dans `api-service.js` -(`executeQuery` et `executeScalarQuery`). Or `QueryContextType` a **quatre** -valeurs. Testées une à une : - -| Valeur | Contexte | Résultat sur `LIMAGRAI2512` | -|---|---|---| -| `0` | Reading | opérationnel (seul utilisé aujourd'hui) | -| `1` | Writing | **opérationnel** — `Context.Products` répond | -| `2` | DataWarehouse | **non configuré** : `Could not resolve serviceType 'IDataWarehouse…'` | -| `3` | Metrics | contexte présent (`ApplicationMetricDataContext`), modèle non exploré | - -Exposer `query_type` sur les outils de requête, défaut `0`. Attention : D3 reste -vrai — en Writing les champs de statut sont des **énumérations**, donc -`== "Release"` échoue. La bascule doit être un choix explicite et documenté. - -Le contexte `Metrics` mérite une exploration à part : c'est probablement là que -vivent les données agrégées produites par les jobs `MetricGatherer`. +L'exposition de `query_type` est livrée (D25). Reste l'investigation : le +contexte `Metrics` (`QueryType: 3`, `ApplicationMetricDataContext`) mérite une +exploration à part — c'est probablement là que vivent les données agrégées +produites par les jobs `MetricGatherer`. Livrable : un rapport, pas du code +(même phase d'investigation que L4.4). ### L4.2 — Une seule application sur neuf est visible diff --git a/src/resources/apis.js b/src/resources/apis.js index f5430c3..c06a93f 100644 --- a/src/resources/apis.js +++ b/src/resources/apis.js @@ -75,7 +75,7 @@ Rules (see the query tools for details): - **\`QueryType: 0\` (Reading) is the default** — status fields are strings (\`"Release"\`). \`QueryType: 1\` (Writing) exists but status fields become enums there: string comparisons fail. Old examples using \`1\` must not be - copied. + copied. The query tools expose this as the opt-in \`query_type\` parameter. - **\`Where\` and \`OrderBy\` go in the Expression; \`Take\`/\`Skip\` are API parameters.** \`OrderBy\` is mandatory as soon as \`Take\` is used. - **No \`Select\` projections** — the \`Select\` parameter causes server-side diff --git a/src/services/api-service.js b/src/services/api-service.js index bf45e1b..06a0b76 100644 --- a/src/services/api-service.js +++ b/src/services/api-service.js @@ -323,14 +323,17 @@ class APIService { * e.g. "Context.OutboundOrders.Where(z => z.OutboundOrderStatus == \"Release\").OrderBy(z => z.Id)" * * @param {string} expression - LINQ expression (Context.Entity or Context.Entity.Where(...)) - * @param {object} options - { take, skip, select, orderBy, inlineCount } + * @param {object} options - { take, skip, select, orderBy, inlineCount, queryType } */ async executeQuery(expression, options = {}) { - const { take, skip, select, orderBy, inlineCount } = options; + const { take, skip, select, orderBy, inlineCount, queryType } = options; const body = { Application: profileManager.getCurrent().application, - QueryType: 0, // Reading = 0 (status fields are strings), Writing = 1 (enums) + // Reading = 0 par défaut (statuts en chaînes) ; Writing/Metrics en + // opt-in explicite via query_type (D25) — la garde de valeur vit dans + // wms-query-service.assertValidQueryType, pas ici. + QueryType: queryType ?? 0, Expression: expression, }; @@ -363,11 +366,13 @@ class APIService { * Execute a scalar LINQ query (Count, Sum, etc.) via QueryScalarExecute. * Returns the scalar value directly. * @param {string} fullExpression - e.g. "Context.OutboundOrders.Where(...).Count()" + * @param {object} options - { queryType } */ - async executeScalarQuery(fullExpression) { + async executeScalarQuery(fullExpression, options = {}) { const body = { Application: profileManager.getCurrent().application, - QueryType: 0, // Reading = 0 — string enum names in filters (Writing=1 fails with enum comparisons) + // Reading = 0 par défaut — voir executeQuery / D25. + QueryType: options.queryType ?? 0, Expression: fullExpression, }; diff --git a/src/services/entity-resolver.js b/src/services/entity-resolver.js index b8c167a..035892b 100644 --- a/src/services/entity-resolver.js +++ b/src/services/entity-resolver.js @@ -119,15 +119,22 @@ function suggestClosest(input, limit = 5) { * 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 — AVANT tout appel réseau de - * requête, avec suggestions proches et renvoi vers get_entity_metadata + * @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) { +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.'); } @@ -150,6 +157,15 @@ async function resolveEntityType(entityType) { 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.` diff --git a/src/services/wms-query-service.js b/src/services/wms-query-service.js index 7d360ce..8388e4a 100644 --- a/src/services/wms-query-service.js +++ b/src/services/wms-query-service.js @@ -6,6 +6,27 @@ const apiService = require('./api-service').getInstance(); const entityResolver = require('./entity-resolver'); +/** + * Garde de valeur de query_type (D25). Le wrapper D23 valide les noms de + * paramètres, pas les valeurs — cette garde s'exécute AVANT tout appel réseau + * (y compris la résolution d'entité) et nomme les quatre contextes. + * @param {*} queryType - valeur reçue de l'outil (défaut 0 si absent) + * @returns {number} la valeur validée + */ +function assertValidQueryType(queryType) { + if (queryType == null) return 0; + if (!Number.isInteger(queryType) || queryType < 0 || queryType > 3) { + throw new Error( + `query_type invalide : ${JSON.stringify(queryType)}. Valeurs acceptées : ` + + `0 = Reading (défaut — statuts en chaînes, ex. "Release"), ` + + `1 = Writing (statuts en énumérations : les comparaisons de chaînes échouent), ` + + `2 = DataWarehouse (souvent non configuré), ` + + `3 = Metrics (modèle de données distinct).` + ); + } + return queryType; +} + /** * Build a LINQ select expression * @param {string} entityType - Entity type (Products, Containers, etc.) @@ -19,11 +40,19 @@ const entityResolver = require('./entity-resolver'); * @param {string} selectExpression - LINQ select expression * @param {string|null} filter - Optional filter * @param {number} limit - Result limit + * @param {number} queryType - QueryContextType (0 = Reading par défaut, D25) */ -async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) { +async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100, queryType = 0) { + // Garde de valeur avant tout réseau (D25). + queryType = assertValidQueryType(queryType); + // 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); + // En query_type != 0, un nom hors Reading passe tel quel avec warning : le + // modèle Writing/Metrics peut contenir des entités hors Reading (D25). + const { tableName, warning } = await entityResolver.resolveEntityType(entityType, { + allowUnknown: queryType !== 0, + }); try { // Enforce max limit @@ -39,17 +68,19 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n // OrderBy must be embedded in the expression (not as a separate API param) expression += `.OrderBy(z => z.Id)`; - console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression}`); + console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression} queryType=${queryType}`); const result = await apiService.executeQuery(expression, { take: actualLimit, select: selectExpression !== 'z => z' ? selectExpression : undefined, + queryType, }); return { entityType, resolvedTableName: tableName, ...(warning ? { warning } : {}), + ...(queryType !== 0 ? { queryType } : {}), expression, limit: actualLimit, count: Array.isArray(result) ? result.length : 0, @@ -57,7 +88,9 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n }; } catch (error) { console.error(`[WMSQuery] Query failed:`, error.message); - throw new Error(`Query failed for ${entityType}: ${error.message}`); + // Le warning de résolution (nom hors Reading en query_type != 0) reste + // visible même quand le WMS échoue ensuite. + throw new Error(`Query failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`); } } @@ -149,11 +182,17 @@ async function getEntitySchema(entityType) { * Count entities with optional filter * @param {string} entityType - Entity type * @param {string|null} filter - Optional filter + * @param {number} queryType - QueryContextType (0 = Reading par défaut, D25) */ -async function countEntities(entityType, filter = null) { +async function countEntities(entityType, filter = null, queryType = 0) { + // Garde de valeur avant tout réseau (D25). + queryType = assertValidQueryType(queryType); + // Résolution Name/TableName -> TableName (D21) — échec avant appel réseau - // sur nom inconnu. - const { tableName, warning } = await entityResolver.resolveEntityType(entityType); + // sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25). + const { tableName, warning } = await entityResolver.resolveEntityType(entityType, { + allowUnknown: queryType !== 0, + }); try { // Build: Context.Entity.Where(...).Count() @@ -165,20 +204,21 @@ async function countEntities(entityType, filter = null) { parts.push('Count()'); const fullExpression = parts.join('.'); - console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression}`); + console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression} | queryType=${queryType}`); - const count = await apiService.executeScalarQuery(fullExpression); + const count = await apiService.executeScalarQuery(fullExpression, { queryType }); return { entityType, resolvedTableName: tableName, ...(warning ? { warning } : {}), + ...(queryType !== 0 ? { queryType } : {}), filter, count }; } catch (error) { console.error(`[WMSQuery] Count failed:`, error.message); - throw new Error(`Count failed for ${entityType}: ${error.message}`); + throw new Error(`Count failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`); } } @@ -188,4 +228,5 @@ module.exports = { searchEntities, getEntitySchema, countEntities, + assertValidQueryType, }; diff --git a/src/tools/api-tools.js b/src/tools/api-tools.js index 5cec3bd..5e6a691 100644 --- a/src/tools/api-tools.js +++ b/src/tools/api-tools.js @@ -1,5 +1,6 @@ const apiService = require('../services/api-service').getInstance(); const entityResolver = require('../services/entity-resolver'); +const { assertValidQueryType } = require('../services/wms-query-service'); /** * Tools MCP pour interagir avec les APIs WMS @@ -36,6 +37,11 @@ function listTools() { description: 'Limite de résultats (défaut: 100)', default: 100, }, + query_type: { + type: 'number', + description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning.', + default: 0, + }, }, required: ['entity_type'], }, @@ -85,12 +91,23 @@ async function executeTool(name, args) { * Tool: call_query_api */ async function callQueryAPI(args) { - const { entity_type, expression = 'z => z', filter, limit = 100 } = args; + const { entity_type, expression = 'z => z', filter, limit = 100, query_type = 0 } = args; + + // Conservé hors du try : si la requête échoue ensuite côté WMS, le warning + // de résolution (nom hors Reading) reste dans la réponse d'erreur. + let resolution = null; try { + // Garde de valeur avant tout réseau (D25) — le wrapper D23 ne valide pas + // les valeurs. + const queryType = assertValidQueryType(query_type); + // Résolution Name/TableName -> TableName (D21) — échec avant appel réseau - // sur nom inconnu. - const { tableName, warning } = await entityResolver.resolveEntityType(entity_type); + // sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25). + resolution = await entityResolver.resolveEntityType(entity_type, { + allowUnknown: queryType !== 0, + }); + const { tableName, warning } = resolution; // Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used) let linqExpression = `Context.${tableName}`; @@ -103,6 +120,7 @@ async function callQueryAPI(args) { const result = await apiService.executeQuery(linqExpression, { take: limit || undefined, select: expression !== 'z => z' ? expression : undefined, + queryType, }); return { @@ -115,6 +133,7 @@ async function callQueryAPI(args) { entityType: entity_type, resolvedTableName: tableName, ...(warning ? { warning } : {}), + ...(queryType !== 0 ? { queryType } : {}), result, }, null, @@ -132,6 +151,7 @@ async function callQueryAPI(args) { { success: false, error: err.message, + ...(resolution?.warning ? { warning: resolution.warning } : {}), }, null, 2 diff --git a/src/tools/wms-query-tools.js b/src/tools/wms-query-tools.js index 38b9dbb..a14eef7 100644 --- a/src/tools/wms-query-tools.js +++ b/src/tools/wms-query-tools.js @@ -13,7 +13,7 @@ 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). +Uses QueryExecute with QueryType=Reading by default — status fields are STRINGS (enum names, not integers). Other contexts via query_type (opt-in, see the parameter warning). 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. @@ -44,6 +44,11 @@ Never guess enum string values — they differ between Reading and Writing model description: 'Maximum results to return (default: 100, max: 1000)', default: 100, }, + query_type: { + type: 'number', + description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.', + default: 0, + }, }, required: ['entity_type'], }, @@ -96,6 +101,11 @@ Verified values (curl-tested): type: 'string', description: 'Optional LINQ filter condition. Status fields are strings (enum names from Reading model). Always verify enum values via docs://entities/ before use.', }, + query_type: { + type: 'number', + description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.', + default: 0, + }, }, required: ['entity_type'], }, @@ -169,15 +179,16 @@ async function executeTool(name, args) { * Tool: query_wms_entities */ async function queryWmsEntities(args) { - const { entity_type, select_expression = 'z => z', filter, limit = 100 } = args; + const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args; - console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit}`); + console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit} query_type=${query_type}`); const result = await wmsQueryService.queryEntities( entity_type, select_expression, filter, - limit + limit, + query_type ); return { @@ -195,11 +206,11 @@ async function queryWmsEntities(args) { * Tool: count_wms_entities */ async function countWmsEntities(args) { - const { entity_type, filter } = args; + const { entity_type, filter, query_type = 0 } = args; - console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''}`); + console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''} query_type=${query_type}`); - const result = await wmsQueryService.countEntities(entity_type, filter || null); + const result = await wmsQueryService.countEntities(entity_type, filter || null, query_type); return { content: [{