@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,722 @@
1
+ import cliProgress from 'cli-progress';
2
+ import { globby, globbyStream } from 'globby';
3
+ import path from 'path';
4
+ import { Transform } from 'stream';
5
+ import { pipeline } from 'stream/promises';
6
+
7
+ import logger from '../services/LoggingService.js';
8
+
9
+ import appConfig from '../config/config.js';
10
+ import ErrorHandler from '../errors/ErrorHandler.js';
11
+ import { ConfigurationError } from '../errors/ErrorTypes.js';
12
+ import FileOperations from '../utils/FileOperations.js';
13
+ import PathNormalizer from '../utils/PathNormalizer.js';
14
+
15
+ /**
16
+ * Scan Command Handler
17
+ * Handles the optimized arela scan command with streaming support
18
+ */
19
+ export class ScanCommand {
20
+ constructor() {
21
+ this.errorHandler = new ErrorHandler(logger);
22
+ this.scanApiService = null; // Will be initialized in execute
23
+ }
24
+
25
+ /**
26
+ * Execute the scan command
27
+ * @param {Object} options - Command options
28
+ * @param {boolean} options.countFirst - Count files first for percentage-based progress
29
+ * @param {string} options.api - API target: 'default', 'agencia', or 'cliente'
30
+ * @param {boolean} options.stream - Use streaming file discovery (default: true, use --no-stream to disable)
31
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
32
+ */
33
+ async execute(options = {}) {
34
+ const startTime = Date.now();
35
+ this.onProgress = options.onProgress || null;
36
+
37
+ try {
38
+ // Validate scan configuration
39
+ appConfig.validateScanConfig();
40
+
41
+ // Determine API target
42
+ const apiTarget = options.api || 'default';
43
+
44
+ // Import ScanApiService dynamically and initialize with target
45
+ const { default: ScanApiService } = await import(
46
+ '../services/ScanApiService.js'
47
+ );
48
+ this.scanApiService = new ScanApiService(apiTarget);
49
+
50
+ const scanConfig = appConfig.getScanConfig();
51
+ // Ensure basePath is absolute for scan operations
52
+ // '*' is a wildcard sentinel for cross-drive pipelines — no real basePath
53
+ const rawBasePath = appConfig.getBasePath();
54
+ const basePath =
55
+ rawBasePath === '*' ? '*' : PathNormalizer.toAbsolutePath(rawBasePath);
56
+
57
+ logger.info('🔍 Starting arela scan command');
58
+ logger.info(`🎯 API Target: ${apiTarget}`);
59
+ logger.info(`📦 Company: ${scanConfig.companySlug}`);
60
+ logger.info(`🖥️ Server: ${scanConfig.serverId}`);
61
+ logger.info(`📂 Base Path: ${basePath}`);
62
+ logger.info(`📊 Directory Level: ${scanConfig.directoryLevel}`);
63
+ logger.info(
64
+ `🔄 File Discovery: ${options.stream !== false ? 'Streaming (globbyStream)' : 'Synchronous (globby)'}`,
65
+ );
66
+
67
+ // Step 1: Discover directories at specified level
68
+ logger.info('\n🔍 Discovering directories...');
69
+ const directories = await this.#discoverDirectories(
70
+ basePath,
71
+ scanConfig.directoryLevel,
72
+ );
73
+ logger.info(
74
+ `📁 Found ${directories.length} director${directories.length === 1 ? 'y' : 'ies'} to scan`,
75
+ );
76
+
77
+ // Step 2: Register instances for each directory
78
+ logger.info('\n📝 Registering scan instances...');
79
+ const registrations = [];
80
+ for (const dir of directories) {
81
+ // dir.path is already absolute from #discoverDirectories
82
+ // Use the absolute path as basePathLabel (simplifies everything!)
83
+ const absolutePath = dir.path;
84
+
85
+ const registration = await this.scanApiService.registerInstance({
86
+ companySlug: scanConfig.companySlug,
87
+ serverId: scanConfig.serverId,
88
+ basePathFull: absolutePath,
89
+ });
90
+
91
+ registrations.push({ ...registration, directory: dir });
92
+
93
+ if (registration.existed) {
94
+ logger.info(` ✓ ${dir.label || 'root'}: ${registration.tableName}`);
95
+ } else {
96
+ logger.success(
97
+ ` ✓ ${dir.label || 'root'}: ${registration.tableName} (new)`,
98
+ );
99
+ }
100
+ }
101
+
102
+ // Optional: Count files first for percentage-based progress
103
+ let totalFiles = null;
104
+ if (options.countFirst) {
105
+ logger.info('\n🔢 Counting files...');
106
+ totalFiles = await this.#countFiles(
107
+ basePath,
108
+ scanConfig,
109
+ options.stream !== false,
110
+ );
111
+ logger.info(`📊 Found ${totalFiles.toLocaleString()} files to scan`);
112
+ }
113
+
114
+ // Step 3: Stream files and upload stats for each directory
115
+ logger.info('\n🚀 Starting file scan...');
116
+ let totalStats = {
117
+ filesScanned: 0,
118
+ filesInserted: 0,
119
+ filesSkipped: 0,
120
+ totalSize: 0,
121
+ };
122
+
123
+ // Report initial progress
124
+ this.#reportProgress(
125
+ 0,
126
+ `Starting scan of ${registrations.length} directories`,
127
+ );
128
+
129
+ for (let i = 0; i < registrations.length; i++) {
130
+ const reg = registrations[i];
131
+ logger.info(`\n📂 Scanning: ${reg.directory.label || 'root'}`);
132
+ this.#reportProgress(
133
+ Math.round((i / registrations.length) * 100),
134
+ `Scanning directory ${i + 1}/${registrations.length}: ${reg.directory.label || 'root'}`,
135
+ );
136
+
137
+ const stats = await this.#streamScanDirectory(
138
+ reg.directory.path,
139
+ scanConfig,
140
+ reg.tableName,
141
+ null, // Don't use percentage for individual directories
142
+ options.stream !== false, // Use streaming by default, --no-stream sets stream=false
143
+ );
144
+
145
+ // Step 4: Complete scan for this directory
146
+ await this.scanApiService.completeScan({
147
+ tableName: reg.tableName,
148
+ totalFiles: stats.filesScanned,
149
+ totalSizeBytes: stats.totalSize,
150
+ });
151
+
152
+ totalStats.filesScanned += stats.filesScanned;
153
+ totalStats.filesInserted += stats.filesInserted;
154
+ totalStats.filesSkipped += stats.filesSkipped;
155
+ totalStats.totalSize += stats.totalSize;
156
+ }
157
+
158
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
159
+ const filesPerSec = (totalStats.filesScanned / duration).toFixed(2);
160
+
161
+ // Report completion
162
+ this.#reportProgress(
163
+ 100,
164
+ `Scan completed: ${totalStats.filesScanned} files in ${duration}s`,
165
+ );
166
+
167
+ logger.success('\n✅ Scan completed successfully!');
168
+ logger.info(`\n📊 Scan Statistics:`);
169
+ logger.info(` Directories scanned: ${registrations.length}`);
170
+ logger.info(
171
+ ` Files scanned: ${totalStats.filesScanned.toLocaleString()}`,
172
+ );
173
+ logger.info(
174
+ ` Files inserted: ${totalStats.filesInserted.toLocaleString()}`,
175
+ );
176
+ logger.info(
177
+ ` Files skipped: ${totalStats.filesSkipped.toLocaleString()} (excluded patterns)`,
178
+ );
179
+ logger.info(` Total size: ${this.#formatBytes(totalStats.totalSize)}`);
180
+ logger.info(` Duration: ${duration}s`);
181
+ logger.info(` Throughput: ${filesPerSec} files/sec`);
182
+ logger.info(`\n📋 Tables created:`);
183
+ for (const reg of registrations) {
184
+ logger.info(` - ${reg.tableName}`);
185
+ }
186
+
187
+ return {
188
+ success: true,
189
+ tables: registrations.map((r) => r.tableName),
190
+ stats: totalStats,
191
+ };
192
+ } catch (error) {
193
+ this.errorHandler.handleError(error, 'scan');
194
+ return {
195
+ success: false,
196
+ error: error.message,
197
+ stats: {
198
+ filesScanned: 0,
199
+ filesInserted: 0,
200
+ filesSkipped: 0,
201
+ totalSize: 0,
202
+ },
203
+ };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Discover directories at specified level
209
+ * @private
210
+ */
211
+ async #discoverDirectories(basePath, level) {
212
+ // Get sources, defaults to ['.'] if not configured
213
+ const sources = appConfig.getUploadSources();
214
+ const isDefaultSource = sources.length === 1 && sources[0] === '.';
215
+
216
+ if (level === 0) {
217
+ // Level 0: Create one entry per source
218
+ return sources.map((source) => {
219
+ let sourcePath;
220
+ if (source === '.') {
221
+ sourcePath = basePath;
222
+ } else if (source.startsWith('..') || path.isAbsolute(source)) {
223
+ // Source is a relative-to-CWD path (e.g., from pipeline UI) or absolute
224
+ sourcePath = PathNormalizer.toAbsolutePath(source);
225
+ } else {
226
+ // Source is a subdirectory of basePath
227
+ sourcePath = path.resolve(basePath, source);
228
+ }
229
+ // Label is relative path for display purposes only
230
+ const label = source === '.' ? '' : source;
231
+ return { path: sourcePath, label };
232
+ });
233
+ }
234
+
235
+ // For level > 0: First discover directories at the base path, then combine with sources
236
+ const fs = await import('fs/promises');
237
+ const directories = [];
238
+
239
+ try {
240
+ const fs = await import('fs/promises');
241
+
242
+ // Step 1: Discover directories at the specified level from base path
243
+ const levelDirs = await this.#getDirectoriesAtLevel(basePath, level, '');
244
+
245
+ // Step 2: For each discovered directory, create entries for each source
246
+ for (const levelDir of levelDirs) {
247
+ for (const source of sources) {
248
+ if (source === '.') {
249
+ // Source is current directory, use discovered path as-is
250
+ directories.push(levelDir);
251
+ } else {
252
+ // Resolve source: if it starts with ".." it's relative to CWD, not levelDir
253
+ const combinedPath =
254
+ source.startsWith('..') || path.isAbsolute(source)
255
+ ? PathNormalizer.toAbsolutePath(source)
256
+ : path.resolve(levelDir.path, source);
257
+
258
+ // Only add if the combined path actually exists
259
+ try {
260
+ const stats = await fs.stat(combinedPath);
261
+ if (stats.isDirectory()) {
262
+ // Label for display
263
+ const label = levelDir.label
264
+ ? `${levelDir.label}/${source}`
265
+ : source;
266
+ directories.push({
267
+ path: combinedPath,
268
+ label,
269
+ });
270
+ } else {
271
+ logger.debug(`⏭️ Skipping ${combinedPath} (not a directory)`);
272
+ }
273
+ } catch (error) {
274
+ logger.debug(`⏭️ Skipping ${combinedPath} (does not exist)`);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ } catch (error) {
280
+ logger.warn(`⚠️ Could not discover directories: ${error.message}`);
281
+ }
282
+
283
+ return directories;
284
+ }
285
+
286
+ /**
287
+ * Recursively get directories at specified level
288
+ * @private
289
+ */
290
+ async #getDirectoriesAtLevel(
291
+ basePath,
292
+ targetLevel,
293
+ currentPath,
294
+ currentLevel = 0,
295
+ ) {
296
+ const fs = await import('fs/promises');
297
+ const fullPath = path.join(basePath, currentPath);
298
+
299
+ if (currentLevel === targetLevel) {
300
+ // Label is the relative path for display
301
+ const label = currentPath || '';
302
+ return [{ path: fullPath, label }];
303
+ }
304
+
305
+ const directories = [];
306
+ const entries = await fs.readdir(fullPath, { withFileTypes: true });
307
+
308
+ for (const entry of entries) {
309
+ if (entry.isDirectory()) {
310
+ const subPath = path.join(currentPath, entry.name);
311
+ const subDirs = await this.#getDirectoriesAtLevel(
312
+ basePath,
313
+ targetLevel,
314
+ subPath,
315
+ currentLevel + 1,
316
+ );
317
+ directories.push(...subDirs);
318
+ }
319
+ }
320
+
321
+ return directories;
322
+ }
323
+
324
+ /**
325
+ * Count files for percentage-based progress
326
+ * @private
327
+ * @param {string} basePath - Base path to count from
328
+ * @param {Object} scanConfig - Scan configuration
329
+ * @param {boolean} useStream - Use streaming (globbyStream) or sync (globby) approach
330
+ */
331
+ async #countFiles(basePath, scanConfig, useStream = true) {
332
+ const sources = appConfig.getUploadSources();
333
+ let totalCount = 0;
334
+
335
+ for (const source of sources) {
336
+ const sourcePath = path.resolve(basePath, source);
337
+
338
+ if (useStream) {
339
+ // Streaming approach
340
+ const files = await globbyStream('**/*', {
341
+ cwd: sourcePath,
342
+ onlyFiles: true,
343
+ absolute: true,
344
+ });
345
+
346
+ for await (const file of files) {
347
+ if (!this.#shouldExcludeFile(file, scanConfig.excludePatterns)) {
348
+ totalCount++;
349
+ }
350
+ }
351
+ } else {
352
+ // Synchronous approach (original method)
353
+ const files = await globby([`${sourcePath}/**/*`], { onlyFiles: true });
354
+ for (const file of files) {
355
+ if (!this.#shouldExcludeFile(file, scanConfig.excludePatterns)) {
356
+ totalCount++;
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ return totalCount;
363
+ }
364
+
365
+ /**
366
+ * Stream files from a single directory and upload stats in batches
367
+ * @private
368
+ * @param {string} dirPath - Directory path to scan
369
+ * @param {Object} scanConfig - Scan configuration
370
+ * @param {string} tableName - Target table name
371
+ * @param {number|null} totalFiles - Total files count for progress (optional)
372
+ * @param {boolean} useStream - Use streaming (globbyStream) or sync (globby) approach
373
+ */
374
+ async #streamScanDirectory(
375
+ dirPath,
376
+ scanConfig,
377
+ tableName,
378
+ totalFiles = null,
379
+ useStream = true,
380
+ ) {
381
+ // For directory-level scanning, we scan the directory directly
382
+ const batchSize = scanConfig.batchSize || 2000;
383
+ const scanTimestamp = new Date().toISOString();
384
+
385
+ let filesScanned = 0;
386
+ let filesInserted = 0;
387
+ let filesSkipped = 0;
388
+ let totalSize = 0;
389
+ let currentBatch = [];
390
+
391
+ // Create progress bar
392
+ const progressBar = this.#createProgressBar(totalFiles);
393
+
394
+ try {
395
+ if (useStream) {
396
+ // Streaming approach: use globbyStream with stats option
397
+ const fileStream = globbyStream('**/*', {
398
+ cwd: dirPath,
399
+ onlyFiles: true,
400
+ absolute: true,
401
+ stats: true, // Get file stats during discovery
402
+ });
403
+
404
+ // Process each file from stream
405
+ for await (const entry of fileStream) {
406
+ // globby with stats:true returns {path, stats} objects
407
+ const filePath = typeof entry === 'string' ? entry : entry.path;
408
+ const stats = typeof entry === 'object' ? entry.stats : null;
409
+
410
+ const result = await this.#processFileEntry(
411
+ filePath,
412
+ stats,
413
+ dirPath,
414
+ scanConfig,
415
+ scanTimestamp,
416
+ tableName,
417
+ currentBatch,
418
+ batchSize,
419
+ progressBar,
420
+ totalFiles,
421
+ { filesScanned, filesInserted, filesSkipped, totalSize },
422
+ );
423
+
424
+ filesScanned = result.filesScanned;
425
+ filesInserted = result.filesInserted;
426
+ filesSkipped = result.filesSkipped;
427
+ totalSize = result.totalSize;
428
+ currentBatch = result.currentBatch;
429
+ }
430
+ } else {
431
+ // Synchronous approach: use globby (original method from UploadCommand)
432
+ logger.debug('Using synchronous file discovery (globby)...');
433
+ const files = await globby([`${dirPath}/**/*`], { onlyFiles: true });
434
+ logger.debug(`Found ${files.length} files to process`);
435
+
436
+ for (const filePath of files) {
437
+ const result = await this.#processFileEntry(
438
+ filePath,
439
+ null, // Stats will be fetched manually
440
+ dirPath,
441
+ scanConfig,
442
+ scanTimestamp,
443
+ tableName,
444
+ currentBatch,
445
+ batchSize,
446
+ progressBar,
447
+ totalFiles,
448
+ { filesScanned, filesInserted, filesSkipped, totalSize },
449
+ );
450
+
451
+ filesScanned = result.filesScanned;
452
+ filesInserted = result.filesInserted;
453
+ filesSkipped = result.filesSkipped;
454
+ totalSize = result.totalSize;
455
+ currentBatch = result.currentBatch;
456
+ }
457
+ }
458
+ } catch (error) {
459
+ logger.error(`❌ Error scanning directory: ${error.message}`);
460
+ }
461
+
462
+ // Upload remaining files
463
+ if (currentBatch.length > 0) {
464
+ const inserted = await this.#uploadBatch(tableName, currentBatch);
465
+ filesInserted += inserted;
466
+ }
467
+
468
+ progressBar.stop();
469
+
470
+ return {
471
+ filesScanned,
472
+ filesInserted,
473
+ filesSkipped,
474
+ totalSize,
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Process a single file entry (used by both streaming and sync approaches)
480
+ * @private
481
+ */
482
+ async #processFileEntry(
483
+ filePath,
484
+ stats,
485
+ dirPath,
486
+ scanConfig,
487
+ scanTimestamp,
488
+ tableName,
489
+ currentBatch,
490
+ batchSize,
491
+ progressBar,
492
+ totalFiles,
493
+ counters,
494
+ ) {
495
+ let { filesScanned, filesInserted, filesSkipped, totalSize } = counters;
496
+
497
+ // Check if file should be excluded
498
+ if (this.#shouldExcludeFile(filePath, scanConfig.excludePatterns)) {
499
+ filesSkipped++;
500
+ return {
501
+ filesScanned,
502
+ filesInserted,
503
+ filesSkipped,
504
+ totalSize,
505
+ currentBatch,
506
+ };
507
+ }
508
+
509
+ // Get file stats (use from globby or fetch manually)
510
+ const fileStats = stats || FileOperations.getFileStats(filePath);
511
+ if (!fileStats) {
512
+ logger.debug(`⚠️ Could not read stats: ${filePath}`);
513
+ filesSkipped++;
514
+ return {
515
+ filesScanned,
516
+ filesInserted,
517
+ filesSkipped,
518
+ totalSize,
519
+ currentBatch,
520
+ };
521
+ }
522
+
523
+ // Normalize file record
524
+ const record = this.#normalizeFileRecord(
525
+ filePath,
526
+ fileStats,
527
+ dirPath,
528
+ scanTimestamp,
529
+ );
530
+
531
+ currentBatch.push(record);
532
+ filesScanned++;
533
+ totalSize += record.sizeBytes;
534
+
535
+ // Update progress
536
+ if (totalFiles) {
537
+ progressBar.update(filesScanned);
538
+ } else {
539
+ // Show throughput instead of percentage
540
+ const elapsed = (Date.now() - progressBar.startTime) / 1000;
541
+ const rate = (filesScanned / elapsed).toFixed(1);
542
+ progressBar.update(filesScanned, { rate });
543
+ }
544
+
545
+ // Upload batch when full
546
+ if (currentBatch.length >= batchSize) {
547
+ const inserted = await this.#uploadBatch(tableName, currentBatch);
548
+ filesInserted += inserted;
549
+ currentBatch = [];
550
+ }
551
+
552
+ return {
553
+ filesScanned,
554
+ filesInserted,
555
+ filesSkipped,
556
+ totalSize,
557
+ currentBatch,
558
+ };
559
+ }
560
+
561
+ /**
562
+ * Upload a batch of file records
563
+ * @private
564
+ */
565
+ async #uploadBatch(tableName, records) {
566
+ try {
567
+ const result = await this.scanApiService.batchInsertStats(
568
+ tableName,
569
+ records,
570
+ );
571
+ return result.inserted;
572
+ } catch (error) {
573
+ logger.error(`❌ Failed to upload batch: ${error.message}`);
574
+ return 0;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Normalize file record for database insertion
580
+ * Stores paths with forward slashes for consistency but keeps them absolute
581
+ * Sets likelySimplificado to true if file is a PDF and filename contains 'simp'
582
+ * Sets likelyInterAgencia to true if filename matches an inter-agency CFDI
583
+ * pattern (e.g. SICINGR*), so the API forces these XML/PDF through detection
584
+ * even though they lack the 'simp/pedim/covefact' heuristic.
585
+ * @private
586
+ */
587
+ #normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
588
+ const fileName = path.basename(filePath);
589
+ const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
590
+
591
+ // Normalize separators to forward slashes for consistency
592
+ const directoryPath = PathNormalizer.normalizeSeparators(
593
+ path.dirname(filePath),
594
+ );
595
+ const relativePath = PathNormalizer.getRelativePath(filePath, basePath);
596
+ const absolutePath = PathNormalizer.normalizeSeparators(filePath);
597
+
598
+ // Determine if this file is potentially a pedimento (simplificado, completo, or CoveFact).
599
+ // PDFs whose filename contains 'simp', 'pedim' or 'covefact' (case-insensitive)
600
+ // are flagged so the identify stage prioritizes them. The column name
601
+ // `likely_simplificado` is preserved for backwards compatibility; semantics
602
+ // are broader (any likely pedimento PDF).
603
+ const likelySimplificado =
604
+ fileExtension === 'pdf' && /(simp|pedim|covefact)/i.test(fileName);
605
+
606
+ // Flag inter-agency CFDIs by filename so detection picks them up.
607
+ // Patterns are configurable via SCAN_INTER_AGENCIA_PATTERNS env var
608
+ // (see config.js). Only meaningful for PDF and XML.
609
+ let likelyInterAgencia = false;
610
+ if (fileExtension === 'pdf' || fileExtension === 'xml') {
611
+ const patterns = appConfig.scan.interAgenciaPatterns;
612
+ if (patterns && patterns.length > 0) {
613
+ likelyInterAgencia = patterns.some((re) => re.test(fileName));
614
+ }
615
+ }
616
+
617
+ return {
618
+ fileName,
619
+ fileExtension,
620
+ directoryPath,
621
+ relativePath,
622
+ absolutePath,
623
+ sizeBytes: Number(fileStats.size),
624
+ modifiedAt: fileStats.mtime.toISOString(),
625
+ scanTimestamp,
626
+ likelySimplificado,
627
+ likelyInterAgencia,
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Check if file should be excluded based on patterns
633
+ * @private
634
+ */
635
+ #shouldExcludeFile(filePath, excludePatterns) {
636
+ const fileName = path.basename(filePath);
637
+
638
+ for (const pattern of excludePatterns) {
639
+ // Convert glob pattern to regex
640
+ const regexPattern = pattern
641
+ .replace(/\./g, '\\.') // Escape dots
642
+ .replace(/\*/g, '.*') // * to .*
643
+ .replace(/\?/g, '.'); // ? to .
644
+
645
+ const regex = new RegExp(`^${regexPattern}$`, 'i');
646
+ if (regex.test(fileName)) {
647
+ return true;
648
+ }
649
+ }
650
+
651
+ return false;
652
+ }
653
+
654
+ /**
655
+ * Create progress bar
656
+ * @private
657
+ */
658
+ #createProgressBar(totalFiles) {
659
+ if (totalFiles) {
660
+ // Percentage-based progress
661
+ const bar = new cliProgress.SingleBar(
662
+ {
663
+ format:
664
+ '📊 Scanning |{bar}| {percentage}% | {value}/{total} files | {rate} files/sec',
665
+ barCompleteChar: '\u2588',
666
+ barIncompleteChar: '\u2591',
667
+ hideCursor: true,
668
+ },
669
+ cliProgress.Presets.shades_classic,
670
+ );
671
+ bar.start(totalFiles, 0, { rate: '0.0' });
672
+ bar.startTime = Date.now();
673
+ return bar;
674
+ } else {
675
+ // Throughput-based progress
676
+ const bar = new cliProgress.SingleBar(
677
+ {
678
+ format: '📊 Scanning | {value} files | {rate} files/sec',
679
+ hideCursor: true,
680
+ clearOnComplete: false,
681
+ stopOnComplete: false,
682
+ },
683
+ cliProgress.Presets.legacy,
684
+ );
685
+ bar.start(0, 0, { rate: '0.0' });
686
+ bar.startTime = Date.now();
687
+ return bar;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Format bytes to human-readable size
693
+ * @private
694
+ */
695
+ #formatBytes(bytes) {
696
+ if (bytes === 0) return '0 Bytes';
697
+
698
+ const k = 1024;
699
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
700
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
701
+
702
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
703
+ }
704
+
705
+ /**
706
+ * Report progress to callback if available (for worker mode)
707
+ * @private
708
+ * @param {number} percent - Progress percentage (0-100)
709
+ * @param {string} message - Progress message
710
+ */
711
+ #reportProgress(percent, message) {
712
+ if (this.onProgress && typeof this.onProgress === 'function') {
713
+ try {
714
+ this.onProgress(percent, message);
715
+ } catch (error) {
716
+ logger.debug(`Progress callback error: ${error.message}`);
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ export default new ScanCommand();