@arela/uploader 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.claude/worktrees/agent-multi-profile/.env.template +224 -0
  2. package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
  3. package/.claude/worktrees/agent-multi-profile/README.md +405 -0
  4. package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
  5. package/.claude/worktrees/agent-multi-profile/package.json +78 -0
  6. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
  7. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
  8. package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
  9. package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
  10. package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
  11. package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
  12. package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
  13. package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
  14. package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
  15. package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
  16. package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
  17. package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
  18. package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
  19. package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
  20. package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
  21. package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
  22. package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
  23. package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
  24. package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
  25. package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
  26. package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
  27. package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
  28. package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
  29. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
  30. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
  31. package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
  32. package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
  33. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
  34. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
  35. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
  36. package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
  37. package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
  38. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
  39. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
  40. package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
  41. package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
  42. package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
  43. package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
  44. package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
  45. package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
  46. package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
  47. package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
  48. package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
  49. package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
  50. package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
  51. package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
  52. package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
  53. package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
  54. package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
  55. package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
  56. package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
  57. package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
  58. package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
  59. package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
  60. package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
  61. package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
  62. package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
  63. package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
  64. package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
  65. package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
  66. package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
  67. package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
  68. package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
  69. package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
  70. package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
  71. package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
  72. package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
  73. package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
  74. package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
  75. package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  76. package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
  77. package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
  78. package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
  79. package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
  80. package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
  81. package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
  82. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
  83. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
  84. package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
  85. package/README.md +65 -0
  86. package/package.json +1 -1
  87. package/src/commands/AgentCommand.js +210 -0
  88. package/src/commands/AgentInitCommand.js +316 -0
  89. package/src/commands/PollWorkerCommand.js +11 -322
  90. package/src/config/config.js +44 -6
  91. package/src/index.js +55 -0
  92. package/src/services/LoggingService.js +35 -0
  93. package/src/services/PipelineApiService.js +7 -1
  94. package/src/services/PipelineJobRunner.js +389 -0
  95. package/src/services/ProfileManager.js +164 -0
@@ -0,0 +1,775 @@
1
+ import { Agent } from 'http';
2
+ import { Agent as HttpsAgent } from 'https';
3
+ import fetch from 'node-fetch';
4
+
5
+ import appConfig from '../config/config.js';
6
+ import logger from './LoggingService.js';
7
+
8
+ /**
9
+ * Scan API Service
10
+ * Handles API communication for the arela scan command
11
+ */
12
+ export class ScanApiService {
13
+ /**
14
+ * @param {string|null} apiTarget - API target: 'default', 'agencia', 'cliente', or null (uses active target)
15
+ */
16
+ constructor(apiTarget = null) {
17
+ this.apiTarget = apiTarget;
18
+ const apiConfig = appConfig.getApiConfig(apiTarget);
19
+ this.baseUrl = apiConfig.baseUrl;
20
+ this.token = apiConfig.token;
21
+
22
+ // Get API connection settings
23
+ const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 10;
24
+ const connectionTimeout =
25
+ parseInt(process.env.API_CONNECTION_TIMEOUT) || 60000;
26
+
27
+ // Get retry configuration
28
+ this.maxRetries = parseInt(process.env.API_MAX_RETRIES) || 3;
29
+ this.useExponentialBackoff =
30
+ process.env.API_RETRY_EXPONENTIAL_BACKOFF !== 'false'; // Default true
31
+ this.fixedRetryDelay = parseInt(process.env.API_RETRY_DELAY) || 1000;
32
+
33
+ // Initialize HTTP agents for connection pooling
34
+ this.httpAgent = new Agent({
35
+ keepAlive: true,
36
+ keepAliveMsecs: 30000,
37
+ maxSockets: maxApiConnections,
38
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
39
+ maxTotalSockets: maxApiConnections + 5,
40
+ timeout: connectionTimeout,
41
+ scheduling: 'fifo',
42
+ });
43
+
44
+ this.httpsAgent = new HttpsAgent({
45
+ keepAlive: true,
46
+ keepAliveMsecs: 30000,
47
+ maxSockets: maxApiConnections,
48
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
49
+ maxTotalSockets: maxApiConnections + 5,
50
+ timeout: connectionTimeout,
51
+ scheduling: 'fifo',
52
+ });
53
+
54
+ logger.debug(
55
+ `🔗 Scan API Service configured with ${maxApiConnections} concurrent connections`,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Get the appropriate HTTP agent based on URL protocol
61
+ * @private
62
+ */
63
+ #getAgent(url) {
64
+ return url.startsWith('https://') ? this.httpsAgent : this.httpAgent;
65
+ }
66
+
67
+ /**
68
+ * Check if error is retryable
69
+ * @private
70
+ * @param {Error} error - Error to check
71
+ * @param {Response} response - HTTP response (if available)
72
+ * @returns {boolean} True if error is retryable
73
+ */
74
+ #isRetryableError(error, response = null) {
75
+ // Network errors are retryable
76
+ if (
77
+ error.code === 'ECONNRESET' ||
78
+ error.code === 'ETIMEDOUT' ||
79
+ error.code === 'ECONNREFUSED' ||
80
+ error.code === 'ENOTFOUND' ||
81
+ error.code === 'EAI_AGAIN'
82
+ ) {
83
+ return true;
84
+ }
85
+
86
+ // HTTP status codes that are retryable
87
+ if (response) {
88
+ const status = response.status;
89
+ // 429 Too Many Requests - should retry with backoff
90
+ // 5xx Server errors - temporary issues
91
+ if (status === 429 || (status >= 500 && status < 600)) {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ // Timeout errors
97
+ if (error.message && error.message.includes('timeout')) {
98
+ return true;
99
+ }
100
+
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Calculate backoff delay
106
+ * @private
107
+ * @param {number} attempt - Current attempt number (1-based)
108
+ * @returns {number} Delay in milliseconds
109
+ */
110
+ #calculateBackoff(attempt) {
111
+ if (!this.useExponentialBackoff) {
112
+ // Fixed delay with jitter
113
+ const jitter = this.fixedRetryDelay * 0.2 * (Math.random() * 2 - 1);
114
+ return Math.floor(this.fixedRetryDelay + jitter);
115
+ }
116
+
117
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s
118
+ const baseDelay = 1000;
119
+ const maxDelay = 16000;
120
+ const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
121
+
122
+ // Add jitter (±20%) to prevent thundering herd
123
+ const jitter = delay * 0.2 * (Math.random() * 2 - 1);
124
+ return Math.floor(delay + jitter);
125
+ }
126
+
127
+ /**
128
+ * Sleep for specified milliseconds
129
+ * @private
130
+ * @param {number} ms - Milliseconds to sleep
131
+ * @returns {Promise<void>}
132
+ */
133
+ async #sleep(ms) {
134
+ return new Promise((resolve) => setTimeout(resolve, ms));
135
+ }
136
+
137
+ /**
138
+ * Make API request with retry logic and exponential backoff
139
+ * @private
140
+ * @param {string} endpoint - API endpoint
141
+ * @param {string} method - HTTP method
142
+ * @param {Object} body - Request body
143
+ * @param {Object} headers - Additional headers
144
+ * @param {number} maxRetries - Maximum retry attempts (defaults to configured value)
145
+ * @returns {Promise<Object>} Response data
146
+ */
147
+ async #request(
148
+ endpoint,
149
+ method = 'GET',
150
+ body = null,
151
+ headers = {},
152
+ maxRetries = null,
153
+ ) {
154
+ // Use configured maxRetries if not specified
155
+ const retries = maxRetries !== null ? maxRetries : this.maxRetries;
156
+
157
+ const url = `${this.baseUrl}${endpoint}`;
158
+
159
+ const options = {
160
+ method,
161
+ headers: {
162
+ 'x-api-key': this.token,
163
+ 'Content-Type': 'application/json',
164
+ ...headers,
165
+ },
166
+ agent: this.#getAgent(url),
167
+ };
168
+
169
+ if (body) {
170
+ options.body = JSON.stringify(body);
171
+ }
172
+
173
+ let lastError;
174
+ let lastResponse = null;
175
+
176
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
177
+ try {
178
+ const response = await fetch(url, options);
179
+ lastResponse = response;
180
+
181
+ if (!response.ok) {
182
+ const errorText = await response.text();
183
+ let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
184
+
185
+ try {
186
+ const errorJson = JSON.parse(errorText);
187
+ errorMessage = errorJson.message || errorMessage;
188
+ } catch {
189
+ errorMessage = errorText || errorMessage;
190
+ }
191
+
192
+ const error = new Error(errorMessage);
193
+ error.status = response.status;
194
+
195
+ // Check if error is retryable
196
+ if (this.#isRetryableError(error, response)) {
197
+ if (attempt <= retries) {
198
+ const backoffDelay = this.#calculateBackoff(attempt);
199
+ logger.warn(
200
+ `API request failed (attempt ${attempt}/${retries + 1}): ${errorMessage}. Retrying in ${backoffDelay}ms...`,
201
+ );
202
+ await this.#sleep(backoffDelay);
203
+ continue;
204
+ }
205
+ }
206
+
207
+ throw error;
208
+ }
209
+
210
+ // Success - log retry success if this wasn't the first attempt
211
+ if (attempt > 1) {
212
+ logger.info(
213
+ `API request succeeded on attempt ${attempt}/${retries + 1}`,
214
+ );
215
+ }
216
+
217
+ return await response.json();
218
+ } catch (error) {
219
+ lastError = error;
220
+
221
+ // Check if this is a retryable error
222
+ if (this.#isRetryableError(error, lastResponse)) {
223
+ if (attempt <= retries) {
224
+ const backoffDelay = this.#calculateBackoff(attempt);
225
+ logger.warn(
226
+ `API request failed (attempt ${attempt}/${retries + 1}): ${error.message}. Retrying in ${backoffDelay}ms...`,
227
+ );
228
+ await this.#sleep(backoffDelay);
229
+ continue;
230
+ }
231
+ }
232
+
233
+ // Non-retryable error or max retries reached
234
+ logger.error(
235
+ `API request failed after ${attempt} attempt(s): ${error.message}`,
236
+ );
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ // Should not reach here, but just in case
242
+ throw lastError;
243
+ }
244
+
245
+ /**
246
+ * Register a scan instance with the API
247
+ * @param {Object} config - Instance configuration
248
+ * @returns {Promise<Object>} Registration result
249
+ */
250
+ async registerInstance(config) {
251
+ logger.debug('Registering scan instance...');
252
+
253
+ const result = await this.#request('/api/uploader/scan/register', 'POST', {
254
+ companySlug: config.companySlug,
255
+ serverId: config.serverId,
256
+ basePathFull: config.basePathFull,
257
+ });
258
+
259
+ logger.debug(`Instance registered: ${result.tableName}`);
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Bulk insert file stats
265
+ * @param {string} tableName - Target table name
266
+ * @param {Array} records - File stat records
267
+ * @returns {Promise<Object>} Insert result
268
+ */
269
+ async batchInsertStats(tableName, records) {
270
+ if (!records || records.length === 0) {
271
+ return { inserted: 0 };
272
+ }
273
+
274
+ logger.debug(`Uploading batch of ${records.length} records...`);
275
+
276
+ const result = await this.#request(
277
+ '/api/uploader/scan/batch-insert',
278
+ 'POST',
279
+ records,
280
+ {
281
+ 'x-table-name': tableName,
282
+ },
283
+ );
284
+
285
+ logger.debug(`Batch uploaded: ${result.inserted} inserted`);
286
+ return result;
287
+ }
288
+
289
+ /**
290
+ * Complete a scan and update statistics
291
+ * @param {Object} data - Completion data
292
+ * @returns {Promise<Object>} Completion result
293
+ */
294
+ async completeScan(data) {
295
+ logger.debug('Completing scan...');
296
+
297
+ const result = await this.#request('/api/uploader/scan/complete', 'PATCH', {
298
+ tableName: data.tableName,
299
+ totalFiles: data.totalFiles,
300
+ totalSizeBytes: data.totalSizeBytes,
301
+ });
302
+
303
+ logger.debug('Scan completed');
304
+ return result;
305
+ }
306
+
307
+ /**
308
+ * Get all scan instances
309
+ * @returns {Promise<Array>} List of scan instances
310
+ */
311
+ async getAllInstances() {
312
+ logger.debug('Fetching scan instances...');
313
+ return await this.#request('/api/uploader/scan/instances', 'GET');
314
+ }
315
+
316
+ /**
317
+ * Get stale scan instances
318
+ * @param {number} days - Days threshold
319
+ * @returns {Promise<Array>} List of stale instances
320
+ */
321
+ async getStaleInstances(days = 90) {
322
+ logger.debug(`Fetching stale instances (${days} days)...`);
323
+ return await this.#request(
324
+ `/api/uploader/scan/stale-instances?days=${days}`,
325
+ 'GET',
326
+ );
327
+ }
328
+
329
+ /**
330
+ * Get all tables for a specific instance
331
+ * @param {string} companySlug - Company slug
332
+ * @param {string} serverId - Server ID
333
+ * @param {string} basePathFull - Base path (absolute)
334
+ * @returns {Promise<Array>} List of tables for the instance
335
+ */
336
+ async getInstanceTables(companySlug, serverId, basePathFull) {
337
+ logger.debug(
338
+ `Fetching instance tables for ${companySlug}/${serverId}/${basePathFull}...`,
339
+ );
340
+ return await this.#request(
341
+ `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
342
+ 'GET',
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Deactivate a scan instance
348
+ * @param {string} tableName - Table name to deactivate
349
+ * @returns {Promise<Object>} Deactivation result
350
+ */
351
+ async deactivateInstance(tableName) {
352
+ logger.debug(`Deactivating instance: ${tableName}`);
353
+
354
+ const result = await this.#request(
355
+ '/api/uploader/scan/deactivate',
356
+ 'PATCH',
357
+ {
358
+ tableName,
359
+ },
360
+ );
361
+
362
+ logger.debug('Instance deactivated');
363
+ return result;
364
+ }
365
+
366
+ // ============================================================================
367
+ // DETECTION OPERATIONS (for arela identify command)
368
+ // ============================================================================
369
+
370
+ /**
371
+ * Fetch files for detection
372
+ * @param {string} tableName - Target table name
373
+ * @param {number} offset - Pagination offset
374
+ * @param {number} limit - Number of records to fetch
375
+ * @param {boolean} allTypes - When true, fetch all supported file types instead of just likely-simplificado PDFs
376
+ * @returns {Promise<Object>} { data: Array, hasMore: boolean }
377
+ */
378
+ /**
379
+ * Get a single file record by ID (for single-file identify mode).
380
+ * @param {string} tableName - Scan table name (with or without cli. prefix)
381
+ * @param {string} fileId - UUID of the file record
382
+ * @returns {Promise<{ id: string, file_name: string, file_extension: string, absolute_path: string }>}
383
+ */
384
+ async getFileRecord(tableName, fileId) {
385
+ const cleanTable = tableName.replace(/^cli\./, '');
386
+ const url = `/api/uploader/scan/file-record?tableName=${encodeURIComponent(cleanTable)}&fileId=${encodeURIComponent(fileId)}`;
387
+ const result = await this.#request(url, 'GET');
388
+ logger.debug(`Fetched file record ${fileId} from ${cleanTable}`);
389
+ return result;
390
+ }
391
+
392
+ /**
393
+ * Fetch the resolved matcher set (this RFC's matchers + globals) for runtime
394
+ * classification. Returns an array of matchers with clues + fieldExtractors.
395
+ * @param {string|null} rfc - optional RFC to scope per-company matchers
396
+ */
397
+ async getResolvedMatchers(rfc = null) {
398
+ const qs = rfc ? `?rfc=${encodeURIComponent(rfc)}` : '';
399
+ const result = await this.#request(
400
+ `/api/document-matcher/resolved${qs}`,
401
+ 'GET',
402
+ );
403
+ return Array.isArray(result) ? result : [];
404
+ }
405
+
406
+ async fetchPdfsForDetection(
407
+ tableName,
408
+ offset = 0,
409
+ limit = 100,
410
+ allTypes = false,
411
+ ) {
412
+ logger.debug(
413
+ `Fetching files for detection (offset: ${offset}, limit: ${limit}, allTypes: ${allTypes})...`,
414
+ );
415
+
416
+ let url = `/api/uploader/scan/pdfs-for-detection?tableName=${encodeURIComponent(tableName)}&offset=${offset}&limit=${limit}`;
417
+ if (allTypes) {
418
+ url += '&allTypes=true';
419
+ }
420
+
421
+ const result = await this.#request(url, 'GET');
422
+
423
+ logger.debug(
424
+ `Fetched ${result.data.length} files, hasMore: ${result.hasMore}`,
425
+ );
426
+ return result;
427
+ }
428
+
429
+ /**
430
+ * Reset detection_attempts to 0 for undetected files so they can be re-processed.
431
+ * @param {string} tableName - Target scan table name
432
+ * @param {string|null} absolutePath - If provided, reset only this specific file
433
+ * @returns {Promise<{ reset: number }>}
434
+ */
435
+ async resetDetectionAttempts(tableName, absolutePath = null) {
436
+ let url = `/api/uploader/scan/reset-detection-attempts?tableName=${encodeURIComponent(tableName)}`;
437
+ if (absolutePath) {
438
+ url += `&absolutePath=${encodeURIComponent(absolutePath)}`;
439
+ }
440
+ const result = await this.#request(url, 'PATCH');
441
+ logger.debug(`Reset ${result.reset} detection attempt(s) in ${tableName}`);
442
+ return result;
443
+ }
444
+
445
+ /**
446
+ * Batch update detection results
447
+ * @param {string} tableName - Target table name
448
+ * @param {Array} updates - Detection results
449
+ * @returns {Promise<Object>} { updated: number, errors: number }
450
+ */
451
+ async batchUpdateDetection(tableName, updates) {
452
+ if (!updates || updates.length === 0) {
453
+ return { updated: 0, errors: 0 };
454
+ }
455
+
456
+ logger.debug(`Updating detection results for ${updates.length} files...`);
457
+
458
+ const result = await this.#request(
459
+ `/api/uploader/scan/batch-update-detection?tableName=${encodeURIComponent(tableName)}`,
460
+ 'PATCH',
461
+ updates,
462
+ );
463
+
464
+ logger.debug(
465
+ `Detection updated: ${result.updated} successful, ${result.errors} errors`,
466
+ );
467
+ return result;
468
+ }
469
+
470
+ /**
471
+ * Get detection statistics
472
+ * @param {string} tableName - Target table name
473
+ * @returns {Promise<Object>} { totalPdfs, detected, pending, errors }
474
+ */
475
+ async getDetectionStats(tableName, allTypes = false) {
476
+ logger.debug('Fetching detection statistics...');
477
+
478
+ let url = `/api/uploader/scan/detection-stats?tableName=${encodeURIComponent(tableName)}`;
479
+ if (allTypes) {
480
+ url += '&allTypes=true';
481
+ }
482
+
483
+ const result = await this.#request(url, 'GET');
484
+
485
+ logger.debug(
486
+ `Detection stats: ${result.detected}/${result.totalPdfs} detected, ${result.pending} pending`,
487
+ );
488
+ return result;
489
+ }
490
+
491
+ // ============================================================================
492
+ // PROPAGATION API METHODS (for arela propagate command)
493
+ // ============================================================================
494
+
495
+ /**
496
+ * Mark files needing propagation
497
+ * @param {string} tableName - Target table name
498
+ * @returns {Promise<Object>} { markedCount: number }
499
+ */
500
+ async markFilesNeedingPropagation(tableName) {
501
+ logger.debug('Marking files needing propagation...');
502
+
503
+ const result = await this.#request(
504
+ `/api/uploader/scan/mark-propagation?tableName=${encodeURIComponent(tableName)}`,
505
+ 'POST',
506
+ );
507
+
508
+ logger.debug(`Marked ${result.markedCount} files for propagation`);
509
+ return result;
510
+ }
511
+
512
+ /**
513
+ * Fetch pedimento sources for propagation
514
+ * @param {string} tableName - Target table name
515
+ * @param {number} offset - Pagination offset
516
+ * @param {number} limit - Number of records to fetch
517
+ * @returns {Promise<Array>} Array of pedimento sources
518
+ */
519
+ async fetchPedimentoSources(tableName, offset = 0, limit = 100) {
520
+ logger.debug(
521
+ `Fetching pedimento sources (offset: ${offset}, limit: ${limit})...`,
522
+ );
523
+
524
+ const result = await this.#request(
525
+ `/api/uploader/scan/pedimento-sources?tableName=${encodeURIComponent(tableName)}&offset=${offset}&limit=${limit}`,
526
+ 'GET',
527
+ );
528
+
529
+ // Validate response is an array
530
+ if (!Array.isArray(result)) {
531
+ logger.error(
532
+ 'fetchPedimentoSources: Expected array, got:',
533
+ typeof result,
534
+ );
535
+ logger.error('Response data:', JSON.stringify(result).substring(0, 200));
536
+ return [];
537
+ }
538
+
539
+ logger.debug(`Fetched ${result.length} pedimento sources`);
540
+ return result;
541
+ }
542
+
543
+ /**
544
+ * Fetch files needing propagation by directory
545
+ * @param {string} tableName - Target table name
546
+ * @param {string} directoryPath - Directory path to query
547
+ * @returns {Promise<Array>} Array of files needing propagation
548
+ */
549
+ async fetchFilesNeedingPropagationByDirectory(tableName, directoryPath) {
550
+ const result = await this.#request(
551
+ `/api/uploader/scan/files-by-directory?tableName=${encodeURIComponent(tableName)}&directoryPath=${encodeURIComponent(directoryPath)}`,
552
+ 'GET',
553
+ );
554
+
555
+ // Validate response is an array
556
+ if (!Array.isArray(result)) {
557
+ logger.error(
558
+ 'fetchFilesNeedingPropagationByDirectory: Expected array, got:',
559
+ typeof result,
560
+ );
561
+ return [];
562
+ }
563
+
564
+ return result;
565
+ }
566
+
567
+ /**
568
+ * Batch update propagation results
569
+ * @param {string} tableName - Target table name
570
+ * @param {Array} updates - Propagation results
571
+ * @returns {Promise<Object>} { updated: number, errors: number }
572
+ */
573
+ async batchUpdatePropagation(tableName, updates) {
574
+ if (!updates || updates.length === 0) {
575
+ return { updated: 0, errors: 0 };
576
+ }
577
+
578
+ logger.debug(`Updating propagation results for ${updates.length} files...`);
579
+
580
+ const result = await this.#request(
581
+ `/api/uploader/scan/batch-update-propagation?tableName=${encodeURIComponent(tableName)}`,
582
+ 'PATCH',
583
+ { updates },
584
+ );
585
+
586
+ logger.debug(
587
+ `Propagation updated: ${result.updated} successful, ${result.errors} errors`,
588
+ );
589
+ return result;
590
+ }
591
+
592
+ /**
593
+ * Get propagation statistics
594
+ * @param {string} tableName - Target table name
595
+ * @returns {Promise<Object>} { totalFiles, withArelaPath, needsPropagation, pending, errors, maxAttemptsReached, pedimentoSources }
596
+ */
597
+ async getPropagationStats(tableName) {
598
+ logger.debug('Fetching propagation statistics...');
599
+
600
+ const result = await this.#request(
601
+ `/api/uploader/scan/propagation-stats?tableName=${encodeURIComponent(tableName)}`,
602
+ 'GET',
603
+ );
604
+
605
+ logger.debug(
606
+ `Propagation stats: ${result.withArelaPath}/${result.totalFiles} with arela_path, ${result.pending} pending`,
607
+ );
608
+ return result;
609
+ }
610
+
611
+ // ============================================================================
612
+ // CROSS-TABLE PROPAGATION
613
+ // ============================================================================
614
+
615
+ /**
616
+ * Fetch pedimento sources across all tables for cross-table propagation
617
+ * @param {string} companySlug - Company slug
618
+ * @param {string} serverId - Server ID
619
+ * @param {string} basePathFull - Base path
620
+ * @returns {Promise<Array>} Array of pedimento sources with source_table info
621
+ */
622
+ async fetchCrossTablePedimentoSources(companySlug, serverId, basePathFull) {
623
+ logger.debug('Fetching cross-table pedimento sources...');
624
+
625
+ const result = await this.#request(
626
+ `/api/uploader/scan/cross-table-pedimento-sources?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
627
+ 'GET',
628
+ );
629
+
630
+ if (!Array.isArray(result)) {
631
+ logger.error(
632
+ 'fetchCrossTablePedimentoSources: Expected array, got:',
633
+ typeof result,
634
+ );
635
+ return [];
636
+ }
637
+
638
+ logger.debug(`Fetched ${result.length} cross-table pedimento sources`);
639
+ return result;
640
+ }
641
+
642
+ /**
643
+ * Fetch files with detected_pedimento but no arela_path (candidates for cross-table propagation)
644
+ * @param {string} tableName - Target table name
645
+ * @param {number} offset - Pagination offset
646
+ * @param {number} limit - Number of records to fetch
647
+ * @returns {Promise<Array>} Array of files needing cross-table propagation
648
+ */
649
+ async fetchFilesWithPedimentoNoArelaPath(tableName, offset = 0, limit = 100) {
650
+ logger.debug(
651
+ `Fetching files with pedimento but no arela_path (offset: ${offset}, limit: ${limit})...`,
652
+ );
653
+
654
+ const result = await this.#request(
655
+ `/api/uploader/scan/files-with-pedimento-no-arela-path?tableName=${encodeURIComponent(tableName)}&offset=${offset}&limit=${limit}`,
656
+ 'GET',
657
+ );
658
+
659
+ if (!Array.isArray(result)) {
660
+ logger.error(
661
+ 'fetchFilesWithPedimentoNoArelaPath: Expected array, got:',
662
+ typeof result,
663
+ );
664
+ return [];
665
+ }
666
+
667
+ logger.debug(
668
+ `Fetched ${result.length} files needing cross-table propagation`,
669
+ );
670
+ return result;
671
+ }
672
+
673
+ // ============================================================================
674
+ // PUSH OPERATIONS
675
+ // ============================================================================
676
+
677
+ /**
678
+ * Fetch files ready for upload (push command)
679
+ * @param {string} tableName - Target table name
680
+ * @param {Object} options - Query options
681
+ * @param {string[]} options.rfcs - RFCs to filter by
682
+ * @param {number[]} options.years - Years to filter by
683
+ * @param {number} options.offset - Pagination offset
684
+ * @param {number} options.limit - Pagination limit
685
+ * @returns {Promise<Array>} Array of files ready for upload
686
+ */
687
+ async fetchFilesForPush(tableName, options = {}) {
688
+ const { rfcs, years, offset = 0, limit = 100 } = options;
689
+
690
+ // Build query string
691
+ const params = new URLSearchParams({
692
+ tableName,
693
+ offset: offset.toString(),
694
+ limit: limit.toString(),
695
+ });
696
+
697
+ if (rfcs && rfcs.length > 0) {
698
+ params.append('rfcs', rfcs.join(','));
699
+ }
700
+
701
+ if (years && years.length > 0) {
702
+ params.append('years', years.join(','));
703
+ }
704
+
705
+ const result = await this.#request(
706
+ `/api/uploader/scan/files-for-push?${params.toString()}`,
707
+ 'GET',
708
+ );
709
+
710
+ // Validate response is an array
711
+ if (!Array.isArray(result)) {
712
+ logger.error('fetchFilesForPush: Expected array, got:', typeof result);
713
+ return [];
714
+ }
715
+
716
+ logger.debug(`Fetched ${result.length} files for push`);
717
+ return result;
718
+ }
719
+
720
+ /**
721
+ * Batch update upload results
722
+ * @param {string} tableName - Target table name
723
+ * @param {Array} updates - Upload results
724
+ * @returns {Promise<Object>} { updated: number, errors: number }
725
+ */
726
+ async batchUpdateUpload(tableName, updates) {
727
+ if (!updates || updates.length === 0) {
728
+ return { updated: 0, errors: 0 };
729
+ }
730
+
731
+ logger.debug(`Updating upload results for ${updates.length} files...`);
732
+
733
+ const result = await this.#request(
734
+ `/api/uploader/scan/batch-update-upload?tableName=${encodeURIComponent(tableName)}`,
735
+ 'PATCH',
736
+ { updates },
737
+ );
738
+
739
+ logger.debug(
740
+ `Upload updated: ${result.updated} successful, ${result.errors} errors`,
741
+ );
742
+ return result;
743
+ }
744
+
745
+ /**
746
+ * Get push statistics
747
+ * @param {string} tableName - Target table name
748
+ * @param {Object} filters - Optional filters { rfcs: string[], years: string[] }
749
+ * @returns {Promise<Object>} { totalWithArelaPath, uploaded, pending, errors, maxAttemptsReached, byRfc }
750
+ */
751
+ async getPushStats(tableName, filters = {}) {
752
+ logger.debug('Fetching push statistics...');
753
+
754
+ // Build query params
755
+ const params = new URLSearchParams({ tableName });
756
+ if (filters.rfcs && filters.rfcs.length > 0) {
757
+ params.append('rfcs', filters.rfcs.join(','));
758
+ }
759
+ if (filters.years && filters.years.length > 0) {
760
+ params.append('years', filters.years.join(','));
761
+ }
762
+
763
+ const result = await this.#request(
764
+ `/api/uploader/scan/push-stats?${params.toString()}`,
765
+ 'GET',
766
+ );
767
+
768
+ logger.debug(
769
+ `Push stats: ${result.uploaded}/${result.totalWithArelaPath} uploaded, ${result.pending} pending`,
770
+ );
771
+ return result;
772
+ }
773
+ }
774
+
775
+ export default ScanApiService;