version mise à jour par claude , à tester

This commit is contained in:
Arthur Ria
2026-05-20 09:38:07 +02:00
commit b59cbb3546
58 changed files with 24548 additions and 0 deletions
+259
View File
@@ -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
};
+393
View File
@@ -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;
}
};
+412
View File
@@ -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,
};
+179
View File
@@ -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,
};
+229
View File
@@ -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
};