L4.1 : expose query_type sur les outils de requête (D25)

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>
This commit is contained in:
Arthur Ria
2026-08-25 11:17:10 +02:00
parent 88dab289cc
commit 706e628715
9 changed files with 171 additions and 49 deletions
+4 -2
View File
@@ -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
+39
View File
@@ -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.
+6 -18
View File
@@ -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
+1 -1
View File
@@ -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
+10 -5
View File
@@ -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,
};
+19 -3
View File
@@ -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.`
+51 -10
View File
@@ -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,
};
+23 -3
View File
@@ -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
+18 -7
View File
@@ -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: [{