403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import chokidar from "chokidar";
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import {
|
|
getImportSettingsByUser,
|
|
createSourceFile,
|
|
updateSourceFile,
|
|
getUserSettings,
|
|
findDuplicateInvoice,
|
|
createInvoice,
|
|
createImportLog,
|
|
} from "./db";
|
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
|
import { sendImportNotification } from "./notificationService";
|
|
|
|
interface FolderImportConfig {
|
|
userId: number;
|
|
folderPath: string;
|
|
frequency: number; // in minutes
|
|
}
|
|
|
|
// Store active watchers for each user
|
|
const activeWatchers = new Map<number, { watcher: any; interval: NodeJS.Timeout }>();
|
|
|
|
// Track processed files to avoid reprocessing
|
|
const processedFiles = new Map<number, Set<string>>();
|
|
|
|
/**
|
|
* Process a single PDF file from the watched folder
|
|
*/
|
|
async function processFolderFile(
|
|
userId: number,
|
|
filePath: string,
|
|
folderPath: string
|
|
): Promise<{ success: boolean; imported: number; duplicates: number; errors: number }> {
|
|
const fileName = path.basename(filePath);
|
|
console.log(`[FolderImport] Processing file: ${fileName} for user ${userId}`);
|
|
|
|
try {
|
|
// Read the file
|
|
const fileBuffer = await fs.readFile(filePath);
|
|
console.log(`[FolderImport] File size: ${fileBuffer.length} bytes`);
|
|
|
|
// Store source file
|
|
const sourceFileKey = generateStorageKey(userId, fileName);
|
|
console.log(`[FolderImport] Generated storage key: ${sourceFileKey}`);
|
|
|
|
let sourceFileUrl: string;
|
|
try {
|
|
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
|
|
sourceFileUrl = result.url;
|
|
console.log(`[FolderImport] File stored successfully at: ${sourceFileUrl}`);
|
|
} catch (error) {
|
|
console.error(`[FolderImport] FAILED to store file:`, error);
|
|
throw new Error("Failed to store PDF file");
|
|
}
|
|
|
|
// Create source file record
|
|
const sourceFile = await createSourceFile({
|
|
userId,
|
|
fileName,
|
|
fileKey: sourceFileKey,
|
|
fileUrl: sourceFileUrl,
|
|
processingStatus: "processing",
|
|
});
|
|
|
|
console.log(`[FolderImport] Source file record created with ID: ${sourceFile.id}`);
|
|
|
|
// Get user settings for custom keywords
|
|
const settings = await getUserSettings(userId);
|
|
const customKeywords = settings ? {
|
|
invoiceNumber: settings.invoiceNumberKeywords,
|
|
deliveryNote: settings.deliveryNoteKeywords,
|
|
orderNumber: settings.orderNumberKeywords,
|
|
supplier: settings.supplierKeywords,
|
|
totalAmount: settings.totalAmountKeywords,
|
|
subscription: settings.subscriptionKeywords,
|
|
recipient: settings.recipientKeywords,
|
|
} : undefined;
|
|
|
|
const model = settings?.llmModel || "mistral-large-latest";
|
|
|
|
// Extract invoices
|
|
console.log(`[FolderImport] Starting invoice extraction...`);
|
|
const result = await extractInvoicesWithMistral(
|
|
fileBuffer,
|
|
userId,
|
|
sourceFile.id,
|
|
model,
|
|
customKeywords
|
|
);
|
|
|
|
console.log(`[FolderImport] Extraction complete: ${result.invoiceCount} invoice(s) detected`);
|
|
|
|
// Update source file with total count
|
|
await updateSourceFile(sourceFile.id, {
|
|
totalInvoicesDetected: result.invoiceCount,
|
|
processingProgress: `Extraction ${result.invoiceCount} facture(s) détectée(s)`,
|
|
});
|
|
|
|
// Process each invoice
|
|
let importedCount = 0;
|
|
let duplicatesCount = 0;
|
|
let errorsCount = 0;
|
|
const duplicateDetails: any[] = [];
|
|
const errorDetails: any[] = [];
|
|
|
|
for (let i = 0; i < result.invoices.length; i++) {
|
|
const invoiceData = result.invoices[i]!;
|
|
|
|
try {
|
|
// Update progress
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
|
});
|
|
|
|
// Check for duplicates
|
|
const duplicate = await findDuplicateInvoice(
|
|
invoiceData.supplierName,
|
|
invoiceData.invoiceNumber,
|
|
invoiceData.invoiceDate
|
|
);
|
|
|
|
if (duplicate) {
|
|
duplicatesCount++;
|
|
duplicateDetails.push({
|
|
supplierName: invoiceData.supplierName,
|
|
invoiceNumber: invoiceData.invoiceNumber,
|
|
invoiceDate: invoiceData.invoiceDate,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Generate metadata JSON
|
|
const metadataJson = generateMetadataJSON(invoiceData);
|
|
const metadataKey = generateStorageKey(userId, `${fileName}-${i + 1}-metadata.json`);
|
|
const { url: metadataUrl } = await localStoragePut(
|
|
metadataKey,
|
|
Buffer.from(metadataJson),
|
|
"application/json"
|
|
);
|
|
|
|
// Create invoice record
|
|
await createInvoice({
|
|
userId,
|
|
sourceFileId: sourceFile.id,
|
|
invoiceIndexInFile: i + 1,
|
|
fileName: `${fileName} - Facture ${i + 1}`,
|
|
fileKey: sourceFileKey,
|
|
fileUrl: sourceFileUrl,
|
|
supplierName: invoiceData.supplierName,
|
|
invoiceNumber: invoiceData.invoiceNumber,
|
|
invoiceDate: invoiceData.invoiceDate,
|
|
deliveryNoteNumber: invoiceData.deliveryNoteNumber,
|
|
orderNumber: invoiceData.orderNumber,
|
|
totalAmount: invoiceData.totalAmount?.toString(),
|
|
recipientName: invoiceData.recipientName,
|
|
pageRange: invoiceData.pageRange,
|
|
qualityScore: invoiceData.qualityScore,
|
|
extractedText: invoiceData.extractedText,
|
|
metadataFileKey: metadataKey,
|
|
metadataFileUrl: metadataUrl,
|
|
status: "completed",
|
|
});
|
|
|
|
importedCount++;
|
|
} catch (error: any) {
|
|
errorsCount++;
|
|
errorDetails.push({
|
|
invoiceIndex: i + 1,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update source file status
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingStatus: "completed",
|
|
processingProgress: `Terminé: ${importedCount} importée(s), ${duplicatesCount} doublon(s)`,
|
|
});
|
|
|
|
// Create import log
|
|
await createImportLog({
|
|
userId,
|
|
sourceFileId: sourceFile.id,
|
|
fileName,
|
|
totalInvoicesDetected: result.invoiceCount,
|
|
invoicesImported: importedCount,
|
|
duplicatesIgnored: duplicatesCount,
|
|
errors: errorsCount,
|
|
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
|
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
|
});
|
|
|
|
// Move file to processed folder
|
|
try {
|
|
const processedDir = path.join(folderPath, "processed");
|
|
await fs.mkdir(processedDir, { recursive: true });
|
|
const newPath = path.join(processedDir, fileName);
|
|
await fs.rename(filePath, newPath);
|
|
console.log(`[FolderImport] File moved to processed folder: ${newPath}`);
|
|
} catch (error) {
|
|
console.error(`[FolderImport] Failed to move file to processed folder:`, error);
|
|
}
|
|
|
|
console.log(`[FolderImport] Successfully processed file: ${fileName}`);
|
|
console.log(`[FolderImport] Results: ${importedCount} imported, ${duplicatesCount} duplicates, ${errorsCount} errors`);
|
|
|
|
return {
|
|
success: true,
|
|
imported: importedCount,
|
|
duplicates: duplicatesCount,
|
|
errors: errorsCount,
|
|
};
|
|
} catch (error) {
|
|
console.error(`[FolderImport] Error processing file ${fileName}:`, error);
|
|
return {
|
|
success: false,
|
|
imported: 0,
|
|
duplicates: 0,
|
|
errors: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan folder for PDF files and process them
|
|
*/
|
|
async function scanAndProcessFolder(config: FolderImportConfig): Promise<void> {
|
|
console.log(`[FolderImport] Scanning folder: ${config.folderPath} for user ${config.userId}`);
|
|
|
|
try {
|
|
// Check if folder exists
|
|
try {
|
|
await fs.access(config.folderPath);
|
|
} catch (error) {
|
|
console.error(`[FolderImport] Folder does not exist: ${config.folderPath}`);
|
|
return;
|
|
}
|
|
|
|
// Read all files in the folder
|
|
const files = await fs.readdir(config.folderPath);
|
|
const pdfFiles = files.filter(file => file.toLowerCase().endsWith('.pdf'));
|
|
|
|
if (pdfFiles.length === 0) {
|
|
console.log(`[FolderImport] No PDF files found in folder for user ${config.userId}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[FolderImport] Found ${pdfFiles.length} PDF file(s) for user ${config.userId}`);
|
|
|
|
// Get or create processed files set for this user
|
|
if (!processedFiles.has(config.userId)) {
|
|
processedFiles.set(config.userId, new Set());
|
|
}
|
|
const userProcessedFiles = processedFiles.get(config.userId)!;
|
|
|
|
// Track totals for notification
|
|
let totalImported = 0;
|
|
let totalDuplicates = 0;
|
|
let totalErrors = 0;
|
|
let totalInvoices = 0;
|
|
let processedCount = 0;
|
|
|
|
// Process each PDF file
|
|
for (const file of pdfFiles) {
|
|
const filePath = path.join(config.folderPath, file);
|
|
|
|
// Skip if already processed
|
|
if (userProcessedFiles.has(filePath)) {
|
|
console.log(`[FolderImport] Skipping already processed file: ${file}`);
|
|
continue;
|
|
}
|
|
|
|
// Process the file
|
|
const result = await processFolderFile(config.userId, filePath, config.folderPath);
|
|
|
|
// Accumulate results
|
|
if (result.success) {
|
|
totalImported += result.imported;
|
|
totalDuplicates += result.duplicates;
|
|
totalErrors += result.errors;
|
|
totalInvoices += result.imported + result.duplicates;
|
|
processedCount++;
|
|
}
|
|
|
|
// Mark as processed
|
|
userProcessedFiles.add(filePath);
|
|
}
|
|
|
|
// Send notification if any files were processed
|
|
if (processedCount > 0) {
|
|
await sendImportNotification(config.userId, {
|
|
source: "folder",
|
|
totalFiles: processedCount,
|
|
totalInvoices,
|
|
imported: totalImported,
|
|
duplicates: totalDuplicates,
|
|
errors: totalErrors,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`[FolderImport] Error scanning folder for user ${config.userId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start folder import service for a user
|
|
*/
|
|
export async function startFolderImportService(userId: number): Promise<boolean> {
|
|
try {
|
|
// Get user's import settings
|
|
const settings = await getImportSettingsByUser(userId);
|
|
|
|
if (!settings || settings.autoImportEnabled !== 1) {
|
|
console.log(`[FolderImport] Auto import not enabled for user ${userId}`);
|
|
return false;
|
|
}
|
|
|
|
if (!settings.autoImportSourcePath) {
|
|
console.log(`[FolderImport] Folder path not configured for user ${userId}`);
|
|
return false;
|
|
}
|
|
|
|
// Stop existing service if running
|
|
stopFolderImportService(userId);
|
|
|
|
const config: FolderImportConfig = {
|
|
userId,
|
|
folderPath: settings.autoImportSourcePath,
|
|
frequency: settings.autoImportFrequency || 30,
|
|
};
|
|
|
|
const frequencyMs = config.frequency * 60 * 1000; // Convert minutes to milliseconds
|
|
|
|
console.log(
|
|
`[FolderImport] Starting folder import service for user ${userId} with frequency ${config.frequency} minutes`
|
|
);
|
|
|
|
// Run immediately on start
|
|
scanAndProcessFolder(config).catch((error) => {
|
|
console.error(`[FolderImport] Error scanning folder for user ${userId}:`, error);
|
|
});
|
|
|
|
// Set up interval for periodic scans
|
|
const interval = setInterval(() => {
|
|
scanAndProcessFolder(config).catch((error) => {
|
|
console.error(`[FolderImport] Error scanning folder for user ${userId}:`, error);
|
|
});
|
|
}, frequencyMs);
|
|
|
|
// Note: We're not using chokidar watcher for now, just periodic scans
|
|
// This is simpler and more reliable for the initial implementation
|
|
const dummyWatcher = chokidar.watch(config.folderPath, { ignored: /processed/ });
|
|
|
|
activeWatchers.set(userId, { watcher: dummyWatcher, interval });
|
|
console.log(`[FolderImport] Folder import service started for user ${userId}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`[FolderImport] Error starting folder import service for user ${userId}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop folder import service for a user
|
|
*/
|
|
export function stopFolderImportService(userId: number): void {
|
|
const service = activeWatchers.get(userId);
|
|
if (service) {
|
|
clearInterval(service.interval);
|
|
service.watcher.close();
|
|
activeWatchers.delete(userId);
|
|
|
|
// Clear processed files tracking
|
|
processedFiles.delete(userId);
|
|
|
|
console.log(`[FolderImport] Folder import service stopped for user ${userId}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if folder import service is running for a user
|
|
*/
|
|
export function isFolderImportServiceRunning(userId: number): boolean {
|
|
return activeWatchers.has(userId);
|
|
}
|
|
|
|
/**
|
|
* Stop all folder import services
|
|
*/
|
|
export function stopAllFolderImportServices(): void {
|
|
activeWatchers.forEach((service, userId) => {
|
|
clearInterval(service.interval);
|
|
service.watcher.close();
|
|
console.log(`[FolderImport] Stopped folder import service for user ${userId}`);
|
|
});
|
|
activeWatchers.clear();
|
|
processedFiles.clear();
|
|
}
|