version mise à jour par claude , à tester
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Constants for WMS MCP Server
|
||||
*/
|
||||
|
||||
// WMS Entity Types available via Query API
|
||||
// Based on queries api.php reference
|
||||
const WMS_ENTITY_TYPES = [
|
||||
// Master Data
|
||||
'Products',
|
||||
'Containers',
|
||||
'Accounts',
|
||||
'Suppliers',
|
||||
'Kits',
|
||||
'Aliases',
|
||||
|
||||
// Operations
|
||||
'Tasks',
|
||||
'Stocks',
|
||||
'ProductLocations',
|
||||
|
||||
// Inbound
|
||||
'InboundOrders',
|
||||
'Receptions',
|
||||
|
||||
// Outbound
|
||||
'OutboundOrders'
|
||||
];
|
||||
|
||||
// Entity Categories for documentation
|
||||
const ENTITY_CATEGORIES = {
|
||||
'Master Data': ['Products', 'Containers', 'Accounts', 'Suppliers', 'Kits', 'Aliases'],
|
||||
'Operations': ['Tasks', 'Stocks', 'ProductLocations'],
|
||||
'Inbound': ['InboundOrders', 'Receptions'],
|
||||
'Outbound': ['OutboundOrders']
|
||||
};
|
||||
|
||||
// Common WMS Commands
|
||||
// These can be used with call_command_api tool
|
||||
const WMS_COMMANDS = {
|
||||
'ProductRemove': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand',
|
||||
'ProductUpdate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductUpdateCommand',
|
||||
'ContainerCreate': 'Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ContainerCreateCommand',
|
||||
'TaskCancel': 'Mecalux.ITSW.EasyWMS.Modules.Tasks.Contracts.Commands.TaskCancelCommand'
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
// MCP Resource URIs
|
||||
const RESOURCE_URIS = {
|
||||
WMS_ENTITIES: 'wms://entities',
|
||||
ENTITY_SCHEMAS: 'wms://entity-schemas',
|
||||
QUERY_EXAMPLES: 'wms://query-examples',
|
||||
WORKFLOWS_OVERVIEW: 'workflows://overview',
|
||||
WORKFLOWS_CATEGORIES: 'workflows://categories',
|
||||
API_CATALOG: 'api://catalog',
|
||||
LOGS_GUIDE: 'logs://guide'
|
||||
};
|
||||
|
||||
// Log patterns for error detection
|
||||
const LOG_ERROR_PATTERNS = [
|
||||
'ERROR',
|
||||
'EXCEPTION',
|
||||
'FATAL',
|
||||
'CRITICAL',
|
||||
'FAILED',
|
||||
'FAILURE',
|
||||
'WARNING'
|
||||
];
|
||||
|
||||
// Query limits
|
||||
const QUERY_LIMITS = {
|
||||
MAX_ROWS: parseInt(process.env.MAX_QUERY_ROWS) || 1000,
|
||||
DEFAULT_LIMIT: 100,
|
||||
TIMEOUT_MS: parseInt(process.env.QUERY_TIMEOUT) || 30000
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
WMS_ENTITY_TYPES,
|
||||
ENTITY_CATEGORIES,
|
||||
WMS_COMMANDS,
|
||||
RESOURCE_URIS,
|
||||
LOG_ERROR_PATTERNS,
|
||||
QUERY_LIMITS
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Profile Manager
|
||||
* Handles multiple WMS profiles (AD, LIMAGRAIN, ...) defined in .env.
|
||||
*
|
||||
* Shared settings (same for all profiles):
|
||||
* - WMS_API_AUTH OAuth client Basic auth header
|
||||
* - WMS_APPLICATION Application name (e.g. "EasyWMS")
|
||||
* - WMS_API_PATH URL path to the ApplicationService API
|
||||
* - WMS_TOKEN_PATH URL path to the OAuth token endpoint
|
||||
* - WORKFLOW_API_PATH URL path to the AD API
|
||||
*
|
||||
* Per-profile settings (prefixed by profile name):
|
||||
* - <PROFILE>_HOST Hostname or IP (e.g. "10.255.255.2", "p4swms.mss.mecalux.com")
|
||||
* - <PROFILE>_USERNAME
|
||||
* - <PROFILE>_PASSWORD
|
||||
* - <PROFILE>_TENANT
|
||||
* - <PROFILE>_SAAS "true" | "false" (default false). When true the WMS is
|
||||
* cloud-hosted — local filesystem logs are not reachable,
|
||||
* so log tools return an error instead of probing paths.
|
||||
*
|
||||
* Profile registry:
|
||||
* - WMS_PROFILES Comma-separated list of profile names (e.g. "AD,LIMAGRAIN")
|
||||
* - DEFAULT_WMS_PROFILE Profile activated at startup (optional)
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
apiPath: '/ApplicationService/api',
|
||||
tokenPath: '/EasySTS/OAuth/Token',
|
||||
workflowApiPath: '/AD/api',
|
||||
application: 'EasyWMS',
|
||||
protocol: 'https',
|
||||
};
|
||||
|
||||
const listeners = [];
|
||||
let profiles = {};
|
||||
let currentProfileName = null;
|
||||
|
||||
/**
|
||||
* Parse .env into an in-memory profile registry.
|
||||
* Called once at startup; safe to call again after env changes (tests).
|
||||
*/
|
||||
function loadProfiles() {
|
||||
profiles = {};
|
||||
currentProfileName = null;
|
||||
|
||||
const names = (process.env.WMS_PROFILES || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (names.length === 0) {
|
||||
console.error('[Profile] WARNING: WMS_PROFILES is empty — no profiles loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const shared = {
|
||||
auth: process.env.WMS_API_AUTH,
|
||||
application: process.env.WMS_APPLICATION || DEFAULTS.application,
|
||||
apiPath: process.env.WMS_API_PATH || DEFAULTS.apiPath,
|
||||
tokenPath: process.env.WMS_TOKEN_PATH || DEFAULTS.tokenPath,
|
||||
workflowApiPath: process.env.WORKFLOW_API_PATH || DEFAULTS.workflowApiPath,
|
||||
};
|
||||
|
||||
for (const name of names) {
|
||||
const host = process.env[`${name}_HOST`];
|
||||
const username = process.env[`${name}_USERNAME`];
|
||||
const password = process.env[`${name}_PASSWORD`];
|
||||
const tenant = process.env[`${name}_TENANT`];
|
||||
const saasRaw = process.env[`${name}_SAAS`];
|
||||
const saas = saasRaw != null && /^(true|1|yes)$/i.test(saasRaw.trim());
|
||||
|
||||
if (!host || !username || !password || !tenant) {
|
||||
console.error(`[Profile] WARNING: profile "${name}" is incomplete (missing HOST/USERNAME/PASSWORD/TENANT) — skipped`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = `${DEFAULTS.protocol}://${host}`;
|
||||
profiles[name] = {
|
||||
name,
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
tenant,
|
||||
saas,
|
||||
auth: shared.auth,
|
||||
application: shared.application,
|
||||
apiBaseUrl: `${base}${shared.apiPath}`,
|
||||
tokenUrl: `${base}${shared.tokenPath}`,
|
||||
workflowApiBase: `${base}${shared.workflowApiPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultName = process.env.DEFAULT_WMS_PROFILE;
|
||||
if (defaultName && profiles[defaultName]) {
|
||||
currentProfileName = defaultName;
|
||||
console.error(`[Profile] Loaded ${Object.keys(profiles).length} profile(s). Active: ${currentProfileName}`);
|
||||
} else if (defaultName) {
|
||||
console.error(`[Profile] DEFAULT_WMS_PROFILE="${defaultName}" is not a valid profile — no profile active`);
|
||||
} else {
|
||||
console.error(`[Profile] Loaded ${Object.keys(profiles).length} profile(s). No default selected — Claude must call switch_wms_profile before any API call`);
|
||||
}
|
||||
}
|
||||
|
||||
function listProfiles() {
|
||||
return Object.keys(profiles).sort();
|
||||
}
|
||||
|
||||
function getCurrentName() {
|
||||
return currentProfileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the active profile config. Throws a helpful error if none is selected,
|
||||
* so tool responses tell Claude exactly what to do next.
|
||||
*/
|
||||
function getCurrent() {
|
||||
if (!currentProfileName) {
|
||||
const available = listProfiles();
|
||||
const hint = available.length > 0
|
||||
? `Available profiles: ${available.join(', ')}. Call switch_wms_profile with one of them.`
|
||||
: 'No profiles configured in .env (WMS_PROFILES is empty).';
|
||||
throw new Error(`No WMS profile selected. ${hint}`);
|
||||
}
|
||||
return profiles[currentProfileName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different profile. Notifies listeners (services) so they can
|
||||
* reset token + invalidate caches tied to the previous profile.
|
||||
*/
|
||||
function switchTo(name) {
|
||||
if (!profiles[name]) {
|
||||
const available = listProfiles();
|
||||
throw new Error(`Unknown profile "${name}". Available: ${available.join(', ') || '(none)'}`);
|
||||
}
|
||||
if (name === currentProfileName) {
|
||||
return profiles[name];
|
||||
}
|
||||
const previous = currentProfileName;
|
||||
currentProfileName = name;
|
||||
console.error(`[Profile] Switched: ${previous || '(none)'} → ${name}`);
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener({ previous, current: name });
|
||||
} catch (err) {
|
||||
console.error(`[Profile] Listener error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return profiles[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback fired whenever the active profile changes.
|
||||
* Used by services to invalidate caches / reset tokens.
|
||||
*/
|
||||
function onSwitch(listener) {
|
||||
listeners.push(listener);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadProfiles,
|
||||
listProfiles,
|
||||
getCurrent,
|
||||
getCurrentName,
|
||||
switchTo,
|
||||
onSwitch,
|
||||
};
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* WMS MCP Server
|
||||
* Main entry point for the Model Context Protocol server
|
||||
*
|
||||
* Architecture: 100% API-based (no direct database access)
|
||||
* Data Access: All data retrieved via WMS REST APIs
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Silence dotenv output to stdout (MCP requires stdout for JSON only)
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
process.stdout.write = process.stderr.write.bind(process.stderr);
|
||||
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '..', '.env')
|
||||
});
|
||||
|
||||
// Restore stdout
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
|
||||
// Load WMS profiles from .env before any service is instantiated.
|
||||
// Services register onSwitch listeners at module load, so they must see
|
||||
// profile-manager in its final state — but since loadProfiles() mutates
|
||||
// the manager's internal state, it's safe to call it after requires too.
|
||||
const profileManager = require('./config/profile-manager');
|
||||
profileManager.loadProfiles();
|
||||
|
||||
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
||||
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} = require('@modelcontextprotocol/sdk/types.js');
|
||||
|
||||
// Import resources
|
||||
const wmsEntitiesResources = require('./resources/wms-entities.js');
|
||||
const entitySchemasResources = require('./resources/entity-schemas.js');
|
||||
const queryExamplesResources = require('./resources/query-examples.js');
|
||||
const workflowsResources = require('./resources/workflows.js');
|
||||
const apisResources = require('./resources/apis.js');
|
||||
const logsResources = require('./resources/logs.js');
|
||||
|
||||
// Import tools
|
||||
const workflowTools = require('./tools/workflow-tools.js');
|
||||
const wmsQueryTools = require('./tools/wms-query-tools.js');
|
||||
const apiTools = require('./tools/api-tools.js');
|
||||
const logTools = require('./tools/log-tools.js');
|
||||
const adTools = require('./tools/ad-tools.js');
|
||||
const metadataTools = require('./tools/metadata-tools.js');
|
||||
const configTools = require('./tools/config-tools.js');
|
||||
const profileTools = require('./tools/profile-tools.js');
|
||||
|
||||
// Create MCP Server
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'wms-mcp-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Handler: resources/list
|
||||
* List all available resources
|
||||
*/
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
const allResources = [
|
||||
...wmsEntitiesResources.listResources(),
|
||||
...entitySchemasResources.listResources(),
|
||||
...queryExamplesResources.listResources(),
|
||||
...workflowsResources.listResources(),
|
||||
...apisResources.listResources(),
|
||||
...logsResources.listResources(),
|
||||
];
|
||||
|
||||
return {
|
||||
resources: allResources,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler: resources/read
|
||||
* Read a specific resource
|
||||
*/
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const { uri } = request.params;
|
||||
|
||||
try {
|
||||
// Route to the appropriate handler based on URI
|
||||
if (uri.startsWith('wms://entities')) {
|
||||
return await wmsEntitiesResources.readResource(uri);
|
||||
} else if (uri.startsWith('wms://entity-schemas')) {
|
||||
return await entitySchemasResources.readResource(uri);
|
||||
} else if (uri.startsWith('wms://query-examples')) {
|
||||
return await queryExamplesResources.readResource(uri);
|
||||
} else if (uri.startsWith('workflows://')) {
|
||||
return await workflowsResources.readResource(uri);
|
||||
} else if (uri.startsWith('api://')) {
|
||||
return await apisResources.readResource(uri);
|
||||
} else if (uri.startsWith('logs://')) {
|
||||
return await logsResources.readResource(uri);
|
||||
} else {
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Server] Error reading resource ${uri}:`, error.message);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `Error reading resource: ${error.message}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler: tools/list
|
||||
* List all available tools
|
||||
*/
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
const allTools = [
|
||||
...workflowTools.listTools(),
|
||||
...wmsQueryTools.listTools(),
|
||||
...apiTools.listTools(),
|
||||
...logTools.listTools(),
|
||||
...adTools.listTools(),
|
||||
...metadataTools.listTools(),
|
||||
...configTools.listTools(),
|
||||
...profileTools.listTools(),
|
||||
];
|
||||
|
||||
return {
|
||||
tools: allTools,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler: tools/call
|
||||
* Execute a specific tool
|
||||
*/
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
console.error(`[Server] Executing tool: ${name}`);
|
||||
|
||||
// Route to the appropriate handler based on tool name
|
||||
if (name.startsWith('search_workflows') ||
|
||||
name.startsWith('get_workflow_') ||
|
||||
name.startsWith('list_workflow_')) {
|
||||
return await workflowTools.executeTool(name, args);
|
||||
} else if (name.startsWith('query_wms_') ||
|
||||
name.startsWith('count_wms_') ||
|
||||
name.startsWith('get_entity_') ||
|
||||
name.startsWith('search_wms_')) {
|
||||
return await wmsQueryTools.executeTool(name, args);
|
||||
} else if (name.startsWith('call_query_api') ||
|
||||
name.startsWith('execute_command')) {
|
||||
return await apiTools.executeTool(name, args);
|
||||
} else if (name.includes('_logs')) {
|
||||
return await logTools.executeTool(name, args);
|
||||
} else if (name.startsWith('get_application_') ||
|
||||
name.startsWith('get_ad_') ||
|
||||
name.startsWith('search_ad_') ||
|
||||
name.startsWith('list_ad_')) {
|
||||
return await adTools.executeTool(name, args);
|
||||
} else if (name === 'get_entity_metadata' || name === 'generic_search') {
|
||||
return await metadataTools.executeTool(name, args);
|
||||
} else if (name === 'get_system_parameters') {
|
||||
return await configTools.executeTool(name, args);
|
||||
} else if (name === 'list_wms_profiles' ||
|
||||
name === 'get_current_wms_profile' ||
|
||||
name === 'switch_wms_profile') {
|
||||
return await profileTools.executeTool(name, args);
|
||||
} else {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Server] Error executing tool ${name}:`, error.message);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
tool: name,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
console.error('[Server] Starting WMS MCP Server...');
|
||||
console.error('[Server] Architecture: 100% API-based (no direct database access)');
|
||||
|
||||
// Validate shared environment variables
|
||||
const requiredSharedEnvVars = ['WMS_API_AUTH', 'WMS_PROFILES'];
|
||||
const missingShared = requiredSharedEnvVars.filter(v => !process.env[v]);
|
||||
if (missingShared.length > 0) {
|
||||
console.error('[Server] WARNING: Missing shared env vars:', missingShared.join(', '));
|
||||
}
|
||||
|
||||
// Validate that at least one profile is loaded
|
||||
const profiles = profileManager.listProfiles();
|
||||
if (profiles.length === 0) {
|
||||
console.error('[Server] WARNING: No WMS profile loaded. Define WMS_PROFILES + <NAME>_HOST/USERNAME/PASSWORD/TENANT in .env');
|
||||
} else {
|
||||
console.error(`[Server] Profiles available: ${profiles.join(', ')}`);
|
||||
const current = profileManager.getCurrentName();
|
||||
if (current) {
|
||||
console.error(`[Server] Active profile: ${current}`);
|
||||
} else {
|
||||
console.error('[Server] No active profile — Claude must call switch_wms_profile before any API call');
|
||||
}
|
||||
}
|
||||
|
||||
// Create stdio transport
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
// Connect server
|
||||
await server.connect(transport);
|
||||
|
||||
console.error('[Server] WMS MCP Server running on stdio');
|
||||
console.error('[Server] Server name: wms-mcp-server');
|
||||
console.error('[Server] Version: 1.0.0');
|
||||
console.error('[Server] Ready to accept requests from Claude Desktop');
|
||||
} catch (error) {
|
||||
console.error('[Server] Fatal error starting server:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle graceful shutdown
|
||||
*/
|
||||
process.on('SIGINT', async () => {
|
||||
console.error('\n[Server] Shutting down WMS MCP Server (SIGINT)...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.error('\n[Server] Shutting down WMS MCP Server (SIGTERM)...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('[Server] Uncaught exception:', error.message);
|
||||
console.error(error.stack);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[Server] Unhandled rejection at:', promise);
|
||||
console.error('[Server] Reason:', reason);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
main();
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* API Resources
|
||||
* Provides catalog of WMS APIs available
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'api://catalog',
|
||||
name: 'API Catalog',
|
||||
description: 'Catalog of WMS APIs available with parameters and documentation',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'api://catalog':
|
||||
content = getAPICatalog();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown API resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getAPICatalog() {
|
||||
return `# WMS API Catalog
|
||||
|
||||
## Overview
|
||||
|
||||
The WMS provides several REST APIs for querying and modifying data.
|
||||
|
||||
**Base URL:** \`${process.env.WMS_API_BASE_URL || 'https://10.255.255.2/ApplicationService/api'}\`
|
||||
**Authentication:** OAuth 2.0 Bearer Token (automatic)
|
||||
|
||||
---
|
||||
|
||||
## Query API
|
||||
|
||||
Execute LINQ queries against WMS entities.
|
||||
|
||||
**Endpoint:** \`/api/QueryExecute\`
|
||||
**Method:** POST
|
||||
**Authentication:** Required
|
||||
|
||||
### Request Format
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"Application": "EasyWMS",
|
||||
"QueryType": 1,
|
||||
"Expression": "Context.{EntityType}.Select(z => z)"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Supported Entity Types
|
||||
|
||||
| Entity Type | Description |
|
||||
|-------------|-------------|
|
||||
| Products | Product references and SKUs |
|
||||
| Containers | Pallets, boxes, and container types |
|
||||
| Stocks | Available inventory by location |
|
||||
| ProductLocations | Product placement in warehouse |
|
||||
| Tasks | WMS tasks (picks, puts, moves, etc.) |
|
||||
| Accounts | Customer accounts |
|
||||
| Suppliers | Supplier information |
|
||||
| Kits | Product kits and bundles |
|
||||
| Aliases | Product aliases and alternative codes |
|
||||
| InboundOrders | Inbound/receiving orders |
|
||||
| Receptions | Actual receptions |
|
||||
| OutboundOrders | Outbound/shipping orders |
|
||||
|
||||
### Example Queries
|
||||
|
||||
\`\`\`
|
||||
# Get all products (limited)
|
||||
Context.Products.Take(100).Select(z => z)
|
||||
|
||||
# Get specific fields
|
||||
Context.Products.Select(z => new { z.Id, z.Code, z.Name })
|
||||
|
||||
# Filter and select
|
||||
Context.Tasks.Where(z => z.Status == "Pending").Take(50).Select(z => z)
|
||||
\`\`\`
|
||||
|
||||
### MCP Tool
|
||||
|
||||
Use \`call_query_api\` tool to execute queries.
|
||||
|
||||
---
|
||||
|
||||
## Command API
|
||||
|
||||
Execute commands to modify WMS data.
|
||||
|
||||
**Endpoint:** \`/api/CommandExecute\`
|
||||
**Method:** POST
|
||||
**Authentication:** Required
|
||||
|
||||
### Request Format
|
||||
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"Name": "CommandName, Mecalux.ITSW.EasyWMS.Modules.Contracts",
|
||||
"Properties": {
|
||||
"PropertyName": "value"
|
||||
}
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| ProductRemoveCommand | Remove a product |
|
||||
| ProductUpdateCommand | Update product information |
|
||||
| ContainerCreateCommand | Create a new container |
|
||||
| TaskCancelCommand | Cancel a task |
|
||||
| InboundOrderCancelCommandV2 | Cancel an inbound order |
|
||||
| OutboundOrderCancelCommand | Cancel an outbound order |
|
||||
|
||||
### Example Command
|
||||
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand, Mecalux.ITSW.EasyWMS.Modules.Contracts",
|
||||
"Properties": {
|
||||
"Id": "product-guid-here"
|
||||
}
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
### MCP Tool
|
||||
|
||||
Use \`execute_command\` tool to execute commands.
|
||||
|
||||
**⚠️ WARNING:** Commands modify data. Use with caution.
|
||||
|
||||
---
|
||||
|
||||
## Workflow API
|
||||
|
||||
Retrieve workflow definitions by application.
|
||||
|
||||
**Endpoint:** \`/AD/api/Workflow/GetByApplication\`
|
||||
**Method:** POST
|
||||
**Authentication:** Required
|
||||
|
||||
### Request Format
|
||||
|
||||
\`\`\`json
|
||||
["EasyWMS", "AD", 5000, 0]
|
||||
\`\`\`
|
||||
|
||||
Parameters:
|
||||
1. Application name (e.g., "EasyWMS")
|
||||
2. Tenant code (e.g., "AD")
|
||||
3. Page size (e.g., 5000)
|
||||
4. Offset (e.g., 0 for first page)
|
||||
|
||||
### Response
|
||||
|
||||
Array of workflow objects with:
|
||||
- Id, Code, Name
|
||||
- Category, Description
|
||||
- Version, Status
|
||||
- Created, Modified
|
||||
- Definition (JSON)
|
||||
|
||||
### MCP Tools
|
||||
|
||||
Use workflow tools to interact with workflows:
|
||||
- \`search_workflows\` - Search by name, code, description
|
||||
- \`get_workflow_details\` - Get full workflow definition
|
||||
- \`list_workflow_categories\` - List all categories
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All APIs use OAuth 2.0 authentication.
|
||||
|
||||
**Token Endpoint:** \`/EasySTS/OAuth/Token\`
|
||||
**Grant Types:** password, refresh_token
|
||||
|
||||
### Token Management
|
||||
|
||||
- Tokens expire after ~1200 seconds
|
||||
- Automatic refresh when < 1000 seconds remaining
|
||||
- Credentials configured in .env file
|
||||
|
||||
The MCP server handles authentication automatically.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
- \`200\` - Success
|
||||
- \`400\` - Bad request (invalid query/command)
|
||||
- \`401\` - Unauthorized (token expired or invalid)
|
||||
- \`403\` - Forbidden (insufficient permissions)
|
||||
- \`500\` - Internal server error
|
||||
|
||||
### Error Response Format
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"error": "Error message",
|
||||
"details": "Detailed error information"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits and Constraints
|
||||
|
||||
- **Query Limit:** Maximum 1000 rows per query (configurable)
|
||||
- **Query Timeout:** 30 seconds (configurable)
|
||||
- **Workflow Cache:** 1 hour TTL (configurable)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
API settings are configured via environment variables:
|
||||
|
||||
\`\`\`env
|
||||
WMS_API_BASE_URL=https://10.255.255.2/ApplicationService/api
|
||||
WMS_API_TOKEN_URL=https://10.255.255.2/EasySTS/OAuth/Token
|
||||
WMS_API_TENANT=AD
|
||||
WMS_API_USERNAME=your-username
|
||||
WMS_API_PASSWORD=your-password
|
||||
WORKFLOW_API_BASE=https://10.255.255.2/AD/api
|
||||
WORKFLOW_PAGE_SIZE=5000
|
||||
MAX_QUERY_ROWS=1000
|
||||
QUERY_TIMEOUT=30000
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
**Note:** Use MCP tools to interact with these APIs. Direct API calls require proper authentication handling.
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Resources MCP pour la documentation
|
||||
* Permet à Claude d'accéder à la documentation structurée en fichiers Markdown
|
||||
*/
|
||||
|
||||
// Chemin vers le dossier de documentation
|
||||
const DOCS_PATH = process.env.DOCS_PATH || path.join(__dirname, '..', '..', 'docs');
|
||||
|
||||
/**
|
||||
* Scanne récursivement un dossier pour trouver tous les fichiers .md
|
||||
* @param {string} dir - Dossier à scanner
|
||||
* @param {string} baseDir - Dossier de base pour les chemins relatifs
|
||||
* @returns {Promise<Array>} - Liste des fichiers .md
|
||||
*/
|
||||
async function scanMarkdownFiles(dir, baseDir = dir) {
|
||||
let files = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Récursion dans les sous-dossiers
|
||||
const subFiles = await scanMarkdownFiles(fullPath, baseDir);
|
||||
files = files.concat(subFiles);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Fichier Markdown trouvé
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
relativePath: relativePath.replace(/\\/g, '/'), // Normaliser les slashes
|
||||
uri: `docs://${relativePath.replace(/\\/g, '/')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Dossier n'existe pas ou erreur de lecture
|
||||
console.error(`Error scanning directory ${dir}:`, err.message);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les resources disponibles pour la documentation
|
||||
* @returns {Promise<Array>} - Liste des resources
|
||||
*/
|
||||
async function listResources() {
|
||||
try {
|
||||
// Scanner les fichiers Markdown
|
||||
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
|
||||
|
||||
const resources = [
|
||||
{
|
||||
uri: 'docs://index',
|
||||
name: 'Documentation Index',
|
||||
description: 'Sommaire de toute la documentation disponible',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter chaque fichier .md comme resource
|
||||
mdFiles.forEach((file) => {
|
||||
resources.push({
|
||||
uri: file.uri,
|
||||
name: file.name.replace('.md', ''),
|
||||
description: `Documentation: ${file.relativePath}`,
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
});
|
||||
|
||||
return resources;
|
||||
} catch (err) {
|
||||
console.error('Error listing documentation resources:', err);
|
||||
return [
|
||||
{
|
||||
uri: 'docs://index',
|
||||
name: 'Documentation Index',
|
||||
description: 'Sommaire de toute la documentation disponible',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un index/sommaire de la documentation
|
||||
* @returns {Promise<string>} - Markdown avec le sommaire
|
||||
*/
|
||||
async function generateIndex() {
|
||||
try {
|
||||
const mdFiles = await scanMarkdownFiles(DOCS_PATH);
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
return `# Documentation\n\n*Aucun fichier de documentation trouvé dans \`${DOCS_PATH}\`*\n\n` +
|
||||
`Pour ajouter de la documentation :\n` +
|
||||
`1. Créez un dossier \`docs\` à la racine du projet\n` +
|
||||
`2. Ajoutez vos fichiers .md (organisation libre avec sous-dossiers)\n` +
|
||||
`3. Redémarrez le serveur MCP\n`;
|
||||
}
|
||||
|
||||
let markdown = '# Documentation WMS\n\n';
|
||||
markdown += `**${mdFiles.length} fichiers de documentation disponibles**\n\n`;
|
||||
markdown += `📁 Emplacement : \`${DOCS_PATH}\`\n\n`;
|
||||
|
||||
// Grouper par dossier
|
||||
const grouped = {};
|
||||
mdFiles.forEach((file) => {
|
||||
const dir = path.dirname(file.relativePath);
|
||||
const folder = dir === '.' ? '📄 Racine' : `📁 ${dir}`;
|
||||
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(file);
|
||||
});
|
||||
|
||||
// Générer le sommaire
|
||||
markdown += '## Sommaire\n\n';
|
||||
|
||||
Object.keys(grouped).sort().forEach((folder) => {
|
||||
markdown += `### ${folder}\n\n`;
|
||||
|
||||
grouped[folder].forEach((file) => {
|
||||
markdown += `- **${file.name.replace('.md', '')}** - \`${file.uri}\`\n`;
|
||||
});
|
||||
|
||||
markdown += '\n';
|
||||
});
|
||||
|
||||
markdown += '---\n\n';
|
||||
markdown += '*Pour lire un fichier, demandez à Claude de lire la resource correspondante (par exemple: "Lis la documentation X")*\n';
|
||||
|
||||
return markdown;
|
||||
} catch (err) {
|
||||
return `# Erreur\n\nImpossible de générer l'index de documentation: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un fichier de documentation
|
||||
* @param {string} relativePath - Chemin relatif du fichier
|
||||
* @returns {Promise<string>} - Contenu Markdown du fichier
|
||||
*/
|
||||
async function readDocFile(relativePath) {
|
||||
try {
|
||||
const filePath = path.join(DOCS_PATH, relativePath);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return content;
|
||||
} catch (err) {
|
||||
return `# Erreur\n\nImpossible de lire le fichier \`${relativePath}\`: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une resource documentation selon son URI
|
||||
* @param {string} uri - URI de la resource (format: docs://path/to/file.md)
|
||||
* @returns {Promise<Object>} - Contenu de la resource
|
||||
*/
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
if (uri === 'docs://index') {
|
||||
content = await generateIndex();
|
||||
} else if (uri.startsWith('docs://')) {
|
||||
// Extraire le chemin relatif de l'URI
|
||||
const relativePath = uri.replace('docs://', '');
|
||||
content = await readDocFile(relativePath);
|
||||
} else {
|
||||
throw new Error(`Unknown documentation resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Entity Schema Resources
|
||||
* Provides schema information for WMS entity types
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'wms://entity-schemas',
|
||||
name: 'Entity Schemas',
|
||||
description: 'Schema information and common fields for WMS entities',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'wms://entity-schemas':
|
||||
content = getEntitySchemas();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown entity schema resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getEntitySchemas() {
|
||||
return `# WMS Entity Schemas
|
||||
|
||||
## Overview
|
||||
|
||||
Entity schemas define the structure and available fields for each entity type.
|
||||
Use the \`get_entity_schema\` tool to retrieve the actual schema from the API by querying one entity.
|
||||
|
||||
## Common Field Patterns
|
||||
|
||||
Most WMS entities follow these patterns:
|
||||
|
||||
### Standard Fields
|
||||
- \`Id\` - Unique identifier (GUID or integer)
|
||||
- \`Code\` - Business code/reference
|
||||
- \`Name\` - Display name
|
||||
- \`Description\` - Detailed description
|
||||
- \`Created\` - Creation timestamp
|
||||
- \`Modified\` - Last modification timestamp
|
||||
- \`Status\` - Entity status
|
||||
|
||||
### Relationship Fields
|
||||
- \`{Entity}Id\` - Foreign key to related entity (e.g., ProductId, AccountId)
|
||||
- \`{Entity}Code\` - Related entity code
|
||||
- \`{Entity}Name\` - Related entity name
|
||||
|
||||
## Entity-Specific Schemas
|
||||
|
||||
### Products
|
||||
Common fields:
|
||||
- Id, Code, Name, Description
|
||||
- Category, Family, SubFamily
|
||||
- Weight, Volume, Height, Width, Length
|
||||
- UnitOfMeasure, BaseUnit
|
||||
- Barcode, AlternativeCodes
|
||||
- Active, Blocked
|
||||
|
||||
### Containers
|
||||
Common fields:
|
||||
- Id, Code, Name, Type
|
||||
- Status, Location, Zone
|
||||
- Weight, Tare, MaxWeight
|
||||
- ProductId, Quantity
|
||||
- Created, Modified
|
||||
|
||||
### Tasks
|
||||
Common fields:
|
||||
- Id, Code, Type, Status
|
||||
- Priority, Sequence
|
||||
- SourceLocation, TargetLocation
|
||||
- ProductId, Quantity
|
||||
- AssignedUser, AssignedDevice
|
||||
- StartedAt, CompletedAt
|
||||
|
||||
### Stocks
|
||||
Common fields:
|
||||
- ProductId, ProductCode, ProductName
|
||||
- LocationId, LocationCode
|
||||
- Quantity, AvailableQuantity, ReservedQuantity
|
||||
- UnitOfMeasure
|
||||
- LotNumber, SerialNumber
|
||||
- ExpirationDate
|
||||
|
||||
### InboundOrders / OutboundOrders
|
||||
Common fields:
|
||||
- Id, Code, ExternalCode
|
||||
- Type, Status, Priority
|
||||
- AccountId, AccountCode, AccountName
|
||||
- SupplierId, SupplierCode (inbound only)
|
||||
- ExpectedDate, PlannedDate, ActualDate
|
||||
- TotalLines, TotalQuantity
|
||||
- Created, Modified
|
||||
|
||||
## Retrieving Schemas
|
||||
|
||||
Use the \`get_entity_schema\` tool to dynamically retrieve the schema:
|
||||
|
||||
\`\`\`
|
||||
# Get Products schema
|
||||
get_entity_schema(entity_type="Products")
|
||||
|
||||
# Get Tasks schema
|
||||
get_entity_schema(entity_type="Tasks")
|
||||
\`\`\`
|
||||
|
||||
This will query one entity from the API and return all available fields with sample values.
|
||||
|
||||
## LINQ Field Selection
|
||||
|
||||
You can select specific fields using LINQ expressions:
|
||||
|
||||
\`\`\`
|
||||
# Select specific fields
|
||||
query_wms_entities(
|
||||
entity_type="Products",
|
||||
select_expression="z => new { z.Id, z.Code, z.Name, z.Category }"
|
||||
)
|
||||
|
||||
# Nested object creation
|
||||
query_wms_entities(
|
||||
entity_type="Tasks",
|
||||
select_expression="z => new {
|
||||
Task = z.Code,
|
||||
Product = z.ProductName,
|
||||
Status = z.Status
|
||||
}"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
**Note:** Field availability may vary depending on WMS configuration and version.
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Logs Resources
|
||||
* Provides guide for WMS log files
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'logs://guide',
|
||||
name: 'Logs Guide',
|
||||
description: 'Guide for WMS log files: formats, locations, common error patterns',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'logs://guide':
|
||||
content = getLogsGuide();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown logs resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getLogsGuide() {
|
||||
return `# WMS Logs Guide
|
||||
|
||||
## Log File Locations
|
||||
|
||||
**Primary Logs:** \`${process.env.LOGS_PATH || 'C:\\inetpub\\logs\\LogFiles\\Mecalux'}\`
|
||||
|
||||
Log files are typically named with date patterns:
|
||||
- \`application_YYYYMMDD.log\`
|
||||
- \`error_YYYYMMDD.log\`
|
||||
- \`api_YYYYMMDD.log\`
|
||||
|
||||
---
|
||||
|
||||
## Log File Format
|
||||
|
||||
WMS logs typically follow this format:
|
||||
|
||||
\`\`\`
|
||||
[YYYY-MM-DD HH:MM:SS.fff] [LEVEL] [Module] Message
|
||||
Additional context or stack trace (if applicable)
|
||||
\`\`\`
|
||||
|
||||
### Example Log Entries
|
||||
|
||||
\`\`\`
|
||||
[2024-01-15 10:23:45.123] [INFO] [TaskService] Task TASK-001 created successfully
|
||||
[2024-01-15 10:23:46.456] [ERROR] [StockService] Insufficient stock for product PROD-123
|
||||
[2024-01-15 10:23:47.789] [WARN] [OrderService] Order ORD-456 delayed - expected date passed
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Level | Description | Severity |
|
||||
|-------|-------------|----------|
|
||||
| DEBUG | Detailed diagnostic information | Low |
|
||||
| INFO | General informational messages | Low |
|
||||
| WARN | Warning messages for potential issues | Medium |
|
||||
| ERROR | Error messages for failures | High |
|
||||
| FATAL | Critical errors causing system failure | Critical |
|
||||
|
||||
---
|
||||
|
||||
## Common Error Patterns
|
||||
|
||||
### Database Errors
|
||||
|
||||
\`\`\`
|
||||
ORA-00001: unique constraint violated
|
||||
ORA-01017: invalid username/password
|
||||
ORA-12170: connection timeout
|
||||
ORA-00060: deadlock detected
|
||||
Connection timeout
|
||||
Constraint violation
|
||||
Foreign key violation
|
||||
\`\`\`
|
||||
|
||||
### WMS Business Errors
|
||||
|
||||
\`\`\`
|
||||
Insufficient stock
|
||||
Stock not found
|
||||
Product not found
|
||||
Location occupied
|
||||
Location not available
|
||||
Task already completed
|
||||
Order already processed
|
||||
Invalid product code
|
||||
Invalid container code
|
||||
\`\`\`
|
||||
|
||||
### Integration Errors
|
||||
|
||||
\`\`\`
|
||||
API call failed
|
||||
Authentication failed
|
||||
Token expired
|
||||
Request timeout
|
||||
JSON parse error
|
||||
XML parse error
|
||||
Connection refused
|
||||
Service unavailable
|
||||
\`\`\`
|
||||
|
||||
### System Errors
|
||||
|
||||
\`\`\`
|
||||
Out of memory
|
||||
OutOfMemoryException
|
||||
StackOverflowException
|
||||
File not found
|
||||
Access denied
|
||||
Permission denied
|
||||
Disk full
|
||||
Network unreachable
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## MCP Tools for Logs
|
||||
|
||||
### read_recent_logs
|
||||
|
||||
Read the most recent log entries from a log file.
|
||||
|
||||
**Parameters:**
|
||||
- \`count\` - Number of lines to read (default: 100)
|
||||
- \`log_file\` - Specific log file name (optional, uses most recent if not specified)
|
||||
|
||||
**Example:**
|
||||
\`\`\`
|
||||
read_recent_logs(count=200)
|
||||
read_recent_logs(count=50, log_file="error_20240115.log")
|
||||
\`\`\`
|
||||
|
||||
### search_logs
|
||||
|
||||
Search for a keyword across all log files with context.
|
||||
|
||||
**Parameters:**
|
||||
- \`keyword\` - Search term (e.g., "ERROR", "PROD-123", "timeout")
|
||||
- \`max_results\` - Maximum results to return (default: 50)
|
||||
- \`context_lines\` - Lines of context before/after match (default: 2)
|
||||
|
||||
**Example:**
|
||||
\`\`\`
|
||||
search_logs(keyword="ERROR", max_results=100)
|
||||
search_logs(keyword="PROD-123", context_lines=5)
|
||||
search_logs(keyword="timeout", max_results=20)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
### 1. Check Recent Activity
|
||||
|
||||
Start by reading recent logs to understand current system state:
|
||||
|
||||
\`\`\`
|
||||
read_recent_logs(count=200)
|
||||
\`\`\`
|
||||
|
||||
### 2. Search for Errors
|
||||
|
||||
Look for error patterns:
|
||||
|
||||
\`\`\`
|
||||
search_logs(keyword="ERROR", max_results=50)
|
||||
search_logs(keyword="EXCEPTION", max_results=50)
|
||||
search_logs(keyword="FAILED", max_results=50)
|
||||
\`\`\`
|
||||
|
||||
### 3. Search for Specific Entity
|
||||
|
||||
Find references to specific orders, products, or tasks:
|
||||
|
||||
\`\`\`
|
||||
search_logs(keyword="ORDER-123", context_lines=5)
|
||||
search_logs(keyword="PROD-456", context_lines=5)
|
||||
\`\`\`
|
||||
|
||||
### 4. Correlate with API/Database
|
||||
|
||||
Once you find relevant log entries:
|
||||
- Use \`query_wms_entities\` to check current entity state
|
||||
- Use \`search_workflows\` to find related workflows
|
||||
- Use \`call_query_api\` to verify data consistency
|
||||
|
||||
---
|
||||
|
||||
## Log Analysis Tips
|
||||
|
||||
### Time-Based Analysis
|
||||
|
||||
Log timestamps help correlate events:
|
||||
- Look for errors occurring at the same time
|
||||
- Check for patterns (e.g., errors every hour, at specific times)
|
||||
- Correlate with known system events (deployments, restarts)
|
||||
|
||||
### Frequency Analysis
|
||||
|
||||
- How often does this error occur?
|
||||
- Is it increasing or decreasing over time?
|
||||
- Does it happen at specific intervals?
|
||||
|
||||
### Context Analysis
|
||||
|
||||
Always request context lines when searching:
|
||||
- See what happened before the error
|
||||
- See what happened after the error
|
||||
- Understand the execution flow
|
||||
|
||||
### Pattern Recognition
|
||||
|
||||
Look for common patterns:
|
||||
- Same error message repeatedly
|
||||
- Same product/order/task in multiple errors
|
||||
- Same module or service causing issues
|
||||
- Cascading failures (one error leading to others)
|
||||
|
||||
---
|
||||
|
||||
## Common Debugging Scenarios
|
||||
|
||||
### Scenario 1: Order Not Processing
|
||||
|
||||
\`\`\`
|
||||
1. search_logs(keyword="ORDER-123", context_lines=5)
|
||||
2. query_wms_entities(entity_type="OutboundOrders", filter='z.Code == "ORDER-123"')
|
||||
3. Check order status and error messages in both logs and database
|
||||
\`\`\`
|
||||
|
||||
### Scenario 2: Stock Discrepancy
|
||||
|
||||
\`\`\`
|
||||
1. search_logs(keyword="PROD-456", context_lines=3)
|
||||
2. query_wms_entities(entity_type="Stocks", filter='z.ProductCode == "PROD-456"')
|
||||
3. Compare expected vs actual stock levels
|
||||
4. Check for stock movement tasks
|
||||
\`\`\`
|
||||
|
||||
### Scenario 3: Task Failures
|
||||
|
||||
\`\`\`
|
||||
1. search_logs(keyword="TASK-789", context_lines=5)
|
||||
2. query_wms_entities(entity_type="Tasks", filter='z.Code == "TASK-789"')
|
||||
3. Check task status, error message, and assigned user/device
|
||||
\`\`\`
|
||||
|
||||
### Scenario 4: Performance Issues
|
||||
|
||||
\`\`\`
|
||||
1. search_logs(keyword="timeout", max_results=100)
|
||||
2. search_logs(keyword="slow", max_results=100)
|
||||
3. Look for patterns in timeouts (specific operations, times, modules)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Log Retention
|
||||
|
||||
- Log files are typically rotated daily
|
||||
- Older logs may be compressed or archived
|
||||
- Check with system administrator for retention policy
|
||||
|
||||
---
|
||||
|
||||
**Note:** Use log tools in combination with WMS entity queries for complete debugging picture.
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Query Examples Resources
|
||||
* Provides example LINQ queries and diagnostic recipes for WMS entities.
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'wms://query-examples',
|
||||
name: 'Query Examples',
|
||||
description: 'LINQ query examples and diagnostic recipes for WMS entities',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'wms://query-examples':
|
||||
content = getQueryExamples();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown query examples resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getQueryExamples() {
|
||||
return `# WMS LINQ Query Examples & Diagnostic Recipes
|
||||
|
||||
> **Reading model** — \`query_wms_entities\` / \`count_wms_entities\` use \`QueryExecute\`
|
||||
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
|
||||
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Recipes
|
||||
|
||||
Recipes below were validated against a live WMS. Use them as-is, substituting the
|
||||
\`X\` placeholders.
|
||||
|
||||
### OS incomplètes par classe d'expédition
|
||||
|
||||
Compter les ordres de sortie incomplets, actifs, d'une classe donnée
|
||||
(\`OutboundClassCode\`). Fields verified on the \`OutboundOrder\` Reading entity:
|
||||
\`OutboundClassCode\` (string), \`IncompleteOrder\` (bool), \`IsActive\` (bool).
|
||||
|
||||
\`\`\`
|
||||
# Toutes classes confondues
|
||||
count_wms_entities(
|
||||
entity_type="OutboundOrders",
|
||||
filter='z.IncompleteOrder == true && z.IsActive == true'
|
||||
)
|
||||
|
||||
# Pour une classe précise
|
||||
count_wms_entities(
|
||||
entity_type="OutboundOrders",
|
||||
filter='z.OutboundClassCode == "SHIPPING_GROUP_01" && z.IncompleteOrder == true && z.IsActive == true'
|
||||
)
|
||||
|
||||
# Lister le détail des OS concernées
|
||||
query_wms_entities(
|
||||
entity_type="OutboundOrders",
|
||||
filter='z.OutboundClassCode == "SHIPPING_GROUP_01" && z.IncompleteOrder == true && z.IsActive == true',
|
||||
limit=200
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Containers bloquants sur un emplacement
|
||||
|
||||
Identifier les conteneurs qui retiennent un emplacement parce qu'ils ont des tâches
|
||||
en attente. Fields verified on the \`Container\` Reading entity:
|
||||
\`LocationCode\` (string), \`NumContainerPendingTasks\` (long).
|
||||
|
||||
\`\`\`
|
||||
# Combien de containers ont des tâches en attente sur l'emplacement
|
||||
count_wms_entities(
|
||||
entity_type="Containers",
|
||||
filter='z.LocationCode == "QUAI_EXP_01" && z.NumContainerPendingTasks > 0'
|
||||
)
|
||||
|
||||
# Lister ces containers (renvoie toutes les colonnes du container)
|
||||
query_wms_entities(
|
||||
entity_type="Containers",
|
||||
filter='z.LocationCode == "QUAI_EXP_01" && z.NumContainerPendingTasks > 0',
|
||||
limit=200
|
||||
)
|
||||
|
||||
# Vue globale : tous les containers bloquants du WMS
|
||||
count_wms_entities(
|
||||
entity_type="Containers",
|
||||
filter='z.NumContainerPendingTasks > 0'
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Paramètres système d'un entrepôt
|
||||
|
||||
Pour lire les paramètres de configuration WMS et leurs valeurs par entrepôt,
|
||||
utiliser l'outil dédié \`get_system_parameters\` plutôt qu'une requête LINQ :
|
||||
|
||||
\`\`\`
|
||||
get_system_parameters(warehouse="DOMBASLE") # tous les paramètres
|
||||
get_system_parameters(warehouse="DOMBASLE", param_class="Shipping")
|
||||
get_system_parameters(search="CROSSDOCK")
|
||||
\`\`\`
|
||||
|
||||
### Modèles d'expédition — suivi des exécutions
|
||||
|
||||
> ⚠️ L'API ne conserve que la **dernière** exécution de chaque modèle d'expédition
|
||||
> (\`ShipmentTemplate.LastExecuteDate\`). L'historique complet des exécutions n'existe
|
||||
> que dans les logs serveur \`ApplyShipmentTemplates\`, non exposés par ce MCP.
|
||||
|
||||
Fields verified on the \`ShipmentTemplate\` Reading entity: \`Code\`, \`Status\`,
|
||||
\`IsEnabled\`, \`IsActive\`, \`LastExecuteDate\`, \`WarehouseCode\`, \`Priority\`,
|
||||
\`DirectiveCode\`.
|
||||
|
||||
\`\`\`
|
||||
# Tous les modèles d'un entrepôt + leur dernière exécution et leur statut
|
||||
query_wms_entities(
|
||||
entity_type="ShipmentTemplates",
|
||||
filter='z.WarehouseCode == "DOMBASLE"',
|
||||
limit=200
|
||||
)
|
||||
|
||||
# Modèles activés mais jamais exécutés (LastExecuteDate null)
|
||||
query_wms_entities(
|
||||
entity_type="ShipmentTemplates",
|
||||
filter='z.IsEnabled == true && z.LastExecuteDate == null',
|
||||
limit=200
|
||||
)
|
||||
|
||||
# Combien de modèles exécutés au moins une fois
|
||||
count_wms_entities(
|
||||
entity_type="ShipmentTemplates",
|
||||
filter='z.LastExecuteDate != null'
|
||||
)
|
||||
|
||||
# Modèles exécutés depuis une date (littéral DateTime obligatoire — voir Date filters)
|
||||
count_wms_entities(
|
||||
entity_type="ShipmentTemplates",
|
||||
filter='z.LastExecuteDate >= new DateTime(2025, 1, 1)'
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Counting (\`count_wms_entities\`)
|
||||
|
||||
Always prefer \`count_wms_entities\` for any "combien" / "how many" question — it uses
|
||||
\`QueryScalarExecute\` and never materialises rows.
|
||||
|
||||
\`\`\`
|
||||
count_wms_entities(entity_type="OutboundOrders")
|
||||
count_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "InProcess"')
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Basic Queries
|
||||
|
||||
### Get rows (with limit)
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(entity_type="Products", limit=100)
|
||||
query_wms_entities(entity_type="Tasks", limit=50)
|
||||
\`\`\`
|
||||
|
||||
> Note: the \`select_expression\` parameter (LINQ projections) is currently unreliable
|
||||
> against \`QueryExecute\` — prefer querying full rows and reading the fields you need.
|
||||
|
||||
## Filtering Examples
|
||||
|
||||
### Status filters (string enum names)
|
||||
|
||||
\`\`\`
|
||||
# Tâches en cours / en attente
|
||||
query_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "InProcess"', limit=100)
|
||||
query_wms_entities(entity_type="Tasks", filter='z.TaskStatus == "Pending"', limit=100)
|
||||
|
||||
# Ordres de sortie lancés
|
||||
query_wms_entities(entity_type="OutboundOrders", filter='z.OutboundOrderStatus == "Release"', limit=100)
|
||||
\`\`\`
|
||||
|
||||
### Date filters
|
||||
|
||||
> ⚠️ \`AddDays()\` et les dates relatives (\`DateTime.Now\`, \`DateTime.Today\`) ne sont
|
||||
> **pas traduisibles** par le moteur de requête — toujours utiliser un littéral
|
||||
> \`new DateTime(année, mois, jour)\`. Le nom du champ date dépend de l'entité
|
||||
> (\`LastExecuteDate\`, \`InternalInfo.CreationDate\`, …) — le vérifier via
|
||||
> \`get_entity_metadata\` ou \`docs://entities/\`.
|
||||
|
||||
\`\`\`
|
||||
# Éléments depuis une date donnée (littéral DateTime obligatoire)
|
||||
count_wms_entities(
|
||||
entity_type="ShipmentTemplates",
|
||||
filter='z.LastExecuteDate >= new DateTime(2025, 1, 1)'
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Numeric filters
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(entity_type="Stocks", filter="z.Quantity > 0", limit=100)
|
||||
\`\`\`
|
||||
|
||||
### String filters
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(entity_type="Products", filter='z.Code.StartsWith("ABC")', limit=100)
|
||||
query_wms_entities(entity_type="Products", filter='z.Code.Contains("test")', limit=100)
|
||||
\`\`\`
|
||||
|
||||
## Complex Filters
|
||||
|
||||
### Multiple conditions (AND / OR)
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(
|
||||
entity_type="Tasks",
|
||||
filter='z.TaskStatus == "Pending" && z.Priority > 50',
|
||||
limit=100
|
||||
)
|
||||
|
||||
query_wms_entities(
|
||||
entity_type="OutboundOrders",
|
||||
filter='z.OutboundOrderStatus == "Release" || z.OutboundOrderStatus == "Creating"',
|
||||
limit=100
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Find an entity by code
|
||||
|
||||
\`\`\`
|
||||
query_wms_entities(entity_type="Products", filter='z.Code == "PROD123"', limit=1)
|
||||
query_wms_entities(entity_type="OutboundOrders", filter='z.Code == "ORDER123"', limit=1)
|
||||
\`\`\`
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Always use limits** — max 1000 rows per query.
|
||||
2. **Use \`count_wms_entities\` for counts** — never fetch rows just to count them.
|
||||
3. **Verify enum values first** — Reading model uses string enum names.
|
||||
4. **Use specific filters** — narrow results at the API level.
|
||||
|
||||
---
|
||||
|
||||
**Note:** All queries respect the maximum limit of 1000 rows configured in the server.
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* WMS Entity Resources
|
||||
* Provides overview of WMS entity types available via Query API
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'wms://entities',
|
||||
name: 'WMS Entities Overview',
|
||||
description: 'Overview of all WMS entity types available for querying',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'wms://entities':
|
||||
content = getEntitiesOverview();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown WMS entity resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getEntitiesOverview() {
|
||||
return `# WMS Entities — Reference Guide
|
||||
|
||||
## Tools to use
|
||||
|
||||
- **\`count_wms_entities\`** — count with optional filter (use for "combien", "nombre de")
|
||||
- **\`query_wms_entities\`** — fetch rows with select/filter/limit
|
||||
- **\`get_entity_schema\`** — discover field names by fetching one sample record
|
||||
- **\`call_query_api\`** — raw LINQ query, full control
|
||||
|
||||
---
|
||||
|
||||
## Note importante : Reading model vs Writing model
|
||||
|
||||
Le \`QueryExecute\` (Context.*) interroge le **Reading model** — les champs de statut sont des **strings**, pas des entiers ni des enums C#.
|
||||
|
||||
---
|
||||
|
||||
## OutboundOrders — Ordres de sortie
|
||||
|
||||
**Reading model:** \`Mecalux.ITSW.EasyWMS.Modules.Expeditions.Reading.Domain.OutboundOrder\`
|
||||
**Status field:** \`OutboundOrderStatus\` → **string**
|
||||
|
||||
| Statut | Filtre LINQ |
|
||||
|---|---|
|
||||
| Lancé | \`z.OutboundOrderStatus == "Launched"\` |
|
||||
| En attente | \`z.OutboundOrderStatus == "Waiting"\` |
|
||||
| En création | \`z.OutboundOrderStatus == "Creating"\` |
|
||||
|
||||
**Champs clés :** \`Code\`, \`OutboundOrderStatus\`, \`WarehouseCode\`, \`AccountCode\`, \`RouteCode\`, \`Priority\`, \`AssignedUser\`, \`NumPendingTasks\`, \`NumReleasedLines\`, \`HasTroubles\`, \`ShippingDeadline\`
|
||||
|
||||
**Exemples:**
|
||||
\`\`\`
|
||||
// Compter les ODS lancés
|
||||
count_wms_entities(entity_type="OutboundOrders", filter='z.OutboundOrderStatus == "Launched"')
|
||||
|
||||
// Lister les ODS lancés avec infos clés
|
||||
query_wms_entities(entity_type="OutboundOrders",
|
||||
select_expression="z => new { z.Code, z.OutboundOrderStatus, z.AccountCode, z.NumPendingTasks, z.HasTroubles }",
|
||||
filter='z.OutboundOrderStatus == "Launched"', limit=100)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## InboundOrders — Ordres d'entrée
|
||||
|
||||
**Status field:** \`InboundStatus\` → **string**
|
||||
|
||||
| Statut | Filtre LINQ |
|
||||
|---|---|
|
||||
| Réception en attente | \`z.InboundStatus == "ReceptionPending"\` |
|
||||
| Réception en cours | \`z.InboundStatus == "Receiving"\` |
|
||||
| Complété | \`z.InboundStatus == "Completed"\` |
|
||||
| Reçu partiellement | \`z.InboundStatus == "PartiallyReceived"\` |
|
||||
| Annulé | \`z.InboundStatus == "Cancelled"\` |
|
||||
| Fermeture | \`z.InboundStatus == "Closing"\` |
|
||||
|
||||
---
|
||||
|
||||
## Tasks — Tâches
|
||||
|
||||
**Status field:** \`TaskStatus\` → **string** (valeurs à confirmer via get_entity_schema)
|
||||
|
||||
\`\`\`
|
||||
// Schéma exact avec valeurs d'exemple
|
||||
get_entity_schema(entity_type="Tasks")
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Kits
|
||||
|
||||
\`\`\`
|
||||
count_wms_entities(entity_type="Kits", filter="z.IsEnable == true")
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Autres entités disponibles (Reading model, 278 au total)
|
||||
|
||||
**Opérations :** Tasks, Stocks, ProductLocations, ContainerLock, CountOrder, CountOrderLine
|
||||
**Entrepôt :** Location, Aisle, AisleType, Division, Container, ContainerType
|
||||
**Entrées :** InboundOrders, Receptions, InboundClass
|
||||
**Sorties :** OutboundOrders, Shipment, OutboundClass, Wave
|
||||
**Référentiel :** Products, Accounts, Suppliers, Kits, Aliases, Agency, Route
|
||||
|
||||
**Pour connaître les champs exacts d'une entité :**
|
||||
1. \`get_entity_metadata(entity_name="...")\` — métadonnées officielles via l'API
|
||||
2. \`get_entity_schema(entity_type="...")\` — exemple live avec tous les champs
|
||||
|
||||
---
|
||||
|
||||
## Note sur \`Take()\`
|
||||
|
||||
Certaines entités (dont OutboundOrders) peuvent retourner HTTP 500 si \`Take()\` est ajouté.
|
||||
- **count_wms_entities** n'utilise jamais \`Take()\` → toujours sûr
|
||||
- **query_wms_entities** utilise \`Take(limit)\` → préférer une limite raisonnable (100-500)
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Workflow Resources
|
||||
* Provides context about workflows available via API
|
||||
*/
|
||||
|
||||
function listResources() {
|
||||
return [
|
||||
{
|
||||
uri: 'workflows://overview',
|
||||
name: 'Workflow Overview',
|
||||
description: 'Overview of workflow system and available categories',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function readResource(uri) {
|
||||
let content;
|
||||
|
||||
switch (uri) {
|
||||
case 'workflows://overview':
|
||||
content = getWorkflowOverview();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown workflow resource: ${uri}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text: content,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkflowOverview() {
|
||||
return `# WMS Workflows
|
||||
|
||||
## Overview
|
||||
|
||||
Workflows are retrieved via the **Workflow API** (\`/AD/api/Workflow/GetByApplication\`).
|
||||
|
||||
**Total Workflows:** ~3712
|
||||
**Data Source:** API endpoint (lazy loaded with 1-hour cache)
|
||||
**Pagination:** Configurable page size (default: 5000)
|
||||
|
||||
## Available Tools
|
||||
|
||||
Use these tools to interact with workflows:
|
||||
|
||||
### \`search_workflows\`
|
||||
Search workflows by name, description, or code.
|
||||
- Supports category filtering
|
||||
- Returns workflow metadata
|
||||
- Workflows are cached after first request
|
||||
|
||||
### \`get_workflow_details\`
|
||||
Get complete details of a specific workflow by ID or code.
|
||||
|
||||
### \`list_workflow_categories\`
|
||||
List all available workflow categories with statistics.
|
||||
|
||||
## Common Workflow Categories
|
||||
|
||||
- **EasyWMS**: Core WMS workflows
|
||||
- **CustomApplication**: Custom workflows
|
||||
- **Deliveries**: Delivery and shipping workflows
|
||||
- **AccountDirective**: Account management workflows
|
||||
- **Notifications**: Notification workflows
|
||||
- **LaborManagement**: Labor management workflows
|
||||
|
||||
## Usage Examples
|
||||
|
||||
\`\`\`
|
||||
# Search for order-related workflows
|
||||
search_workflows(query="Order", limit=20)
|
||||
|
||||
# Get workflow details
|
||||
get_workflow_details(workflow_id="abc123")
|
||||
|
||||
# List all categories
|
||||
list_workflow_categories()
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
**Note:** Workflows are fetched from the API only when first requested, then cached for 1 hour to improve performance.
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResources,
|
||||
readResource,
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Application Dictionary Service
|
||||
* Handles fetching of all AD element types (Commands, Queries, Dialogs, Views, etc.)
|
||||
* Implements lazy loading and caching similar to workflow-service.js
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
|
||||
// Cache state - one cache per element type
|
||||
const cache = {};
|
||||
const cacheTimestamps = {};
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour
|
||||
|
||||
// Invalidate all caches when profile changes — AD elements are per-tenant.
|
||||
profileManager.onSwitch(() => invalidateCache());
|
||||
|
||||
// Element types and their recommended page sizes
|
||||
// Note: WorkflowAction and WritingModel removed (404 Not Found on API)
|
||||
const AD_ELEMENT_TYPES = {
|
||||
// Heavy types requiring pagination
|
||||
Workflow: 5000,
|
||||
View: 200,
|
||||
Resource: 15000,
|
||||
|
||||
// Light types - fetch all at once
|
||||
Command: 100000,
|
||||
Dashboard: 100000,
|
||||
Dialog: 100000,
|
||||
Entity: 100000,
|
||||
Event: 100000,
|
||||
FieldType: 100000,
|
||||
Hook: 100000,
|
||||
List: 100000,
|
||||
Query: 100000,
|
||||
Record: 100000,
|
||||
Relationship: 100000,
|
||||
Report: 100000,
|
||||
Subscription: 100000,
|
||||
TimelineTemplate: 100000,
|
||||
Toggle: 100000,
|
||||
Validator: 100000,
|
||||
ViewGroup: 100000
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if cache is valid for a given element type
|
||||
*/
|
||||
function isCacheValid(elementType) {
|
||||
if (!cache[elementType] || !cacheTimestamps[elementType]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheTimestamps[elementType];
|
||||
return age < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements of a specific type from AD API
|
||||
* Implements lazy loading with caching and pagination
|
||||
*
|
||||
* @param {string} elementType - Type of element (Command, Query, Dialog, etc.)
|
||||
* @returns {Promise<Array>} Array of elements
|
||||
*/
|
||||
async function getElements(elementType) {
|
||||
// Validate element type
|
||||
if (!AD_ELEMENT_TYPES[elementType]) {
|
||||
throw new Error(`Unknown element type: ${elementType}. Valid types: ${Object.keys(AD_ELEMENT_TYPES).join(', ')}`);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (isCacheValid(elementType)) {
|
||||
console.error(`[AD] Cache hit: ${elementType} (${cache[elementType].length} elements)`);
|
||||
return cache[elementType];
|
||||
}
|
||||
|
||||
console.error(`[AD] Cache expired or empty, fetching ${elementType}...`);
|
||||
|
||||
try {
|
||||
let allElements = [];
|
||||
let offset = 0;
|
||||
const pageSize = AD_ELEMENT_TYPES[elementType];
|
||||
const profile = profileManager.getCurrent();
|
||||
const application = profile.application;
|
||||
const tenant = profile.tenant;
|
||||
|
||||
while (true) {
|
||||
const body = [application, tenant, pageSize, offset];
|
||||
|
||||
console.error(`[AD] Fetching ${elementType}: offset=${offset}, pageSize=${pageSize}`);
|
||||
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const elements = response?.entities || [];
|
||||
|
||||
// Check if response is valid
|
||||
if (!elements || elements.length === 0) {
|
||||
console.error(`[AD] No more ${elementType} to fetch`);
|
||||
break;
|
||||
}
|
||||
|
||||
allElements = allElements.concat(elements);
|
||||
console.error(`[AD] Fetched ${elements.length} ${elementType} (total: ${allElements.length})`);
|
||||
|
||||
// If we got less than page size, we've reached the last page
|
||||
if (elements.length < pageSize) {
|
||||
console.error(`[AD] Last page of ${elementType} reached`);
|
||||
break;
|
||||
}
|
||||
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cache[elementType] = allElements;
|
||||
cacheTimestamps[elementType] = Date.now();
|
||||
|
||||
console.error(`[AD] Successfully cached ${allElements.length} ${elementType}`);
|
||||
return allElements;
|
||||
} catch (error) {
|
||||
console.error(`[AD] Error fetching ${elementType}:`, error.message);
|
||||
throw new Error(`Failed to fetch ${elementType}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search elements by query string
|
||||
* @param {string} elementType - Type of element
|
||||
* @param {string} query - Search query (matches name, description, etc.)
|
||||
* @param {number} limit - Maximum results to return
|
||||
*/
|
||||
async function searchElements(elementType, query, limit = 50) {
|
||||
const elements = await getElements(elementType);
|
||||
|
||||
if (!query) {
|
||||
return elements.slice(0, limit);
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const results = elements.filter(e => {
|
||||
const name = (e.name || e.Name || '').toLowerCase();
|
||||
const description = (e.description || e.Description || '').toLowerCase();
|
||||
const code = (e.code || e.Code || '').toLowerCase();
|
||||
|
||||
return name.includes(lowerQuery) ||
|
||||
description.includes(lowerQuery) ||
|
||||
code.includes(lowerQuery);
|
||||
});
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element details by ID or name
|
||||
* @param {string} elementType - Type of element
|
||||
* @param {string|number} elementId - Element ID or name
|
||||
*/
|
||||
async function getElementDetails(elementType, elementId) {
|
||||
const elements = await getElements(elementType);
|
||||
|
||||
// Try to find by Id, id, Code, code, Name, or name
|
||||
const element = elements.find(e =>
|
||||
e.id === elementId ||
|
||||
e.Id === elementId ||
|
||||
e.id === parseInt(elementId) ||
|
||||
e.Id === parseInt(elementId) ||
|
||||
e.Code === elementId ||
|
||||
e.code === elementId ||
|
||||
e.Name === elementId ||
|
||||
e.name === elementId
|
||||
);
|
||||
|
||||
if (!element) {
|
||||
throw new Error(`${elementType} not found: ${elementId}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application summary (count of each element type)
|
||||
* Only loads types that are already cached to avoid long wait times
|
||||
*/
|
||||
function getApplicationSummary() {
|
||||
const summary = {};
|
||||
|
||||
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
|
||||
if (cache[type]) {
|
||||
summary[type] = {
|
||||
count: cache[type].length,
|
||||
cached: true,
|
||||
cacheAge: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null
|
||||
};
|
||||
} else {
|
||||
summary[type] = {
|
||||
count: 0,
|
||||
cached: false,
|
||||
cacheAge: null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific type or all types
|
||||
*/
|
||||
function invalidateCache(elementType = null) {
|
||||
if (elementType) {
|
||||
delete cache[elementType];
|
||||
delete cacheTimestamps[elementType];
|
||||
console.error(`[AD] Cache invalidated: ${elementType}`);
|
||||
} else {
|
||||
Object.keys(cache).forEach(k => {
|
||||
delete cache[k];
|
||||
delete cacheTimestamps[k];
|
||||
});
|
||||
console.error('[AD] All caches invalidated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status
|
||||
*/
|
||||
function getCacheStatus() {
|
||||
const status = {};
|
||||
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
|
||||
status[type] = {
|
||||
cached: !!cache[type],
|
||||
count: cache[type] ? cache[type].length : 0,
|
||||
timestamp: cacheTimestamps[type],
|
||||
age: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null,
|
||||
valid: isCacheValid(type)
|
||||
};
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available element types
|
||||
*/
|
||||
function getAvailableTypes() {
|
||||
return Object.keys(AD_ELEMENT_TYPES).sort();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getElements,
|
||||
searchElements,
|
||||
getElementDetails,
|
||||
getApplicationSummary,
|
||||
invalidateCache,
|
||||
getCacheStatus,
|
||||
getAvailableTypes,
|
||||
AD_ELEMENT_TYPES
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* WMS API Service
|
||||
* Handles OAuth authentication and HTTP requests to WMS APIs
|
||||
* Supports both ApplicationService API and Workflow (AD) API
|
||||
*
|
||||
* All WMS-specific settings (host, credentials, tenant, application) are
|
||||
* read from the active profile via profile-manager.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const profileManager = require('../config/profile-manager');
|
||||
|
||||
class APIService {
|
||||
constructor() {
|
||||
this.token = null;
|
||||
this.refreshToken = null;
|
||||
this.tokenTimestamp = null;
|
||||
this.tokenRefreshThreshold = parseInt(process.env.TOKEN_REFRESH_THRESHOLD) || 1000; // seconds
|
||||
this.tokenMaxAge = parseInt(process.env.TOKEN_MAX_AGE) || 1190; // seconds
|
||||
|
||||
// Create axios instance with SSL verification disabled (for internal servers)
|
||||
this.httpClient = axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
}),
|
||||
timeout: parseInt(process.env.QUERY_TIMEOUT) || 30000
|
||||
});
|
||||
|
||||
// Reset token when the active profile changes — credentials no longer apply.
|
||||
profileManager.onSwitch(() => this.resetToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token age in seconds
|
||||
*/
|
||||
getTokenAge() {
|
||||
if (!this.tokenTimestamp) return Infinity;
|
||||
return (Date.now() - this.tokenTimestamp) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token needs refresh
|
||||
*/
|
||||
needsRefresh() {
|
||||
return this.getTokenAge() > this.tokenRefreshThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and get initial OAuth token
|
||||
*/
|
||||
async authenticate() {
|
||||
const profile = profileManager.getCurrent();
|
||||
try {
|
||||
console.error(`[API] Authenticating profile="${profile.name}" tenant="${profile.tenant}" ...`);
|
||||
|
||||
const response = await this.httpClient.post(
|
||||
profile.tokenUrl,
|
||||
new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
tenant_code: profile.tenant,
|
||||
username: profile.username,
|
||||
password: profile.password
|
||||
}).toString(),
|
||||
{
|
||||
headers: {
|
||||
'Authorization': profile.auth,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.token = response.data.access_token;
|
||||
this.refreshToken = response.data.refresh_token;
|
||||
this.tokenTimestamp = Date.now();
|
||||
|
||||
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
|
||||
return this.token;
|
||||
} catch (error) {
|
||||
console.error('[API] Authentication failed:', error.message);
|
||||
throw new Error(`Authentication failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth token
|
||||
*/
|
||||
async refreshOAuthToken() {
|
||||
const tokenAge = this.getTokenAge();
|
||||
|
||||
try {
|
||||
// If token is too old (>= maxAge), use password grant
|
||||
if (tokenAge >= this.tokenMaxAge) {
|
||||
console.error('[API] Token too old, re-authenticating with password...');
|
||||
return await this.authenticate();
|
||||
}
|
||||
|
||||
// Otherwise use refresh_token grant
|
||||
console.error('[API] Refreshing token with refresh_token grant...');
|
||||
|
||||
const profile = profileManager.getCurrent();
|
||||
const response = await this.httpClient.post(
|
||||
profile.tokenUrl,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.refreshToken
|
||||
}).toString(),
|
||||
{
|
||||
headers: {
|
||||
'Authorization': profile.auth,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.token = response.data.access_token;
|
||||
this.refreshToken = response.data.refresh_token;
|
||||
this.tokenTimestamp = Date.now();
|
||||
|
||||
console.error('[API] Token refreshed successfully');
|
||||
return this.token;
|
||||
} catch (error) {
|
||||
console.error('[API] Token refresh failed, re-authenticating:', error.message);
|
||||
return await this.authenticate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure token is valid before making requests
|
||||
*/
|
||||
async ensureTokenValid() {
|
||||
if (!this.token) {
|
||||
await this.authenticate();
|
||||
} else if (this.needsRefresh()) {
|
||||
await this.refreshOAuthToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request to WMS API
|
||||
* @param {string} endpoint - API endpoint (e.g., '/QueryExecute' or '/AD/api/Workflow/GetByApplication')
|
||||
* @param {object} data - Request body
|
||||
* @param {boolean} useAdApi - Use AD API base URL instead of ApplicationService
|
||||
*/
|
||||
async post(endpoint, data, useAdApi = false) {
|
||||
await this.ensureTokenValid();
|
||||
|
||||
const profile = profileManager.getCurrent();
|
||||
const baseUrl = useAdApi ? profile.workflowApiBase : profile.apiBaseUrl;
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
console.error(`[API] POST ${endpoint}`);
|
||||
|
||||
const response = await this.httpClient.post(url, data, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[API] Request failed: ${error.message}`);
|
||||
|
||||
// If unauthorized, try refreshing token and retry once
|
||||
if (error.response?.status === 401) {
|
||||
console.error('[API] Unauthorized, refreshing token and retrying...');
|
||||
await this.refreshOAuthToken();
|
||||
|
||||
const retryResponse = await this.httpClient.post(url, data, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return retryResponse.data;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request to WMS API
|
||||
* @param {string} endpoint - API endpoint
|
||||
* @param {object} params - Query parameters
|
||||
* @param {boolean} useAdApi - Use AD API base URL instead of ApplicationService
|
||||
*/
|
||||
async get(endpoint, params = {}, useAdApi = false) {
|
||||
await this.ensureTokenValid();
|
||||
|
||||
const profile = profileManager.getCurrent();
|
||||
const baseUrl = useAdApi ? profile.workflowApiBase : profile.apiBaseUrl;
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
console.error(`[API] GET ${endpoint}`);
|
||||
|
||||
const response = await this.httpClient.get(url, {
|
||||
params,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[API] Request failed: ${error.message}`);
|
||||
|
||||
// If unauthorized, try refreshing token and retry once
|
||||
if (error.response?.status === 401) {
|
||||
console.error('[API] Unauthorized, refreshing token and retrying...');
|
||||
await this.refreshOAuthToken();
|
||||
|
||||
const retryResponse = await this.httpClient.get(url, {
|
||||
params,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return retryResponse.data;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a LINQ query via WMS QueryExecute API.
|
||||
* Take/Skip/Select are top-level API params. OrderBy MUST be embedded in the Expression.
|
||||
* The Expression should contain: base path + optional Where + OrderBy (required when Take is used):
|
||||
* 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 }
|
||||
*/
|
||||
async executeQuery(expression, options = {}) {
|
||||
const { take, skip, select, orderBy, inlineCount } = options;
|
||||
|
||||
const body = {
|
||||
Application: profileManager.getCurrent().application,
|
||||
QueryType: 0, // Reading = 0 (status fields are strings), Writing = 1 (enums)
|
||||
Expression: expression,
|
||||
};
|
||||
|
||||
if (take != null) body.Take = take;
|
||||
if (skip != null) body.Skip = skip;
|
||||
if (select != null) body.Select = select;
|
||||
if (orderBy != null) body.OrderBy = orderBy;
|
||||
if (inlineCount != null) body.InlineCount = inlineCount;
|
||||
|
||||
const raw = await this.post('/QueryExecute', body);
|
||||
return this._parseQueryResponse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse QueryExecute response — handles both response shapes:
|
||||
* - Flat array (legacy / full-LINQ-in-expression queries)
|
||||
* - Table object { Table: { Columns, Rows } }
|
||||
*/
|
||||
_parseQueryResponse(raw) {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw?.Table?.Rows) {
|
||||
return raw.Table.Rows
|
||||
.filter(Boolean)
|
||||
.map(row => row.Values ?? row);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a scalar LINQ query (Count, Sum, etc.) via QueryScalarExecute.
|
||||
* Returns the scalar value directly.
|
||||
* @param {string} fullExpression - e.g. "Context.OutboundOrders.Where(...).Count()"
|
||||
*/
|
||||
async executeScalarQuery(fullExpression) {
|
||||
const body = {
|
||||
Application: profileManager.getCurrent().application,
|
||||
QueryType: 0, // Reading = 0 — string enum names in filters (Writing=1 fails with enum comparisons)
|
||||
Expression: fullExpression,
|
||||
};
|
||||
|
||||
const result = await this.post('/QueryScalarExecute', body);
|
||||
return result?.Rows?.[0]?.Values?.Column ?? result;
|
||||
}
|
||||
|
||||
/**
|
||||
* List queryable entities via Metadata API.
|
||||
* @param {string} [applicationName] - Override application name (defaults to active profile's app)
|
||||
*/
|
||||
async getMetadataEntities(applicationName) {
|
||||
const app = applicationName || profileManager.getCurrent().application;
|
||||
return await this.get(`/Metadata/Entities?applicationName=${encodeURIComponent(app)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get properties of a specific entity via Metadata API.
|
||||
* @param {string} assemblyFullName - e.g. "Mecalux.ITSW.EasyWMS.Modules.Contracts"
|
||||
* @param {string} fullName - e.g. "Mecalux.ITSW.EasyWMS.Modules.Contracts.Domain.OutboundOrder"
|
||||
*/
|
||||
async getEntityProperties(assemblyFullName, fullName) {
|
||||
const qs = `assemblyFullName=${encodeURIComponent(assemblyFullName)}&fullName=${encodeURIComponent(fullName)}`;
|
||||
return await this.get(`/Metadata/EntityProperties?${qs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GenericSearch categories (indexed document types).
|
||||
* @param {string} [applicationName]
|
||||
*/
|
||||
async getSearchCategories(applicationName) {
|
||||
const app = applicationName || profileManager.getCurrent().application;
|
||||
return await this.get(`/GenericSearch/Categories?applicationName=${encodeURIComponent(app)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search via GenericSearch API.
|
||||
* @param {string} query - Search keyword
|
||||
* @param {string[]} categories - Document categories to search (from getSearchCategories)
|
||||
* @param {number} take - Max results
|
||||
* @param {number} skip - Offset
|
||||
* @param {string} [applicationName]
|
||||
*/
|
||||
async genericSearch(query, categories = [], take = 20, skip = 0, applicationName) {
|
||||
const body = {
|
||||
ApplicationName: applicationName || profileManager.getCurrent().application,
|
||||
Query: query,
|
||||
Categories: categories,
|
||||
Take: take,
|
||||
Skip: skip,
|
||||
};
|
||||
return await this.post('/GenericSearch/Search', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a WMS command
|
||||
* @param {string} commandName - Full command name
|
||||
* @param {object} properties - Command properties
|
||||
*/
|
||||
async executeCommand(commandName, properties) {
|
||||
// Use command_name as-is — appending assembly suffix causes FileLoadException.
|
||||
// The InternalCommandName from AD already contains the correct fully-qualified name.
|
||||
const command = [{
|
||||
Name: commandName,
|
||||
Properties: properties
|
||||
}];
|
||||
|
||||
return await this.post('/CommandExecute', command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connection
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.ensureTokenValid();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[API] Connection test failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset token (force re-authentication)
|
||||
*/
|
||||
resetToken() {
|
||||
if (this.token) {
|
||||
console.error('[API] Token reset');
|
||||
}
|
||||
this.token = null;
|
||||
this.refreshToken = null;
|
||||
this.tokenTimestamp = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance = null;
|
||||
|
||||
module.exports = {
|
||||
getInstance() {
|
||||
if (!instance) {
|
||||
instance = new APIService();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,412 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const profileManager = require('../config/profile-manager');
|
||||
|
||||
/**
|
||||
* Service de gestion des logs
|
||||
* Supporte plusieurs emplacements de logs :
|
||||
* - IIS logs: \\<host>\inetpub\logs\LogFiles\Mecalux (avec sous-dossiers)
|
||||
* - ETL middleware: \\<host>\ProgramData\Mecalux\ETLLogs
|
||||
*
|
||||
* LOGS_PATH dans .env peut contenir le placeholder "{host}" qui est remplacé
|
||||
* par le host du profil actif à chaque appel.
|
||||
*
|
||||
* En mode SaaS (profile.saas === true) l'accès aux logs est bloqué car le
|
||||
* WMS est hébergé dans le cloud et le filesystem n'est pas joignable.
|
||||
*/
|
||||
|
||||
const DEFAULT_LOG_PATHS = [
|
||||
'\\\\{host}\\inetpub\\logs\\LogFiles\\Mecalux',
|
||||
'\\\\{host}\\ProgramData\\Mecalux\\ETLLogs',
|
||||
];
|
||||
|
||||
const LOG_FILE_PATTERN = process.env.LOG_FILE_PATTERN || '*.log';
|
||||
|
||||
/**
|
||||
* Retourne les chemins de logs à scanner pour le profil actif.
|
||||
* Substitue {host} par l'hostname du profil.
|
||||
* Lance une erreur si le profil est en mode SaaS.
|
||||
*/
|
||||
function getLogPaths() {
|
||||
const profile = profileManager.getCurrent();
|
||||
if (profile.saas) {
|
||||
throw new Error(`Log access is disabled for SaaS profile "${profile.name}". The WMS is cloud-hosted — local log files are not reachable. Use the WMS API (query/command/workflow tools) instead.`);
|
||||
}
|
||||
|
||||
const templates = process.env.LOGS_PATH
|
||||
? process.env.LOGS_PATH.split(';').map(p => p.trim()).filter(Boolean)
|
||||
: DEFAULT_LOG_PATHS;
|
||||
|
||||
return templates.map(t => t.replace(/\{host\}/g, profile.host));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanne récursivement un dossier pour trouver tous les fichiers .log
|
||||
* @param {string} dir - Dossier à scanner
|
||||
* @param {Array} fileList - Liste accumulée des fichiers (pour la récursion)
|
||||
* @returns {Promise<Array>} - Liste des fichiers trouvés
|
||||
*/
|
||||
async function scanLogsRecursively(dir, fileList = []) {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Récursion dans les sous-dossiers
|
||||
await scanLogsRecursively(fullPath, fileList);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.log')) {
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
fileList.push({
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
relativePath: path.relative(dir, fullPath),
|
||||
directory: path.dirname(fullPath),
|
||||
size: stats.size,
|
||||
modified: stats.mtime,
|
||||
});
|
||||
} catch (statErr) {
|
||||
// Ignorer les fichiers inaccessibles
|
||||
console.error(`Cannot access file ${fullPath}: ${statErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ne pas planter si un dossier n'existe pas ou n'est pas accessible
|
||||
console.error(`Cannot scan directory ${dir}: ${err.message}`);
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les fichiers de logs dans tous les répertoires configurés
|
||||
* @returns {Promise<Array>} - Liste des fichiers de logs
|
||||
*/
|
||||
async function listLogFiles() {
|
||||
const logPaths = getLogPaths();
|
||||
const allFiles = [];
|
||||
|
||||
// Scanner chaque chemin configuré
|
||||
for (const logPath of logPaths) {
|
||||
const files = await scanLogsRecursively(logPath);
|
||||
allFiles.push(...files);
|
||||
}
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
const pathsList = logPaths.join(', ');
|
||||
throw new Error(`No log files found in configured paths: ${pathsList}`);
|
||||
}
|
||||
|
||||
// Trier par date de modification (plus récent en premier)
|
||||
allFiles.sort((a, b) => b.modified - a.modified);
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le fichier de log le plus récent
|
||||
* @returns {Promise<string>} - Chemin du fichier le plus récent
|
||||
*/
|
||||
async function findLatestLogFile() {
|
||||
const files = await listLogFiles();
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('No log files found');
|
||||
}
|
||||
|
||||
return files[0].path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier existe
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le chemin d'un fichier de log en 3 passes :
|
||||
* 1. Chemin direct (LOGS_PATH + logFile, peut inclure un sous-dossier)
|
||||
* 2. Nom de fichier exact dans chaque sous-dossier immédiat de LOGS_PATH
|
||||
* 3. Correspondance floue (partielle, insensible à la casse)
|
||||
* En cas d'échec, retourne une erreur avec la liste des fichiers disponibles.
|
||||
* @param {string} logFile - Nom ou chemin partiel du fichier
|
||||
* @returns {Promise<string>} - Chemin absolu résolu
|
||||
*/
|
||||
async function resolveLogFilePath(logFile) {
|
||||
// Si chemin absolu fourni directement
|
||||
if (path.isAbsolute(logFile)) {
|
||||
if (await fileExists(logFile)) return logFile;
|
||||
throw new Error(`File not found: ${logFile}`);
|
||||
}
|
||||
|
||||
const basePath = getLogPaths()[0];
|
||||
|
||||
// Passe 1 — Chemin direct (ex: "ApplicationDictionary\ApplicationDictionary.log")
|
||||
const directPath = path.join(basePath, logFile);
|
||||
if (await fileExists(directPath)) return directPath;
|
||||
|
||||
// Passe 2 & 3 — Scanner les sous-dossiers immédiats
|
||||
const fileName = path.basename(logFile);
|
||||
const fileNameNoExt = path.basename(fileName, path.extname(fileName)).toLowerCase();
|
||||
let subEntries = [];
|
||||
try {
|
||||
subEntries = await fs.readdir(basePath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
console.error(`Cannot read base log path ${basePath}: ${err.message}`);
|
||||
}
|
||||
|
||||
const subDirs = subEntries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
|
||||
// Passe 2 — Nom exact dans les sous-dossiers
|
||||
for (const dir of subDirs) {
|
||||
const candidate = path.join(basePath, dir, fileName);
|
||||
if (await fileExists(candidate)) return candidate;
|
||||
}
|
||||
|
||||
// Passe 3 — Correspondance floue
|
||||
for (const dir of subDirs) {
|
||||
let dirFiles = [];
|
||||
try {
|
||||
dirFiles = await fs.readdir(path.join(basePath, dir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of dirFiles) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const fNoExt = path.basename(f, path.extname(f)).toLowerCase();
|
||||
if (fNoExt.includes(fileNameNoExt) || fileNameNoExt.includes(fNoExt)) {
|
||||
return path.join(basePath, dir, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aucun fichier trouvé — construire une liste utile
|
||||
const available = [];
|
||||
for (const dir of subDirs) {
|
||||
try {
|
||||
const files = await fs.readdir(path.join(basePath, dir));
|
||||
files.filter(f => f.endsWith('.log')).forEach(f => available.push(`${dir}\\${f}`));
|
||||
} catch { /* ignorer */ }
|
||||
}
|
||||
|
||||
const availableMsg = available.length > 0
|
||||
? `Available log files:\n${available.join('\n')}`
|
||||
: `No log files found in ${basePath}`;
|
||||
|
||||
throw new Error(`Log file "${logFile}" not found.\n${availableMsg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les dernières lignes d'un fichier de log
|
||||
* @param {number} count - Nombre de lignes à lire
|
||||
* @param {string} logFile - Nom ou chemin partiel du fichier (optionnel, prend le plus récent par défaut)
|
||||
* @returns {Promise<Object>} - Lignes du log
|
||||
*/
|
||||
async function readRecentLogs(count = 100, logFile = null) {
|
||||
try {
|
||||
const filePath = logFile
|
||||
? await resolveLogFilePath(logFile)
|
||||
: await findLatestLogFile();
|
||||
|
||||
// Lire le fichier complet
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim() !== '');
|
||||
|
||||
// Prendre les dernières lignes
|
||||
const recentLines = lines.slice(-count);
|
||||
|
||||
return {
|
||||
file: path.basename(filePath),
|
||||
fullPath: filePath,
|
||||
totalLines: lines.length,
|
||||
returnedLines: recentLines.length,
|
||||
lines: recentLines,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read log file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche un mot-clé dans les fichiers de logs
|
||||
* @param {string} keyword - Mot-clé à rechercher
|
||||
* @param {number} maxResults - Nombre maximum de résultats
|
||||
* @param {number} contextLines - Nombre de lignes de contexte avant/après
|
||||
* @returns {Promise<Object>} - Résultats de la recherche
|
||||
*/
|
||||
async function searchLogs(keyword, maxResults = 50, contextLines = 2) {
|
||||
try {
|
||||
const files = await listLogFiles();
|
||||
const results = [];
|
||||
const searchPattern = keyword.toLowerCase();
|
||||
|
||||
// Rechercher dans chaque fichier (du plus récent au plus ancien)
|
||||
for (const file of files) {
|
||||
if (results.length >= maxResults) break;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(file.path, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Rechercher dans chaque ligne
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (results.length >= maxResults) break;
|
||||
|
||||
const line = lines[i];
|
||||
if (line.toLowerCase().includes(searchPattern)) {
|
||||
// Extraire le contexte (lignes avant et après)
|
||||
const start = Math.max(0, i - contextLines);
|
||||
const end = Math.min(lines.length, i + contextLines + 1);
|
||||
const context = lines.slice(start, end);
|
||||
|
||||
results.push({
|
||||
file: file.name,
|
||||
fullPath: file.path,
|
||||
directory: file.directory,
|
||||
lineNumber: i + 1,
|
||||
line: line.trim(),
|
||||
context: context.map((l, idx) => ({
|
||||
lineNumber: start + idx + 1,
|
||||
text: l.trim(),
|
||||
isMatch: start + idx === i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (readErr) {
|
||||
// Ignorer les fichiers illisibles
|
||||
console.error(`Cannot read file ${file.path}: ${readErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keyword,
|
||||
totalResults: results.length,
|
||||
results,
|
||||
scannedPaths: getLogPaths(),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Search failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des erreurs dans les logs récents
|
||||
* @param {number} maxResults - Nombre maximum de résultats
|
||||
* @returns {Promise<Object>} - Erreurs trouvées
|
||||
*/
|
||||
async function findRecentErrors(maxResults = 20) {
|
||||
const errorPatterns = ['error', 'exception', 'failed', 'fatal', 'critical'];
|
||||
const allErrors = [];
|
||||
|
||||
try {
|
||||
for (const pattern of errorPatterns) {
|
||||
if (allErrors.length >= maxResults) break;
|
||||
|
||||
const results = await searchLogs(pattern, maxResults - allErrors.length, 1);
|
||||
allErrors.push(...results.results);
|
||||
}
|
||||
|
||||
// Dédupliquer par numéro de ligne et fichier
|
||||
const unique = allErrors.filter(
|
||||
(error, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(e) => e.fullPath === error.fullPath && e.lineNumber === error.lineNumber
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
totalErrors: unique.length,
|
||||
errors: unique.slice(0, maxResults),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to find errors: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit tout le contenu d'un fichier de log spécifique
|
||||
* @param {string} logFilePath - Chemin complet du fichier
|
||||
* @returns {Promise<Object>} - Contenu du fichier
|
||||
*/
|
||||
async function readFullLog(logFilePath) {
|
||||
try {
|
||||
const content = await fs.readFile(logFilePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.trim() !== '');
|
||||
|
||||
return {
|
||||
file: path.basename(logFilePath),
|
||||
fullPath: logFilePath,
|
||||
totalLines: lines.length,
|
||||
content: lines,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read log file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient des statistiques sur les logs
|
||||
* @returns {Promise<Object>} - Statistiques
|
||||
*/
|
||||
async function getLogStats() {
|
||||
try {
|
||||
const files = await listLogFiles();
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
// Grouper par dossier
|
||||
const byDirectory = {};
|
||||
files.forEach(file => {
|
||||
const dir = file.directory;
|
||||
if (!byDirectory[dir]) {
|
||||
byDirectory[dir] = {
|
||||
directory: dir,
|
||||
count: 0,
|
||||
totalSize: 0,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
byDirectory[dir].count++;
|
||||
byDirectory[dir].totalSize += file.size;
|
||||
byDirectory[dir].files.push({
|
||||
name: file.name,
|
||||
sizeMB: (file.size / (1024 * 1024)).toFixed(2),
|
||||
modified: file.modified.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
configuredPaths: getLogPaths(),
|
||||
totalFiles: files.length,
|
||||
totalSize,
|
||||
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||
oldestFile: files[files.length - 1]?.name,
|
||||
newestFile: files[0]?.name,
|
||||
byDirectory: Object.values(byDirectory).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get log stats: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listLogFiles,
|
||||
findLatestLogFile,
|
||||
readRecentLogs,
|
||||
searchLogs,
|
||||
findRecentErrors,
|
||||
readFullLog,
|
||||
getLogStats,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* WMS Query Service
|
||||
* Helper functions for building and executing LINQ queries via WMS API
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const constants = require('../config/constants');
|
||||
|
||||
/**
|
||||
* Build a LINQ select expression
|
||||
* @param {string} entityType - Entity type (Products, Containers, etc.)
|
||||
* @param {string} selectFields - Fields to select (e.g., "z => new { z.Id, z.Name }")
|
||||
* @param {string|null} filter - Optional filter (e.g., "z.Status == 'Active'")
|
||||
* @param {number|null} limit - Optional limit
|
||||
*/
|
||||
/**
|
||||
* Query WMS entities with LINQ expression
|
||||
* @param {string} entityType - Entity type
|
||||
* @param {string} selectExpression - LINQ select expression
|
||||
* @param {string|null} filter - Optional filter
|
||||
* @param {number} limit - Result limit
|
||||
*/
|
||||
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
|
||||
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}`;
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
expression += `.Where(${whereExpr})`;
|
||||
}
|
||||
// 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}`);
|
||||
|
||||
const result = await apiService.executeQuery(expression, {
|
||||
take: actualLimit,
|
||||
select: selectExpression !== 'z => z' ? selectExpression : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
entityType,
|
||||
expression,
|
||||
limit: actualLimit,
|
||||
count: Array.isArray(result) ? result.length : 0,
|
||||
data: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WMSQuery] Query failed:`, error.message);
|
||||
throw new Error(`Query failed for ${entityType}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query entity by ID
|
||||
* @param {string} entityType - Entity type
|
||||
* @param {string|number} id - Entity ID
|
||||
*/
|
||||
async function queryById(entityType, id) {
|
||||
const filter = `z => z.Id == ${id}`;
|
||||
const result = await queryEntities(entityType, 'z => z', filter, 1);
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error(`${entityType} with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return result.data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search entities by keyword in common fields
|
||||
* @param {string} entityType - Entity type
|
||||
* @param {string} keyword - Search keyword
|
||||
* @param {number} limit - Result limit
|
||||
*/
|
||||
async function searchEntities(entityType, keyword, limit = 50) {
|
||||
// Common searchable fields by entity type
|
||||
const searchFields = {
|
||||
'Products': ['Code', 'Description', 'Reference'],
|
||||
'Containers': ['Code', 'Reference'],
|
||||
'Tasks': ['Code', 'Reference'],
|
||||
'InboundOrders': ['Code', 'Reference'],
|
||||
'OutboundOrders': ['Code', 'Reference'],
|
||||
'Accounts': ['Code', 'Name'],
|
||||
'Suppliers': ['Code', 'Name']
|
||||
};
|
||||
|
||||
const fields = searchFields[entityType] || ['Code'];
|
||||
|
||||
// Build filter for multiple fields (OR condition)
|
||||
const conditions = fields.map(field => `z.${field}.Contains("${keyword}")`).join(' || ');
|
||||
const filter = `z => ${conditions}`;
|
||||
|
||||
try {
|
||||
return await queryEntities(entityType, 'z => z', filter, limit);
|
||||
} catch (error) {
|
||||
// If Contains doesn't work, try simpler approach
|
||||
console.error(`[WMSQuery] Search failed, trying alternative method:`, error.message);
|
||||
return await queryEntities(entityType, 'z => z', null, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity schema information (example entity to show available fields)
|
||||
* @param {string} entityType - Entity type
|
||||
*/
|
||||
async function getEntitySchema(entityType) {
|
||||
try {
|
||||
// Query one entity to see its structure
|
||||
const result = await queryEntities(entityType, 'z => z', null, 1);
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
return {
|
||||
entityType,
|
||||
fields: [],
|
||||
message: `No ${entityType} found to inspect schema`
|
||||
};
|
||||
}
|
||||
|
||||
const sample = result.data[0];
|
||||
const fields = Object.keys(sample).map(key => ({
|
||||
name: key,
|
||||
type: typeof sample[key],
|
||||
sample: sample[key]
|
||||
}));
|
||||
|
||||
return {
|
||||
entityType,
|
||||
fields,
|
||||
totalFields: fields.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WMSQuery] Failed to get schema for ${entityType}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count entities with optional filter
|
||||
* @param {string} entityType - Entity type
|
||||
* @param {string|null} filter - Optional filter
|
||||
*/
|
||||
async function countEntities(entityType, filter = null) {
|
||||
try {
|
||||
// Build: Context.Entity.Where(...).Count()
|
||||
const parts = [`Context.${entityType}`];
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
parts.push(`Where(${whereExpr})`);
|
||||
}
|
||||
parts.push('Count()');
|
||||
const fullExpression = parts.join('.');
|
||||
|
||||
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression}`);
|
||||
|
||||
const count = await apiService.executeScalarQuery(fullExpression);
|
||||
|
||||
return {
|
||||
entityType,
|
||||
filter,
|
||||
count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WMSQuery] Count failed:`, error.message);
|
||||
throw new Error(`Count failed for ${entityType}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryEntities,
|
||||
queryById,
|
||||
searchEntities,
|
||||
getEntitySchema,
|
||||
countEntities,
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Workflow Service
|
||||
* Handles workflow fetching with lazy loading and caching
|
||||
* Workflows are only loaded when first requested (not at startup)
|
||||
*/
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
|
||||
// Cache state
|
||||
let workflowCache = null;
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
|
||||
|
||||
// Clear cache when profile changes — workflows are per-tenant, so the previous
|
||||
// profile's cache is meaningless after a switch.
|
||||
profileManager.onSwitch(() => clearCache());
|
||||
|
||||
/**
|
||||
* Check if cache is still valid
|
||||
*/
|
||||
function isCacheValid() {
|
||||
if (!workflowCache || !cacheTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheTimestamp;
|
||||
return age < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all workflows from API with pagination
|
||||
* Uses high page size (5000) to minimize API calls
|
||||
*/
|
||||
async function fetchAllWorkflows() {
|
||||
// Check cache validity
|
||||
if (isCacheValid()) {
|
||||
console.error('[Workflow] Using cached data');
|
||||
return workflowCache;
|
||||
}
|
||||
|
||||
console.error('[Workflow] Cache expired or empty, fetching from API...');
|
||||
|
||||
try {
|
||||
let allWorkflows = [];
|
||||
let offset = 0;
|
||||
const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000;
|
||||
const profile = profileManager.getCurrent();
|
||||
const application = profile.application;
|
||||
const tenant = profile.tenant;
|
||||
|
||||
while (true) {
|
||||
const body = [application, tenant, pageSize, offset];
|
||||
|
||||
console.error(`[Workflow] Fetching page: offset=${offset}, pageSize=${pageSize}`);
|
||||
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const workflows = response?.entities || [];
|
||||
|
||||
// Check if response is valid
|
||||
if (!workflows || workflows.length === 0) {
|
||||
console.error('[Workflow] No more workflows to fetch');
|
||||
break;
|
||||
}
|
||||
|
||||
allWorkflows = allWorkflows.concat(workflows);
|
||||
console.error(`[Workflow] Fetched ${workflows.length} workflows (total: ${allWorkflows.length})`);
|
||||
|
||||
// If we got less than page size, we've reached the last page
|
||||
if (workflows.length < pageSize) {
|
||||
console.error('[Workflow] Last page reached');
|
||||
break;
|
||||
}
|
||||
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
workflowCache = allWorkflows;
|
||||
cacheTimestamp = Date.now();
|
||||
|
||||
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows`);
|
||||
return allWorkflows;
|
||||
} catch (error) {
|
||||
console.error('[Workflow] Error fetching workflows:', error.message);
|
||||
throw new Error(`Failed to fetch workflows: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workflows by query string
|
||||
* @param {string} query - Search query (matches name, description, etc.)
|
||||
* @param {string|null} category - Optional category filter
|
||||
* @param {number} limit - Maximum results to return
|
||||
*/
|
||||
async function searchWorkflows(query, category = null, limit = 50) {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
|
||||
let results = workflows;
|
||||
|
||||
// Filter by query if provided
|
||||
if (query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
results = results.filter(w => {
|
||||
const name = (w.name || w.Name || '').toLowerCase();
|
||||
const description = (w.description || w.Description || '').toLowerCase();
|
||||
const code = (w.code || w.Code || '').toLowerCase();
|
||||
|
||||
return name.includes(lowerQuery) ||
|
||||
description.includes(lowerQuery) ||
|
||||
code.includes(lowerQuery);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by category if provided
|
||||
if (category) {
|
||||
const lowerCategory = category.toLowerCase();
|
||||
results = results.filter(w => {
|
||||
const wfCategory = (w.category || w.Category || '').toLowerCase();
|
||||
return wfCategory.includes(lowerCategory);
|
||||
});
|
||||
}
|
||||
|
||||
// Limit results
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow details by ID
|
||||
* @param {string|number} workflowId - Workflow ID
|
||||
*/
|
||||
async function getWorkflowDetails(workflowId) {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
|
||||
// Try to find by Id, id, Code, code, Name, or name
|
||||
const workflow = workflows.find(w =>
|
||||
w.id === workflowId ||
|
||||
w.Id === workflowId ||
|
||||
w.id === parseInt(workflowId) ||
|
||||
w.Id === parseInt(workflowId) ||
|
||||
w.Code === workflowId ||
|
||||
w.code === workflowId ||
|
||||
w.Name === workflowId ||
|
||||
w.name === workflowId
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow not found: ${workflowId}`);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all workflow categories
|
||||
*/
|
||||
async function listWorkflowCategories() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
|
||||
// Extract unique categories (try both lowercase and uppercase)
|
||||
const categories = new Set();
|
||||
workflows.forEach(w => {
|
||||
const category = w.category || w.Category;
|
||||
if (category) {
|
||||
categories.add(category);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort alphabetically
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow statistics
|
||||
*/
|
||||
async function getWorkflowStats() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
const categories = await listWorkflowCategories();
|
||||
|
||||
// Count workflows per category
|
||||
const categoryCounts = {};
|
||||
workflows.forEach(w => {
|
||||
const cat = w.category || w.Category || 'Uncategorized';
|
||||
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: workflows.length,
|
||||
categories: categories.length,
|
||||
categoryCounts,
|
||||
cacheAge: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workflow cache (force refresh on next request)
|
||||
*/
|
||||
function clearCache() {
|
||||
workflowCache = null;
|
||||
cacheTimestamp = null;
|
||||
console.error('[Workflow] Cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status
|
||||
*/
|
||||
function getCacheStatus() {
|
||||
return {
|
||||
cached: workflowCache !== null,
|
||||
count: workflowCache ? workflowCache.length : 0,
|
||||
timestamp: cacheTimestamp,
|
||||
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
|
||||
valid: isCacheValid()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAllWorkflows,
|
||||
searchWorkflows,
|
||||
getWorkflowDetails,
|
||||
listWorkflowCategories,
|
||||
getWorkflowStats,
|
||||
clearCache,
|
||||
getCacheStatus
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Application Dictionary Tools
|
||||
* MCP tools for interacting with AD elements (Commands, Queries, Dialogs, Views, etc.)
|
||||
*/
|
||||
|
||||
const adService = require('../services/ad-service');
|
||||
|
||||
/**
|
||||
* List available AD tools
|
||||
*/
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'get_application_summary',
|
||||
description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ad_elements',
|
||||
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
description: 'Element type: Command, Query, Dialog, View, Entity, Event, FieldType, Hook, List, Record, Relationship, Report, Resource, Subscription, TimelineTemplate, Toggle, Validator, ViewGroup, Workflow, Dashboard',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of elements to return (default: 100, max: 1000)',
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
required: ['element_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_ad_elements',
|
||||
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
description: 'Element type to search in',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (matches name, description, code)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
required: ['element_type', 'query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ad_element_details',
|
||||
description: 'Get detailed information about a specific AD element by ID or name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element_type: {
|
||||
type: 'string',
|
||||
description: 'Element type',
|
||||
},
|
||||
element_id: {
|
||||
type: 'string',
|
||||
description: 'Element ID or name',
|
||||
},
|
||||
},
|
||||
required: ['element_type', 'element_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_ad_types',
|
||||
description: 'List all available Application Dictionary element types',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute AD tool
|
||||
*/
|
||||
async function executeTool(name, args) {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'get_application_summary':
|
||||
return await getApplicationSummaryTool(args);
|
||||
|
||||
case 'get_ad_elements':
|
||||
return await getADElementsTool(args);
|
||||
|
||||
case 'search_ad_elements':
|
||||
return await searchADElementsTool(args);
|
||||
|
||||
case 'get_ad_element_details':
|
||||
return await getADElementDetailsTool(args);
|
||||
|
||||
case 'list_ad_types':
|
||||
return await listADTypesTool(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown AD tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ADTools] Error executing ${name}:`, error.message);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
tool: name
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_application_summary
|
||||
*/
|
||||
async function getApplicationSummaryTool(args) {
|
||||
console.error('[ADTools] Getting application summary');
|
||||
|
||||
const summary = adService.getApplicationSummary();
|
||||
|
||||
// Calculate totals
|
||||
let totalCached = 0;
|
||||
let totalElements = 0;
|
||||
const cachedTypes = [];
|
||||
const uncachedTypes = [];
|
||||
|
||||
Object.entries(summary).forEach(([type, info]) => {
|
||||
if (info.cached) {
|
||||
totalCached++;
|
||||
totalElements += info.count;
|
||||
cachedTypes.push(type);
|
||||
} else {
|
||||
uncachedTypes.push(type);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
summary: {
|
||||
totalTypes: Object.keys(summary).length,
|
||||
cachedTypes: totalCached,
|
||||
uncachedTypes: uncachedTypes.length,
|
||||
totalElements: totalElements
|
||||
},
|
||||
elementCounts: summary,
|
||||
cached: cachedTypes,
|
||||
notCached: uncachedTypes
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_ad_elements
|
||||
*/
|
||||
async function getADElementsTool(args) {
|
||||
const { element_type, limit = 100 } = args;
|
||||
|
||||
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit})`);
|
||||
|
||||
const elements = await adService.getElements(element_type);
|
||||
|
||||
// Limit results
|
||||
const limitedElements = elements.slice(0, Math.min(limit, 1000));
|
||||
|
||||
// Map to simplified format
|
||||
const mappedElements = limitedElements.map(e => ({
|
||||
id: e.id || e.Id,
|
||||
name: e.name || e.Name,
|
||||
description: e.description || e.Description,
|
||||
code: e.code || e.Code,
|
||||
// Include a few other common fields
|
||||
validFrom: e.validFrom,
|
||||
validTo: e.validTo,
|
||||
versionId: e.versionId
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
count: elements.length,
|
||||
returned: mappedElements.length,
|
||||
elements: mappedElements
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: search_ad_elements
|
||||
*/
|
||||
async function searchADElementsTool(args) {
|
||||
const { element_type, query, limit = 50 } = args;
|
||||
|
||||
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}`);
|
||||
|
||||
const results = await adService.searchElements(element_type, query, limit);
|
||||
|
||||
// Map to simplified format
|
||||
const mappedResults = results.map(e => ({
|
||||
id: e.id || e.Id,
|
||||
name: e.name || e.Name,
|
||||
description: e.description || e.Description,
|
||||
code: e.code || e.Code
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
query,
|
||||
count: mappedResults.length,
|
||||
elements: mappedResults
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_ad_element_details
|
||||
*/
|
||||
async function getADElementDetailsTool(args) {
|
||||
const { element_type, element_id } = args;
|
||||
|
||||
console.error(`[ADTools] Getting ${element_type} details: ${element_id}`);
|
||||
|
||||
const element = await adService.getElementDetails(element_type, element_id);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
element
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: list_ad_types
|
||||
*/
|
||||
async function listADTypesTool(args) {
|
||||
console.error('[ADTools] Listing AD types');
|
||||
|
||||
const types = adService.getAvailableTypes();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
count: types.length,
|
||||
types
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTools,
|
||||
executeTool,
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
|
||||
/**
|
||||
* Tools MCP pour interagir avec les APIs WMS
|
||||
*/
|
||||
|
||||
/**
|
||||
* Liste des tools disponibles
|
||||
* @returns {Array} - Liste des tools
|
||||
*/
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'call_query_api',
|
||||
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)',
|
||||
},
|
||||
expression: {
|
||||
type: 'string',
|
||||
description: 'Expression LINQ (ex: "z => z" pour tout, "z => z.Id" pour les IDs uniquement)',
|
||||
default: 'z => z',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'Filtre LINQ optionnel (ex: "z.Id > 100")',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Limite de résultats (défaut: 100)',
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'execute_command',
|
||||
description: 'Exécute une commande WMS (ATTENTION: peut modifier des données). Toujours récupérer la commande via get_ad_elements/get_ad_element_details avant d\'exécuter.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command_name: {
|
||||
type: 'string',
|
||||
description: 'InternalCommandName exact tel que retourné par get_ad_element_details (ex: "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.SupplierCreateCommand"). Ne pas ajouter de suffix assembly — cela cause une FileLoadException.',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'Propriétés de la commande. Pour toute commande de création (Create), un champ Id de type GUID est requis et doit être généré à la volée (ex: crypto.randomUUID() ou uuidv4()). Ne jamais laisser Id vide ou null.',
|
||||
},
|
||||
},
|
||||
required: ['command_name', 'properties'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute un tool API
|
||||
* @param {string} name - Nom du tool
|
||||
* @param {Object} args - Arguments du tool
|
||||
* @returns {Promise<Object>} - Résultat du tool
|
||||
*/
|
||||
async function executeTool(name, args) {
|
||||
switch (name) {
|
||||
case 'call_query_api':
|
||||
return await callQueryAPI(args);
|
||||
|
||||
case 'execute_command':
|
||||
return await executeCommand(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown API tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: call_query_api
|
||||
*/
|
||||
async function callQueryAPI(args) {
|
||||
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
|
||||
|
||||
try {
|
||||
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
|
||||
let linqExpression = `Context.${entity_type}`;
|
||||
if (filter) {
|
||||
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
|
||||
linqExpression += `.Where(${whereExpr})`;
|
||||
}
|
||||
linqExpression += `.OrderBy(z => z.Id)`;
|
||||
|
||||
const result = await apiService.executeQuery(linqExpression, {
|
||||
take: limit || undefined,
|
||||
select: expression !== 'z => z' ? expression : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
entityType: entity_type,
|
||||
result,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: execute_command
|
||||
*/
|
||||
async function executeCommand(args) {
|
||||
const { command_name, properties } = args;
|
||||
|
||||
try {
|
||||
const result = await apiService.executeCommand(command_name, properties);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
command: command_name,
|
||||
result,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTools,
|
||||
executeTool,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Config Tools
|
||||
* - get_system_parameters : read WMS configuration parameters and their
|
||||
* per-warehouse values.
|
||||
*
|
||||
* The Reading model exposes configuration as two entities:
|
||||
* - Parameter : the parameter definition + DefaultValue
|
||||
* - ParamValue : per-warehouse overrides, linked to Parameter by ParameterId
|
||||
*
|
||||
* This tool fetches both (small datasets — a few hundred rows), merges them
|
||||
* client-side, and reports the effective value for the requested warehouse.
|
||||
*/
|
||||
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'get_system_parameters',
|
||||
description: `Read WMS configuration parameters (entité Parameter) and their per-warehouse
|
||||
overrides (entité ParamValue). Returns, for each parameter, the effective value
|
||||
for the requested warehouse — the warehouse override if one exists, otherwise the
|
||||
default value.
|
||||
Use this instead of a raw LINQ query — the "CommandParameterData" entity does NOT
|
||||
exist in the Reading model; the correct entities are Parameter + ParamValue.
|
||||
|
||||
Examples:
|
||||
- get_system_parameters(warehouse="DOMBASLE") — all parameters with DOMBASLE values
|
||||
- get_system_parameters(warehouse="DOMBASLE", param_class="Shipping")
|
||||
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
description: 'Warehouse code to resolve effective values for (e.g. "DOMBASLE"). If omitted, all warehouse overrides are listed without picking an effective value.',
|
||||
},
|
||||
param_class: {
|
||||
type: 'string',
|
||||
description: 'Optional filter on ParamClass (e.g. "Shipping", "Reception", "Putaway").',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
description: 'Optional case-insensitive keyword matched against parameter Code and Description.',
|
||||
},
|
||||
only_overridden: {
|
||||
type: 'boolean',
|
||||
description: 'If true, return only parameters that have at least one warehouse override (default: false).',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function executeTool(name, args) {
|
||||
switch (name) {
|
||||
case 'get_system_parameters':
|
||||
return await getSystemParameters(args);
|
||||
default:
|
||||
throw new Error(`Unknown config tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemParameters(args) {
|
||||
const { warehouse, param_class, search, only_overridden = false } = args || {};
|
||||
|
||||
try {
|
||||
// Both entities are small (a few hundred rows max) — fetch fully and merge
|
||||
// client-side to avoid LINQ string-injection and null-field pitfalls.
|
||||
const [parameters, paramValues] = await Promise.all([
|
||||
apiService.executeQuery('Context.Parameters.OrderBy(z => z.Id)', { take: 1000 }),
|
||||
apiService.executeQuery('Context.ParamValues.OrderBy(z => z.Id)', { take: 1000 }),
|
||||
]);
|
||||
|
||||
// Index overrides by ParameterId
|
||||
const valuesByParam = {};
|
||||
for (const v of paramValues || []) {
|
||||
const pid = v.ParameterId;
|
||||
if (!pid) continue;
|
||||
(valuesByParam[pid] = valuesByParam[pid] || []).push(v);
|
||||
}
|
||||
|
||||
const needle = search ? String(search).toLowerCase() : null;
|
||||
|
||||
let rows = (parameters || [])
|
||||
.filter(p => {
|
||||
if (param_class && p.ParamClass !== param_class) return false;
|
||||
if (needle) {
|
||||
const hay = `${p.Code || ''} ${p.Description || ''}`.toLowerCase();
|
||||
if (!hay.includes(needle)) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(p => {
|
||||
const overrides = (valuesByParam[p.Id] || []).map(v => ({
|
||||
warehouse: v.WarehouseCode,
|
||||
value: v.Value,
|
||||
}));
|
||||
const whOverride = warehouse
|
||||
? overrides.find(o => o.warehouse === warehouse)
|
||||
: null;
|
||||
|
||||
return {
|
||||
code: p.Code,
|
||||
type: p.ParamType,
|
||||
class: p.ParamClass,
|
||||
scope: p.ParamScope,
|
||||
isSystemParameter: p.IsSystemParameter,
|
||||
isActive: p.IsActive,
|
||||
description: p.Description,
|
||||
defaultValue: p.DefaultValue,
|
||||
effectiveValue: whOverride ? whOverride.value : p.DefaultValue,
|
||||
overriddenForWarehouse: !!whOverride,
|
||||
warehouseOverrides: overrides,
|
||||
};
|
||||
});
|
||||
|
||||
if (only_overridden) {
|
||||
rows = rows.filter(r => r.warehouseOverrides.length > 0);
|
||||
}
|
||||
|
||||
rows.sort((a, b) => String(a.code).localeCompare(String(b.code)));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
warehouse: warehouse || '(none — effective value = default)',
|
||||
filters: { param_class: param_class || null, search: search || null, only_overridden },
|
||||
totalParameters: Array.isArray(parameters) ? parameters.length : 0,
|
||||
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
|
||||
returned: rows.length,
|
||||
parameters: rows,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'get_system_parameters' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listTools, executeTool };
|
||||
@@ -0,0 +1,216 @@
|
||||
const logService = require('../services/log-service');
|
||||
|
||||
/**
|
||||
* Tools MCP pour interagir avec les logs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Liste des tools disponibles
|
||||
* @returns {Array} - Liste des tools
|
||||
*/
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'read_recent_logs',
|
||||
description: 'Lit les dernières lignes des fichiers de logs',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Nombre de lignes à lire (défaut: 100)',
|
||||
default: 100,
|
||||
},
|
||||
log_file: {
|
||||
type: 'string',
|
||||
description: 'Nom du fichier de log spécifique (optionnel, prend le plus récent par défaut)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_log_files',
|
||||
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_logs',
|
||||
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
description: 'Mot-clé à rechercher',
|
||||
},
|
||||
max_results: {
|
||||
type: 'number',
|
||||
description: 'Nombre maximum de résultats (défaut: 50)',
|
||||
default: 50,
|
||||
},
|
||||
context_lines: {
|
||||
type: 'number',
|
||||
description: 'Nombre de lignes de contexte avant/après (défaut: 2)',
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ['keyword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute un tool log
|
||||
* @param {string} name - Nom du tool
|
||||
* @param {Object} args - Arguments du tool
|
||||
* @returns {Promise<Object>} - Résultat du tool
|
||||
*/
|
||||
async function executeTool(name, args) {
|
||||
switch (name) {
|
||||
case 'list_log_files':
|
||||
return await listLogFiles(args);
|
||||
|
||||
case 'read_recent_logs':
|
||||
return await readRecentLogs(args);
|
||||
|
||||
case 'search_logs':
|
||||
return await searchLogs(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown log tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: list_log_files
|
||||
*/
|
||||
async function listLogFiles() {
|
||||
try {
|
||||
const files = await logService.listLogFiles();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
totalFiles: files.length,
|
||||
files: files.map(f => ({
|
||||
name: f.name,
|
||||
relativePath: f.relativePath,
|
||||
sizeMB: (f.size / (1024 * 1024)).toFixed(2),
|
||||
modified: f.modified.toISOString(),
|
||||
})),
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: read_recent_logs
|
||||
*/
|
||||
async function readRecentLogs(args) {
|
||||
const { count = 100, log_file } = args;
|
||||
|
||||
try {
|
||||
const result = await logService.readRecentLogs(count, log_file);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
file: result.file,
|
||||
totalLines: result.totalLines,
|
||||
returnedLines: result.returnedLines,
|
||||
lines: result.lines,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: search_logs
|
||||
*/
|
||||
async function searchLogs(args) {
|
||||
const { keyword, max_results = 50, context_lines = 2 } = args;
|
||||
|
||||
try {
|
||||
const result = await logService.searchLogs(keyword, max_results, context_lines);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
keyword: result.keyword,
|
||||
totalResults: result.totalResults,
|
||||
results: result.results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTools,
|
||||
executeTool,
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Metadata & GenericSearch Tools
|
||||
* - get_entity_metadata : list queryable entities and their properties (Metadata API)
|
||||
* - generic_search : full-text search across indexed WMS documents (GenericSearch API)
|
||||
*/
|
||||
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'get_entity_metadata',
|
||||
description: `Get metadata for WMS entities — field names, types, and assembly info — without querying actual data.
|
||||
Use this to discover the exact field names and types for any entity before building a filter.
|
||||
- Without entity_name: lists all queryable entities
|
||||
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_name: {
|
||||
type: 'string',
|
||||
description: 'Partial entity name to look up (e.g. "OutboundOrder", "Task", "Stock"). Omit to list all entities.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'generic_search',
|
||||
description: `Full-text search across indexed WMS documents (faster than LINQ for keyword searches).
|
||||
First call without arguments to get available categories, then search within them.
|
||||
Examples:
|
||||
- generic_search(query="ORD-2024-001") — find an order by code
|
||||
- generic_search(query="picking error", categories=["Tasks"]) — search tasks
|
||||
- generic_search() — list available search categories`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search keyword or phrase',
|
||||
},
|
||||
categories: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Document categories to search (omit to search all). Get available categories by calling without query.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results (default: 20)',
|
||||
default: 20,
|
||||
},
|
||||
skip: {
|
||||
type: 'number',
|
||||
description: 'Offset for pagination (default: 0)',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function executeTool(name, args) {
|
||||
switch (name) {
|
||||
case 'get_entity_metadata':
|
||||
return await getEntityMetadata(args);
|
||||
case 'generic_search':
|
||||
return await genericSearch(args);
|
||||
default:
|
||||
throw new Error(`Unknown metadata tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getEntityMetadata(args) {
|
||||
const { entity_name } = args || {};
|
||||
|
||||
try {
|
||||
const entities = await apiService.getMetadataEntities();
|
||||
|
||||
if (!entity_name) {
|
||||
// Return full entity list
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
totalEntities: Array.isArray(entities) ? entities.length : '?',
|
||||
entities,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Find matching entities (case-insensitive partial match)
|
||||
const search = entity_name.toLowerCase();
|
||||
const matches = Array.isArray(entities)
|
||||
? entities.filter(e => {
|
||||
const name = (e.FullName || e.Name || e.fullName || e.name || JSON.stringify(e)).toLowerCase();
|
||||
return name.includes(search);
|
||||
})
|
||||
: [];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `No entity matching "${entity_name}" found`,
|
||||
availableCount: Array.isArray(entities) ? entities.length : '?',
|
||||
hint: 'Call get_entity_metadata without entity_name to see all entities',
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch properties for each match (up to 3)
|
||||
const results = [];
|
||||
for (const entity of matches.slice(0, 3)) {
|
||||
const assemblyFullName = entity.AssemblyFullName || entity.assemblyFullName;
|
||||
const fullName = entity.FullName || entity.fullName;
|
||||
|
||||
let properties = null;
|
||||
if (assemblyFullName && fullName) {
|
||||
try {
|
||||
properties = await apiService.getEntityProperties(assemblyFullName, fullName);
|
||||
} catch (err) {
|
||||
console.error(`[Metadata] Could not fetch properties for ${fullName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ entity, properties });
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
query: entity_name,
|
||||
matchCount: matches.length,
|
||||
results,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function genericSearch(args) {
|
||||
const { query, categories = [], limit = 20, skip = 0 } = args || {};
|
||||
|
||||
try {
|
||||
// No query: return available categories
|
||||
if (!query) {
|
||||
const cats = await apiService.getSearchCategories();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
availableCategories: cats,
|
||||
hint: 'Pass query + optional categories[] to search',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const result = await apiService.genericSearch(query, categories, limit, skip);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
query,
|
||||
categories: categories.length > 0 ? categories : 'all',
|
||||
result,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listTools, executeTool };
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Profile Management Tools
|
||||
* Let Claude list, inspect, and switch the active WMS profile at runtime.
|
||||
*
|
||||
* On switch: api-service resets its OAuth token, workflow-service/ad-service
|
||||
* clear their caches. The next API call re-authenticates against the new host
|
||||
* with the new tenant/credentials.
|
||||
*/
|
||||
|
||||
const profileManager = require('../config/profile-manager');
|
||||
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'list_wms_profiles',
|
||||
description: 'List all WMS profiles configured in .env (AD, LIMAGRAIN, ...) with their host and tenant. Use this to see which WMS backends are available.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_current_wms_profile',
|
||||
description: 'Return the currently active WMS profile (name, host, tenant, application). If no profile is active, returns an error explaining that switch_wms_profile must be called first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'switch_wms_profile',
|
||||
description: 'Switch the active WMS profile. Resets the OAuth token and clears workflow/AD caches so the next API call targets the new backend. Use list_wms_profiles to see valid names.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'string',
|
||||
description: 'Profile name (case-sensitive), e.g. "AD" or "LIMAGRAIN".',
|
||||
},
|
||||
},
|
||||
required: ['profile'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function executeTool(name, args) {
|
||||
switch (name) {
|
||||
case 'list_wms_profiles':
|
||||
return listProfilesTool();
|
||||
case 'get_current_wms_profile':
|
||||
return getCurrentProfileTool();
|
||||
case 'switch_wms_profile':
|
||||
return switchProfileTool(args);
|
||||
default:
|
||||
throw new Error(`Unknown profile tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(profile) {
|
||||
return {
|
||||
name: profile.name,
|
||||
host: profile.host,
|
||||
tenant: profile.tenant,
|
||||
username: profile.username,
|
||||
application: profile.application,
|
||||
saas: profile.saas,
|
||||
logsAvailable: !profile.saas,
|
||||
apiBaseUrl: profile.apiBaseUrl,
|
||||
tokenUrl: profile.tokenUrl,
|
||||
workflowApiBase: profile.workflowApiBase,
|
||||
};
|
||||
}
|
||||
|
||||
function listProfilesTool() {
|
||||
const names = profileManager.listProfiles();
|
||||
const current = profileManager.getCurrentName();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
current,
|
||||
profiles: names,
|
||||
hint: current
|
||||
? `Active profile: ${current}. Call switch_wms_profile to change.`
|
||||
: 'No profile active. Call switch_wms_profile with one of the names above before any WMS API call.',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentProfileTool() {
|
||||
try {
|
||||
const profile = profileManager.getCurrent();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: true, profile: summarize(profile) }, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function switchProfileTool(args) {
|
||||
const { profile } = args || {};
|
||||
if (!profile) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing "profile" argument',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const previous = profileManager.getCurrentName();
|
||||
const next = profileManager.switchTo(profile);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
previous,
|
||||
current: next.name,
|
||||
profile: summarize(next),
|
||||
note: 'OAuth token and workflow/AD caches have been cleared. Next API call will re-authenticate.',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listTools, executeTool };
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* WMS Query Tools
|
||||
* MCP tools for querying WMS entities via LINQ expressions
|
||||
*/
|
||||
|
||||
const wmsQueryService = require('../services/wms-query-service');
|
||||
|
||||
/**
|
||||
* List available WMS query tools
|
||||
*/
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
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.
|
||||
|
||||
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)
|
||||
2. If no doc found: call get_ad_elements(element_type="Entity", search=entity_name)
|
||||
3. Last resort: call get_entity_metadata(entity_name=...) to get field names
|
||||
Never guess enum string values — they differ between Reading and Writing models.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)',
|
||||
},
|
||||
select_expression: {
|
||||
type: 'string',
|
||||
description: 'LINQ select expression (default: "z => z" for all fields, or "z => new { z.Id, z.Name }" for specific fields)',
|
||||
default: 'z => z',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'Optional LINQ filter (e.g., "z => z.Id > 100" or "z => z.Status == \'Active\'")',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results to return (default: 100, max: 1000)',
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_entity_schema',
|
||||
description: 'Get the schema/structure of a WMS entity by querying one sample record',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Entity type to inspect',
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'count_wms_entities',
|
||||
description: `PREFERRED TOOL for counting ("combien", "nombre de", "how many"). Uses QueryScalarExecute → Reading model. Never adds Take(), safe on all entity types.
|
||||
IMPORTANT: Always use this tool first for any counting question — do not use query_wms_entities for counts.
|
||||
|
||||
IMPORTANT — enum/status values MUST be verified before use. Reading model uses string enum names, NOT integers.
|
||||
Lookup order for enum values:
|
||||
1. Read the relevant doc: docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
|
||||
2. If no doc: call get_ad_elements(element_type="Entity", search=entity_name)
|
||||
3. Last resort: call get_entity_metadata(entity_name=...) — gives field names but not all enum values
|
||||
Never hardcode enum values without checking — Reading vs Writing model distinction has caused bugs before.
|
||||
|
||||
Verified values (curl-tested):
|
||||
- Tasks actives: filter='z.TaskStatus == "InProcess" || z.TaskStatus == "Pending"'
|
||||
- Tasks en cours: filter='z.TaskStatus == "InProcess"'
|
||||
- Tasks en attente: filter='z.TaskStatus == "Pending"'
|
||||
- ODS lancés: filter='z.OutboundOrderStatus == "Release"'
|
||||
- OS incomplètes actives: filter='z.IncompleteOrder == true && z.IsActive == true'
|
||||
(par classe: ajouter && z.OutboundClassCode == "X")
|
||||
- Containers bloquants: filter='z.NumContainerPendingTasks > 0'
|
||||
(sur un emplacement: ajouter && z.LocationCode == "X")`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_type: {
|
||||
type: 'string',
|
||||
description: 'Entity type (Products, Containers, Tasks, Stocks, OutboundOrders, InboundOrders, etc.)',
|
||||
},
|
||||
filter: {
|
||||
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.',
|
||||
},
|
||||
},
|
||||
required: ['entity_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_wms_data',
|
||||
description: 'Search for a keyword across multiple WMS entities',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
description: 'Keyword to search for',
|
||||
},
|
||||
entity_types: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of entity types to search in (default: Products, Containers, Tasks)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results per entity (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
required: ['keyword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute WMS query tool
|
||||
*/
|
||||
async function executeTool(name, args) {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'query_wms_entities':
|
||||
return await queryWmsEntities(args);
|
||||
|
||||
case 'count_wms_entities':
|
||||
return await countWmsEntities(args);
|
||||
|
||||
case 'get_entity_schema':
|
||||
return await getEntitySchema(args);
|
||||
|
||||
case 'search_wms_data':
|
||||
return await searchWmsData(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown WMS query tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WMSQueryTools] Error executing ${name}:`, error.message);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
tool: name
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: query_wms_entities
|
||||
*/
|
||||
async function queryWmsEntities(args) {
|
||||
const { entity_type, select_expression = 'z => z', filter, limit = 100 } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit}`);
|
||||
|
||||
const result = await wmsQueryService.queryEntities(
|
||||
entity_type,
|
||||
select_expression,
|
||||
filter,
|
||||
limit
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: count_wms_entities
|
||||
*/
|
||||
async function countWmsEntities(args) {
|
||||
const { entity_type, filter } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''}`);
|
||||
|
||||
const result = await wmsQueryService.countEntities(entity_type, filter || null);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_entity_schema
|
||||
*/
|
||||
async function getEntitySchema(args) {
|
||||
const { entity_type } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Getting schema for ${entity_type}`);
|
||||
|
||||
const result = await wmsQueryService.getEntitySchema(entity_type);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: search_wms_data
|
||||
*/
|
||||
async function searchWmsData(args) {
|
||||
const { keyword, entity_types = ['Products', 'Containers', 'Tasks'], limit = 50 } = args;
|
||||
|
||||
console.error(`[WMSQueryTools] Searching for "${keyword}" in ${entity_types.join(', ')}`);
|
||||
|
||||
const results = {};
|
||||
let totalFound = 0;
|
||||
|
||||
for (const entityType of entity_types) {
|
||||
try {
|
||||
const result = await wmsQueryService.searchEntities(entityType, keyword, limit);
|
||||
results[entityType] = {
|
||||
count: result.count,
|
||||
data: result.data
|
||||
};
|
||||
totalFound += result.count;
|
||||
} catch (error) {
|
||||
console.error(`[WMSQueryTools] Search failed for ${entityType}:`, error.message);
|
||||
results[entityType] = {
|
||||
error: error.message,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
keyword,
|
||||
totalFound,
|
||||
results
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTools,
|
||||
executeTool,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Workflow Tools
|
||||
* MCP tools for searching and retrieving workflow information via API
|
||||
*/
|
||||
|
||||
const workflowService = require('../services/workflow-service');
|
||||
|
||||
/**
|
||||
* List available workflow tools
|
||||
*/
|
||||
function listTools() {
|
||||
return [
|
||||
{
|
||||
name: 'search_workflows',
|
||||
description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (searches in name, description, code)',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by workflow category/application',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results to return (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_workflow_details',
|
||||
description: 'Get full details of a specific workflow by ID or code',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow_id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID or code',
|
||||
},
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_workflow_categories',
|
||||
description: 'List all available workflow categories',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute workflow tool
|
||||
*/
|
||||
async function executeTool(name, args) {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'search_workflows':
|
||||
return await searchWorkflows(args);
|
||||
|
||||
case 'get_workflow_details':
|
||||
return await getWorkflowDetails(args);
|
||||
|
||||
case 'list_workflow_categories':
|
||||
return await listWorkflowCategories(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown workflow tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WorkflowTools] Error executing ${name}:`, error.message);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
tool: name
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: search_workflows
|
||||
*/
|
||||
async function searchWorkflows(args) {
|
||||
const { query, category, limit = 50 } = args;
|
||||
|
||||
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}`);
|
||||
|
||||
const results = await workflowService.searchWorkflows(query, category, limit);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
count: results.length,
|
||||
workflows: results.map(w => ({
|
||||
id: w.Id,
|
||||
code: w.Code,
|
||||
name: w.Name,
|
||||
category: w.Category,
|
||||
description: w.Description,
|
||||
version: w.Version,
|
||||
created: w.Created,
|
||||
modified: w.Modified
|
||||
}))
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_workflow_details
|
||||
*/
|
||||
async function getWorkflowDetails(args) {
|
||||
const { workflow_id } = args;
|
||||
|
||||
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id}`);
|
||||
|
||||
const workflow = await workflowService.getWorkflowDetails(workflow_id);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
workflow
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: list_workflow_categories
|
||||
*/
|
||||
async function listWorkflowCategories(args) {
|
||||
console.error('[WorkflowTools] Listing workflow categories');
|
||||
|
||||
const categories = await workflowService.listWorkflowCategories();
|
||||
const stats = await workflowService.getWorkflowStats();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
totalCategories: categories.length,
|
||||
categories,
|
||||
stats: {
|
||||
totalWorkflows: stats.total,
|
||||
categoryCounts: stats.categoryCounts,
|
||||
cacheAge: stats.cacheAge
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listTools,
|
||||
executeTool,
|
||||
};
|
||||
Reference in New Issue
Block a user