version mise à jour par claude , à tester
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user