import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; interface Condition { field: string; operator: string; value: string; } interface Actions { typeAchat?: string; serviceConcerne?: string; ventilationComptable?: string; } import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { createInvoice, getInvoiceById, getInvoicesByUser, updateInvoice, deleteInvoice, searchInvoices, getInvoiceStats, createSourceFile, getSourceFileById, updateSourceFile, getUserSettings, upsertUserSettings, createLocalUser, getAllUsers, updateUserPassword, toggleUserActive, deleteUser, findDuplicateInvoice, getAllInvoices, getAllImportLogs, getAllBapHistory, createImportLog, getImportLogsByUser, deleteAllImportLogs, getLlmLogsBySourceFile, getLlmLogsByInvoice, getImportSettingsByUser, upsertImportSettings, getDepartmentsByUser, createDepartment, deleteDepartment, getAccountingAllocationsByUser, createAccountingAllocation, deleteAccountingAllocation, initializeDefaultLists, getLlmFieldsConfigByUser, upsertLlmFieldConfig, initializeDefaultLlmFields, getAutomationRulesByUser, getAutomationRuleById, createAutomationRule, updateAutomationRule, deleteAutomationRule, getSignaturesByUser, getSignatureById, createSignature, deleteSignature, getServiceSignaturesByUser, upsertServiceSignature, deleteServiceSignature, createBapHistoryEntry, getBapHistoryByUser, deleteBapHistoryEntry, updateBapHistoryPdfUrl, getLearningsByUser, getLearningsBySupplier, upsertLearning, deleteLearning, deleteAllLearnings, getBapPdfUrlsByInvoiceIds, } from "./db"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { localStoragePut, generateStorageKey } from "./localStorage"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { drawBapCartouche } from "./bapCartouche"; import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService"; import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { TRPCError } from "@trpc/server"; import { processFreeproExcel } from "./freeproService"; import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport"; import { createFreeproImport, getFreeproImportsByUser, getFreeproImportWithLines, deleteFreeproImport, getFreeproSettings, upsertFreeproSettings, } from "./db"; // Admin-only procedure const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" }); } return next({ ctx }); }); export const appRouter = router({ system: systemRouter, // ============= AUTH ROUTES ============= auth: router({ me: publicProcedure.query(opts => opts.ctx.user), // Local login (username/email + password) loginLocal: publicProcedure .input(z.object({ email: z.string().min(1), password: z.string().min(1), })) .mutation(async ({ input, ctx }) => { const result = await loginLocal(input.email, input.password); if (!result) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" }); } // Set auth cookie ctx.res.cookie("auth_token", result.token, { httpOnly: true, secure: false, // Désactivé pour VPS sans HTTPS sameSite: "lax", path: "/", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); return { user: result.user }; }), // Get Azure AD login URL getAzureLoginUrl: publicProcedure.query(async () => { if (!isAzureAdConfigured()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD not configured" }); } const url = await getAzureAuthUrl(); return { url }; }), // Check if Azure AD is available isAzureAdAvailable: publicProcedure.query(() => { return { available: isAzureAdConfigured() }; }), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); ctx.res.clearCookie("auth_token", { path: "/", maxAge: -1 }); return { success: true }; }), }), // ============= INVOICE ROUTES ============= invoices: router({ // Upload and process PDF file upload: protectedProcedure .input(z.object({ fileName: z.string(), fileData: z.string(), // Base64 encoded PDF })) .mutation(async ({ input, ctx }) => { const userId = ctx.user.id; // Decode base64 file data const fileBuffer = Buffer.from(input.fileData, "base64"); console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`); // Store source file const sourceFileKey = generateStorageKey(userId, input.fileName); console.log(`[Upload] Generated storage key: ${sourceFileKey}`); let sourceFileUrl: string; try { const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf"); sourceFileUrl = result.url; console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`); } catch (error) { console.error(`[Upload] FAILED to store file:`, error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" }); } // Create source file record const sourceFile = await createSourceFile({ userId, fileName: input.fileName, fileKey: sourceFileKey, fileUrl: sourceFileUrl, processingStatus: "processing", }); // Start extraction process (async - don't wait) (async () => { try { // 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"; const aiSettings = settings ? { aiProvider: (settings as any).aiProvider || "mistral", mistralApiKey: (settings as any).mistralApiKey || null, manusForgeApiKey: (settings as any).manusForgeApiKey || null, manusForgeApiUrl: (settings as any).manusForgeApiUrl || null, geminiApiKey: (settings as any).geminiApiKey || null, } : undefined; // Extract invoices const result = await extractInvoicesWithMistral( fileBuffer, userId, sourceFile.id, model, customKeywords, aiSettings ); // 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 (numéro de facture + montant) const duplicate = await findDuplicateInvoice( invoiceData.invoiceNumber, invoiceData.totalAmount?.toString() ?? null, userId ); if (duplicate) { duplicatesCount++; duplicateDetails.push({ supplierName: invoiceData.supplierName, invoiceNumber: invoiceData.invoiceNumber, totalAmount: invoiceData.totalAmount, }); continue; } // Generate metadata JSON const metadataJson = generateMetadataJSON(invoiceData); const metadataKey = generateStorageKey(userId, `${input.fileName}-${i + 1}-metadata.json`); const { url: metadataUrl } = await localStoragePut( metadataKey, Buffer.from(metadataJson), "application/json" ); // Create invoice record const newInvoice = await createInvoice({ userId, sourceFileId: sourceFile.id, invoiceIndexInFile: i + 1, fileName: `${input.fileName} - Facture ${i + 1}`, fileKey: sourceFileKey, // Same as source for now 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, isSubscription: invoiceData.isSubscription ? 1 : 0, metadataFileKey: metadataKey, metadataFileUrl: metadataUrl, status: "completed", }); // Apply automation rules to the newly created invoice try { const { applyAutomationRules } = await import("./automationEngine"); const automationUpdates = await applyAutomationRules(userId, newInvoice); // If automation rules suggest updates, apply them if (Object.keys(automationUpdates).length > 0) { await updateInvoice(newInvoice.id, automationUpdates); } } catch (autoError) { console.error("[Automation] Error applying rules:", autoError); // Don't fail the import if automation fails } // Apply learnings (corrections manuelles mémorisées) after automation rules try { if (newInvoice.supplierName) { const learnings = await getLearningsBySupplier(userId, newInvoice.supplierName); if (learnings.length > 0) { const learningUpdates: Record = {}; const isSubscriptionLearning = learnings.find(l => l.fieldName === 'isSubscription'); for (const learning of learnings) { if (learning.fieldName === 'typeAchat' || learning.fieldName === 'serviceConcerne' || learning.fieldName === 'ventilationComptable') { learningUpdates[learning.fieldName] = learning.correctedValue; } } // Appliquer l'apprentissage isSubscription if (isSubscriptionLearning) { (learningUpdates as any)['isSubscription'] = isSubscriptionLearning.correctedValue === 'OUI' ? 1 : 0; } if (Object.keys(learningUpdates).length > 0) { await updateInvoice(newInvoice.id, learningUpdates as any); console.log(`[Learning] Applied ${Object.keys(learningUpdates).length} learning(s) to invoice ${newInvoice.id} (${newInvoice.supplierName})`); } } } } catch (learningError) { console.error("[Learning] Error applying learnings:", learningError); // Don't fail the import if learning application fails } 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: input.fileName, totalInvoicesDetected: result.invoiceCount, invoicesImported: importedCount, duplicatesIgnored: duplicatesCount, errors: errorsCount, duplicateDetails: JSON.stringify(duplicateDetails), errorDetails: JSON.stringify(errorDetails), importSource: "file", }); } catch (error: any) { console.error("[Upload] Extraction failed:", error); // Limit error message to 200 characters to avoid database field overflow const errorMsg = error.message ? String(error.message).substring(0, 200) : "Erreur inconnue"; await updateSourceFile(sourceFile.id, { processingStatus: "error", processingProgress: `Erreur: ${errorMsg}`, }); } })(); return { sourceFileId: sourceFile.id }; }), list: protectedProcedure.query(async ({ ctx }) => { // Les admins voient toutes les factures, les utilisateurs standard voient les leurs if (ctx.user.role === 'admin') { return getAllInvoices(); } return getInvoicesByUser(ctx.user.id); }), getById: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input, ctx }) => { const invoice = await getInvoiceById(input.id); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: "NOT_FOUND" }); } return invoice; }), update: protectedProcedure .input(z.object({ id: z.number(), data: z.object({ supplierName: z.string().optional(), invoiceNumber: z.string().optional(), invoiceDate: z.date().optional(), deliveryNoteNumber: z.string().optional(), orderNumber: z.string().optional(), totalAmount: z.string().optional(), recipientName: z.string().optional(), serviceConcerne: z.string().optional(), typeAchat: z.enum(["CAPEX", "OPEX"]).optional(), ventilationComptable: z.string().optional(), isSubscription: z.number().min(0).max(1).optional(), autoFilledFields: z.string().nullable().optional(), }), })) .mutation(async ({ input, ctx }) => { const invoice = await getInvoiceById(input.id); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: "NOT_FOUND" }); } await updateInvoice(input.id, { ...input.data, manuallyEdited: 1, }); return { success: true }; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const invoice = await getInvoiceById(input.id); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: "NOT_FOUND" }); } await deleteInvoice(input.id); return { success: true }; }), validateBAP: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const invoice = await getInvoiceById(input.id); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: "NOT_FOUND" }); } // Vérifier les critères de validation BAP const score = invoice.qualityScore || 0; const isNotSubscription = invoice.isSubscription === 0; const hasService = !!invoice.serviceConcerne; const hasTypeAchat = !!invoice.typeAchat; const hasVentilation = !!invoice.ventilationComptable; if (score < 100) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" }); } if (!isNotSubscription) { throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" }); } if (!hasService || !hasTypeAchat || !hasVentilation) { throw new TRPCError({ code: "BAD_REQUEST", message: "Les champs Service, Type d'achat et Ventilation doivent être remplis" }); } // ── Génération du PDF annoté ────────────────────────────────────── const fs = await import('fs/promises'); const path = await import('path'); const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const { localStoragePut, generateStorageKey } = await import('./localStorage'); const importSettings = await getImportSettingsByUser(ctx.user.id); const bapExportMode = importSettings?.bapExportMode || 'browser'; const exportFolder = importSettings?.exportFolder || null; const exportFolderType = (importSettings as any)?.exportFolderType || 'local'; const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); let pdfUrl: string | null = null; let exportPath: string | null = null; let signatureName: string | null = null; let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null; let sharepointUploadPath: string | null = null; let sharepointUploadError: string | null = null; try { if (!invoice.fileKey && !invoice.fileUrl) throw new Error('Fichier PDF source introuvable'); let pdfBytes: Buffer; // Essayer d'abord le chemin local, puis l'URL publique const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || ''); try { pdfBytes = await fs.readFile(sourcePath); } catch (_localErr) { // Fichier non disponible localement → télécharger depuis l'URL const fileUrl = invoice.fileUrl; if (!fileUrl) throw new Error('Fichier PDF source introuvable (local et URL)'); // Construire l'URL absolue si relative let absoluteUrl = fileUrl; if (fileUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteUrl = `${baseUrl}${fileUrl}`; } // Node.js 22 a fetch natif const response = await fetch(absoluteUrl); if (!response.ok) throw new Error(`Impossible de télécharger le PDF: ${response.status}`); const arrayBuffer = await response.arrayBuffer(); pdfBytes = Buffer.from(arrayBuffer); } const pdfDoc = await PDFDocument.load(pdfBytes); // ── Récupération de la signature du service ─────────────────────── let sigBytesForCartouche: Buffer | undefined; let sigMimeForCartouche: "image/png" | "image/jpeg" | undefined; const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const serviceName = invoice.serviceConcerne || ''; const assoc = serviceAssociations.find( a => a.serviceName.toLowerCase() === serviceName.toLowerCase() ); if (assoc) { const sig = await getSignatureById(assoc.signatureId); if (sig) { signatureName = `${sig.firstName} ${sig.lastName}`; try { const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_sigLocalErr) { const sigUrl = sig.imageUrl; if (!sigUrl) throw new Error('Image signature introuvable'); let absoluteSigUrl = sigUrl; if (sigUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteSigUrl = `${baseUrl}${sigUrl}`; } const sigResp = await fetch(absoluteSigUrl); if (!sigResp.ok) throw new Error(`Impossible de télécharger la signature: ${sigResp.status}`); sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer()); } sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore signature errors */ } } } // ── Placement intelligent du cartouche BAP ──────────────────────── await drawBapCartouche(pdfDoc, pdfBytes, { typeAchat: invoice.typeAchat || 'N/A', destinataire: (invoice as any).recipientName || 'TOUS', serviceConcerne: invoice.serviceConcerne || '-', ventilationComptable: invoice.ventilationComptable || '-', validatedAt: new Date(), signatureImageBytes: sigBytesForCartouche, signatureMimeType: sigMimeForCartouche, signatureName: signatureName || undefined, }); const signedPdfBytes = await pdfDoc.save(); const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim(); const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf'; console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`); if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) { if (exportFolderType === 'sharepoint') { // Mode SharePoint : upload via Microsoft Graph console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename); const { uploadToSharePoint } = await import('./sharepoint'); const spResult = await uploadToSharePoint( { tenantId: (importSettings as any)?.azureTenantId || '', clientId: (importSettings as any)?.azureClientId || '', clientSecret: (importSettings as any)?.azureClientSecret || '', sharepointUrl: exportFolder, }, Buffer.from(signedPdfBytes), bapFilename ); if (spResult.success) { exportPath = spResult.webUrl || exportFolder; sharepointUploadStatus = 'success'; sharepointUploadPath = spResult.webUrl || exportFolder; console.log('[BAP] Upload SharePoint réussi:', spResult.webUrl); } else { console.error('[BAP] Erreur upload SharePoint:', spResult.error, '| Debug:', spResult.debugInfo); sharepointUploadStatus = 'error'; sharepointUploadError = spResult.error || 'Erreur inconnue'; // Fallback : stocker localement const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); pdfUrl = url; } } else { // Mode dossier local : enregistrer sur le disque await fs.mkdir(exportFolder, { recursive: true }); exportPath = path.join(exportFolder, bapFilename); await fs.writeFile(exportPath, signedPdfBytes); } } if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) { // Mode navigateur : stocker localement et retourner l'URL relative const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); pdfUrl = url; } } catch (pdfError: any) { console.warn('[BAP] Erreur génération PDF:', pdfError.message); // On continue même si le PDF échoue — on valide quand même } // ── Mise à jour de la facture ───────────────────────────────────── const validatedAt = new Date(); const newExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported'; await updateInvoice(input.id, { bapValidated: 1, bapValidatedAt: validatedAt, exportStatus: newExportStatus as any, }); // ── Enregistrement dans l'historique BAP ───────────────────────── await createBapHistoryEntry({ userId: ctx.user.id, invoiceId: invoice.id, supplierName: invoice.supplierName || null, invoiceNumber: invoice.invoiceNumber || null, invoiceDate: invoice.invoiceDate || null, totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null, typeAchat: invoice.typeAchat || null, serviceConcerne: invoice.serviceConcerne || null, ventilationComptable: invoice.ventilationComptable || null, recipientName: (invoice as any).recipientName || null, exportMode: bapExportMode === 'folder' ? 'folder' : 'browser', exportPath: exportPath || null, pdfUrl: pdfUrl || null, signatureName: signatureName || null, sharepointUploadStatus: (sharepointUploadStatus as any) || null, sharepointUploadPath: sharepointUploadPath || null, sharepointUploadError: sharepointUploadError || null, validatedAt, }); return { success: true, invoiceId: input.id, validatedAt, pdfUrl, exportPath, exportMode: bapExportMode, sharepointUploadStatus: sharepointUploadStatus || null, sharepointUploadError: sharepointUploadError || null, sharepointUploadPath: sharepointUploadPath || null, }; }), // ── Dévalidation BAP ────────────────────────────────────── devalidateBAP: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()) })) .mutation(async ({ input, ctx }) => { let processed = 0; for (const id of input.invoiceIds) { const invoice = await getInvoiceById(id); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) continue; await updateInvoice(id, { bapValidated: 0, bapValidatedAt: null, exportStatus: "not_exported", }); processed++; } return { success: true, processed }; }), // ── Validation BAP en masse ────────────────────────────── validateBAPBulk: protectedProcedure .mutation(async ({ ctx }) => { const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id); // Filtrer les factures éligibles (non déjà validées) const eligible = allInvoices.filter((inv: any) => (inv.qualityScore || 0) >= 100 && inv.isSubscription === 0 && !!inv.serviceConcerne && !!inv.typeAchat && !!inv.ventilationComptable && !inv.bapValidated ); if (eligible.length === 0) { return { success: true, processed: 0, errors: 0, results: [] }; } const fs = await import('fs/promises'); const path = await import('path'); const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const { localStoragePut, generateStorageKey } = await import('./localStorage'); const importSettings = await getImportSettingsByUser(ctx.user.id); const bapExportMode = importSettings?.bapExportMode || 'browser'; const exportFolder = importSettings?.exportFolder || null; const exportFolderType = (importSettings as any)?.exportFolderType || 'local'; const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id); const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = []; let processed = 0; let errors = 0; const validatedAt = new Date(); for (const invoice of eligible) { let pdfUrl: string | null = null; let exportPath: string | null = null; let signatureName: string | null = null; let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null; let sharepointUploadPath: string | null = null; let sharepointUploadError: string | null = null; try { // ─ Lecture du PDF source ─ let sourcePdfBytes: Buffer; const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey); try { sourcePdfBytes = await fs.readFile(localPath); } catch (_) { const fileUrl = invoice.fileUrl; if (!fileUrl) throw new Error('Fichier PDF introuvable'); let absoluteUrl = fileUrl; if (fileUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteUrl = `${baseUrl}${fileUrl}`; } const resp = await fetch(absoluteUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); sourcePdfBytes = Buffer.from(await resp.arrayBuffer()); } const pdfDoc = await PDFDocument.load(sourcePdfBytes); // ── Récupération de la signature du service ─────────────────────── let sigBytesForCartouche2: Buffer | undefined; let sigMimeForCartouche2: "image/png" | "image/jpeg" | undefined; const serviceName2 = invoice.serviceConcerne || ''; const assoc2 = serviceSignaturesList.find( a => a.serviceName.toLowerCase() === serviceName2.toLowerCase() ); if (assoc2) { const sig2 = await getSignatureById(assoc2.signatureId); if (sig2) { signatureName = `${sig2.firstName} ${sig2.lastName}`; try { const sigImagePath2 = path.join(STORAGE_BASE_PATH, sig2.imageKey); try { sigBytesForCartouche2 = await fs.readFile(sigImagePath2); } catch (_) { const sigUrl2 = sig2.imageUrl; if (!sigUrl2) throw new Error('Image signature introuvable'); let absoluteSigUrl2 = sigUrl2; if (sigUrl2.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteSigUrl2 = `${baseUrl}${sigUrl2}`; } const sigResp2 = await fetch(absoluteSigUrl2); if (!sigResp2.ok) throw new Error(`HTTP ${sigResp2.status}`); sigBytesForCartouche2 = Buffer.from(await sigResp2.arrayBuffer()); } sigMimeForCartouche2 = sig2.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore */ } } } // ── Placement intelligent du cartouche BAP ──────────────────────── await drawBapCartouche(pdfDoc, sourcePdfBytes, { typeAchat: invoice.typeAchat || '', destinataire: (invoice as any).recipientName || 'TOUS', serviceConcerne: invoice.serviceConcerne || '-', ventilationComptable: invoice.ventilationComptable || '-', validatedAt: validatedAt, signatureImageBytes: sigBytesForCartouche2, signatureMimeType: sigMimeForCartouche2, signatureName: signatureName || undefined, }); const signedPdfBytes = await pdfDoc.save(); const _bapDateStr2 = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const _bapSupplier2 = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim(); const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf'; if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) { if (exportFolderType === 'sharepoint') { const { uploadToSharePoint } = await import('./sharepoint'); const spResult = await uploadToSharePoint( { tenantId: (importSettings as any)?.azureTenantId || '', clientId: (importSettings as any)?.azureClientId || '', clientSecret: (importSettings as any)?.azureClientSecret || '', sharepointUrl: exportFolder, }, Buffer.from(signedPdfBytes), bapFilename ); if (spResult.success) { exportPath = spResult.webUrl || exportFolder; } else { console.warn('[BAP Bulk] Erreur upload SharePoint:', spResult.error); const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); pdfUrl = url; } } else { await fs.mkdir(exportFolder, { recursive: true }); exportPath = path.join(exportFolder, bapFilename); await fs.writeFile(exportPath, signedPdfBytes); } } if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) { const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); pdfUrl = url; } } catch (pdfError: any) { console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message); } // Mise à jour de la facture const bulkExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported'; await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt, exportStatus: bulkExportStatus as any }); // Historique await createBapHistoryEntry({ userId: ctx.user.id, invoiceId: invoice.id, supplierName: invoice.supplierName || null, invoiceNumber: invoice.invoiceNumber || null, invoiceDate: invoice.invoiceDate || null, totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null, typeAchat: invoice.typeAchat || null, serviceConcerne: invoice.serviceConcerne || null, ventilationComptable: invoice.ventilationComptable || null, recipientName: (invoice as any).recipientName || null, exportMode: bapExportMode === 'folder' ? 'folder' : 'browser', exportPath: exportPath || null, pdfUrl: pdfUrl || null, signatureName: signatureName || null, sharepointUploadStatus: (sharepointUploadStatus as any) || null, sharepointUploadPath: sharepointUploadPath || null, sharepointUploadError: sharepointUploadError || null, validatedAt, }); results.push({ id: invoice.id, success: true, pdfUrl, exportPath }); processed++; } return { success: true, processed, errors, results }; }), // ── Récupérer les pdfUrl BAP pour une liste de factures validées ───────────────── getBapPdfUrls: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()) })) .query(async ({ input }) => { if (input.invoiceIds.length === 0) return {}; return getBapPdfUrlsByInvoiceIds(input.invoiceIds); }), // ── Relancer les automatismes sur une sélection ───────────────── reprocessSelected: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()).min(1), })) .mutation(async ({ input, ctx }) => { // Relance UNIQUEMENT les automatismes (sans re-extraction LLM) const { applyAutomationRules } = await import("./automationEngine"); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id); const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id)); if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' }); let processed = 0; let errors = 0; const results: Array<{ id: number; success: boolean; error?: string }> = []; for (const invoice of selected) { try { const automationUpdates = await applyAutomationRules(ctx.user.id, invoice); if (Object.keys(automationUpdates).length > 0) { await updateInvoice(invoice.id, automationUpdates); } results.push({ id: invoice.id, success: true }); processed++; } catch (err: any) { console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err); results.push({ id: invoice.id, success: false, error: err.message }); errors++; } } return { success: true, processed, errors, results }; }), // ── Régénérer le PDF BAP pour une facture validée sans PDF ───────────────── regenerateBapPdf: protectedProcedure .input(z.object({ invoiceId: z.number() })) .mutation(async ({ input, ctx }) => { const invoice = await getInvoiceById(input.invoiceId); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' }); } if (!invoice.bapValidated) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' }); } const fs = await import('fs/promises'); const path = await import('path'); const { PDFDocument } = await import('pdf-lib'); const { localStoragePut, generateStorageKey } = await import('./localStorage'); const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); if (!invoice.fileKey && !invoice.fileUrl) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' }); } let pdfBytes: Buffer; const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || ''); try { pdfBytes = await fs.readFile(sourcePath); } catch (_) { const fileUrl = invoice.fileUrl; if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' }); let absoluteUrl = fileUrl; if (fileUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteUrl = `${baseUrl}${fileUrl}`; } const response = await fetch(absoluteUrl); if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' }); pdfBytes = Buffer.from(await response.arrayBuffer()); } const pdfDoc = await PDFDocument.load(pdfBytes); // Récupération de la signature du service let sigBytesForCartouche: Buffer | undefined; let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined; let signatureName: string | null = null; if (invoice.serviceConcerne) { const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase()); if (assoc) { const sig = await getSignatureById(assoc.signatureId); if (sig) { signatureName = `${sig.firstName} ${sig.lastName}`; try { const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) { const sigUrl = sig.imageUrl; if (sigUrl) { let absoluteSigUrl = sigUrl; if (sigUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteSigUrl = `${baseUrl}${sigUrl}`; } const sigResp = await fetch(absoluteSigUrl); if (sigResp.ok) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer()); } } sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore */ } } } } // Placement du cartouche BAP const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date(); await drawBapCartouche(pdfDoc, pdfBytes, { typeAchat: invoice.typeAchat || 'N/A', destinataire: (invoice as any).recipientName || 'TOUS', serviceConcerne: invoice.serviceConcerne || '-', ventilationComptable: invoice.ventilationComptable || '-', validatedAt, signatureImageBytes: sigBytesForCartouche, signatureMimeType: sigMimeForCartouche, signatureName: signatureName || undefined, }); const signedPdfBytes = await pdfDoc.save(); const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim(); const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf'; const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); // Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl const allEntries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id); const latestEntry = allEntries .filter(e => e.invoiceId === input.invoiceId) .sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0]; if (latestEntry) { await updateBapHistoryPdfUrl(latestEntry.id, url); } // Mettre à jour le statut export de la facture await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any }); return { success: true, pdfUrl: url }; }), // // ── Historique BAP ──────────────────── search: protectedProcedure .input(z.object({ query: z.string() })) .query(async ({ input, ctx }) => { if (ctx.user.role === 'admin') { return searchInvoices(null, input.query); } return searchInvoices(ctx.user.id, input.query); }), getStats: protectedProcedure.query(async ({ ctx }) => { return getInvoiceStats(ctx.user.id); }), }), // ============= BAP HISTORY ROUTES ============= bapHistory: router({ getAll: protectedProcedure.query(async ({ ctx }) => { // Les admins voient tout l'historique BAP if (ctx.user.role === 'admin') { return getAllBapHistory(); } return getBapHistoryByUser(ctx.user.id); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id); const entry = entries.find(e => e.id === input.id); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); await deleteBapHistoryEntry(input.id); return { success: true }; }), // Re-génération du PDF annoté BAP à partir des données en base regenerate: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id); const entry = entries.find(e => e.id === input.id); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); const invoice = await getInvoiceById(entry.invoiceId); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' }); } const fs = await import('fs/promises'); const path = await import('path'); const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const { localStoragePut, generateStorageKey } = await import('./localStorage'); const importSettings = await getImportSettingsByUser(ctx.user.id); const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); if (!invoice.fileKey && !invoice.fileUrl) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' }); } let pdfBytes: Buffer; const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || ''); try { pdfBytes = await fs.readFile(sourcePath); } catch (_) { const fileUrl = invoice.fileUrl; if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' }); let absoluteUrl = fileUrl; if (fileUrl.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteUrl = `${baseUrl}${fileUrl}`; } const response = await fetch(absoluteUrl); if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' }); pdfBytes = Buffer.from(await response.arrayBuffer()); } const pdfDoc = await PDFDocument.load(pdfBytes); // ── Récupération de la signature du service ─────────────────────── let sigBytesForCartouche3: Buffer | undefined; let sigMimeForCartouche3: "image/png" | "image/jpeg" | undefined; let signatureName3: string | null = entry.signatureName || null; if (entry.serviceConcerne) { const serviceAssociations3 = await getServiceSignaturesByUser(ctx.user.id); const assoc3 = serviceAssociations3.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase()); if (assoc3) { const sig3 = await getSignatureById(assoc3.signatureId); if (sig3) { signatureName3 = `${sig3.firstName} ${sig3.lastName}`; try { const sigImagePath3 = path.join(STORAGE_BASE_PATH, sig3.imageKey); try { sigBytesForCartouche3 = await fs.readFile(sigImagePath3); } catch (_) { const sigUrl3 = sig3.imageUrl; if (!sigUrl3) throw new Error('Image signature introuvable'); let absoluteSigUrl3 = sigUrl3; if (sigUrl3.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; absoluteSigUrl3 = `${baseUrl}${sigUrl3}`; } const sigResp3 = await fetch(absoluteSigUrl3); if (!sigResp3.ok) throw new Error('Impossible de télécharger la signature'); sigBytesForCartouche3 = Buffer.from(await sigResp3.arrayBuffer()); } sigMimeForCartouche3 = sig3.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore */ } } } } // ── Placement intelligent du cartouche BAP ──────────────────────── const validatedDate3 = entry.validatedAt ? new Date(entry.validatedAt) : new Date(); await drawBapCartouche(pdfDoc, pdfBytes, { typeAchat: entry.typeAchat || 'N/A', destinataire: entry.recipientName || 'TOUS', serviceConcerne: entry.serviceConcerne || '-', ventilationComptable: entry.ventilationComptable || '-', validatedAt: validatedDate3, signatureImageBytes: sigBytesForCartouche3, signatureMimeType: sigMimeForCartouche3, signatureName: signatureName3 || undefined, }); const signedPdfBytes = await pdfDoc.save(); const _bapDateStr3 = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const _bapSupplier3 = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim(); const _bapNumber3 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const bapFilename = [_bapDateStr3, _bapSupplier3, _bapNumber3].filter(Boolean).join(' - ') + '.pdf'; const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); // Mettre à jour l'entrée bapHistory avec le nouveau pdfUrl await updateBapHistoryPdfUrl(input.id, url); return { success: true, pdfUrl: url }; }), }), // ============= SOURCE FILES ROUTES ============= sourceFiles: router({ getByIds: protectedProcedure .input(z.object({ ids: z.array(z.number()) })) .query(async ({ input, ctx }) => { const files = await Promise.all( input.ids.map(id => getSourceFileById(id)) ); return files.filter(f => f && f.userId === ctx.user.id); }), }), // ============= SETTINGS ROUTES ============= settings: router({ get: protectedProcedure.query(async ({ ctx }) => { return getUserSettings(ctx.user.id); }), upsert: protectedProcedure .input(z.object({ llmModel: z.string().optional(), orderNumberFormat: z.string().optional(), invoiceNumberKeywords: z.string().optional(), deliveryNoteKeywords: z.string().optional(), orderNumberKeywords: z.string().optional(), supplierKeywords: z.string().optional(), totalAmountKeywords: z.string().optional(), subscriptionKeywords: z.string().optional(), recipientKeywords: z.string().optional(), sftpRecipientFilter: z.string().optional(), sftpHost: z.string().optional(), sftpPort: z.number().optional(), sftpUsername: z.string().optional(), sftpPassword: z.string().optional(), sftpRemotePath: z.string().optional(), sftpAutoExport: z.number().optional(), llmLogsRetentionMonths: z.number().optional(), learningConfidenceThreshold: z.number().min(1).optional(), aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(), mistralApiKey: z.string().optional(), manusForgeApiKey: z.string().optional(), manusForgeApiUrl: z.string().optional(), geminiApiKey: z.string().optional(), })) .mutation(async ({ input, ctx }) => { await upsertUserSettings({ userId: ctx.user.id, ...input, }); return { success: true }; }), }), // ============= ADMIN ROUTES ============= admin: router({ createUser: adminProcedure .input(z.object({ email: z.string().min(1), password: z.string().min(1), name: z.string(), role: z.enum(["user", "admin"]), })) .mutation(async ({ input }) => { const passwordHash = await hashPassword(input.password); const user = await createLocalUser(input.email, passwordHash, input.name, input.role); return { user }; }), getAllUsers: adminProcedure.query(async () => { return getAllUsers(); }), updateUserPassword: adminProcedure .input(z.object({ userId: z.number(), newPassword: z.string().min(6), })) .mutation(async ({ input }) => { const passwordHash = await hashPassword(input.newPassword); await updateUserPassword(input.userId, passwordHash); return { success: true }; }), toggleUserActive: adminProcedure .input(z.object({ userId: z.number(), isActive: z.number(), })) .mutation(async ({ input }) => { await toggleUserActive(input.userId, input.isActive); return { success: true }; }), deleteUser: adminProcedure .input(z.object({ userId: z.number() })) .mutation(async ({ input }) => { await deleteUser(input.userId); return { success: true }; }), }), // ============= SFTP ROUTES ============= sftp: router({ testConnection: protectedProcedure.mutation(async ({ ctx }) => { const config = await getUserSftpConfig(ctx.user.id); if (!config) { throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" }); } const success = await testSftpConnection(config); return { success }; }), exportToExcel: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()) })) .mutation(async ({ input, ctx }) => { const invoices = await Promise.all( input.invoiceIds.map(id => getInvoiceById(id)) ); const validInvoices = invoices.filter( inv => inv && (ctx.user.role === 'admin' || inv.userId === ctx.user.id) ); // Return invoice data for Excel generation on client side return { success: true, invoices: validInvoices.map(inv => ({ id: inv!.id, supplierName: inv!.supplierName || '', invoiceNumber: inv!.invoiceNumber || '', invoiceDate: inv!.invoiceDate ? new Date(inv!.invoiceDate).toLocaleDateString('fr-FR') : '', deliveryNoteNumber: inv!.deliveryNoteNumber || '', orderNumber: inv!.orderNumber || '', totalAmount: inv!.totalAmount ? parseFloat(inv!.totalAmount as string) : 0, qualityScore: inv!.qualityScore || 0, exportStatus: inv!.exportStatus || 'not_exported', exportedAt: inv!.exportedAt ? new Date(inv!.exportedAt).toLocaleDateString('fr-FR') : '', createdAt: new Date(inv!.createdAt).toLocaleDateString('fr-FR'), })) }; }), exportToPdf: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()) })) .mutation(async ({ input, ctx }) => { const fs = await import('fs/promises'); const path = await import('path'); const { PDFDocument } = await import('pdf-lib'); // Get export folder from settings const settings = await getImportSettingsByUser(ctx.user.id); const exportFolder = settings?.exportFolder; const exportFolderType = (settings as any)?.exportFolderType || 'local'; const isSharePoint = exportFolderType === 'sharepoint'; // Load service→signature associations const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); if (!exportFolder) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier d'export n'est pas configuré. Veuillez le définir dans les paramètres de réception." }); } // Validate that all invoices have quality score of 100 const invoices = await Promise.all( input.invoiceIds.map(id => getInvoiceById(id)) ); const invalidInvoices = invoices.filter( inv => !inv || (ctx.user.role !== 'admin' && inv.userId !== ctx.user.id) || (inv.qualityScore || 0) < 100 ); if (invalidInvoices.length > 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Toutes les factures doivent avoir un score de qualité de 100% pour être exportées" }); } // Create export folder if not SharePoint if (!isSharePoint) { try { await fs.mkdir(exportFolder, { recursive: true }); } catch (error) { console.error('Error creating export folder:', error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Impossible de créer le dossier d'export: ${exportFolder}` }); } } console.log(`[exportToPdf] Mode: ${exportFolderType}, dossier: ${exportFolder}, ${invoices.length} facture(s)`); // Copy/upload PDFs to export folder const copiedFiles: string[] = []; const errors: string[] = []; const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), "storage"); for (const invoice of invoices) { if (invoice && invoice.fileKey) { try { // Build source path from fileKey const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey); // Build export filename: date-fournisseur-numero.pdf const invoiceDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const supplierSafe = (invoice.supplierName || 'INCONNU').replace(/[^a-zA-Z0-9\u00C0-\u017F\s-]/g, '').trim().replace(/\s+/g, '_'); const invoiceNumSafe = (invoice.invoiceNumber || `id${invoice.id}`).replace(/[^a-zA-Z0-9\u00C0-\u017F\s-]/g, '').trim().replace(/\s+/g, '_'); const filename = `${invoiceDateStr}-${supplierSafe}-${invoiceNumSafe}.pdf`; const destPath = path.join(exportFolder, filename); // Find signature associated with invoice service const serviceName = invoice.serviceConcerne || ''; const assoc = serviceAssociations.find( a => a.serviceName.toLowerCase() === serviceName.toLowerCase() ); if (assoc) { // Load signature image and embed it in the PDF try { const sig = await getSignatureById(assoc.signatureId); if (sig) { const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); // Lire le PDF source (local ou URL) let pdfBytes: Buffer; try { pdfBytes = await fs.readFile(sourcePath); } catch (_) { const fileUrl = invoice!.fileUrl; if (!fileUrl) throw new Error('PDF source introuvable'); let absUrl = fileUrl.startsWith('/') ? `${process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`}${fileUrl}` : fileUrl; const r = await fetch(absUrl); if (!r.ok) throw new Error(`HTTP ${r.status}`); pdfBytes = Buffer.from(await r.arrayBuffer()); } const pdfDoc = await PDFDocument.load(pdfBytes); const pages = pdfDoc.getPages(); const lastPage = pages[pages.length - 1]; const { width, height } = lastPage.getSize(); // Read signature image (local ou URL) let sigImageBytes: Buffer; try { sigImageBytes = await fs.readFile(sigImagePath); } catch (_) { const sigUrl = sig.imageUrl; if (!sigUrl) throw new Error('Signature introuvable'); let absSigUrl = sigUrl.startsWith('/') ? `${process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`}${sigUrl}` : sigUrl; const sr = await fetch(absSigUrl); if (!sr.ok) throw new Error(`HTTP ${sr.status}`); sigImageBytes = Buffer.from(await sr.arrayBuffer()); } const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; let embeddedSig; if (mimeType === 'image/png') { embeddedSig = await pdfDoc.embedPng(sigImageBytes); } else { embeddedSig = await pdfDoc.embedJpg(sigImageBytes); } // Draw signature in bottom-right corner const sigWidth = 120; const sigHeight = 50; const margin = 30; lastPage.drawImage(embeddedSig, { x: width - sigWidth - margin, y: margin, width: sigWidth, height: sigHeight, }); // Add signer name below signature const { StandardFonts } = await import('pdf-lib'); const font = await pdfDoc.embedFont(StandardFonts.Helvetica); lastPage.drawText(`${sig.firstName} ${sig.lastName}`, { x: width - sigWidth - margin, y: margin - 14, size: 9, font, }); const signedPdfBytes = await pdfDoc.save(); console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`); if (isSharePoint) { const { uploadToSharePoint } = await import('./sharepoint'); console.log(`[exportToPdf] Upload SharePoint: ${filename}`); const spResult = await uploadToSharePoint( { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, Buffer.from(signedPdfBytes), filename ); if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`); console.log(`[exportToPdf] Upload SharePoint réussi: ${spResult.webUrl}`); copiedFiles.push(spResult.webUrl || filename); } else { await fs.writeFile(destPath, signedPdfBytes); copiedFiles.push(destPath); } } else { // Signature not found, upload/copy without signature let pdfBuf: Buffer; try { pdfBuf = await fs.readFile(sourcePath); } catch (_) { const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable'); const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu; const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); pdfBuf = Buffer.from(await rb.arrayBuffer()); } if (isSharePoint) { const { uploadToSharePoint } = await import('./sharepoint'); const spResult = await uploadToSharePoint( { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, pdfBuf, filename ); if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`); copiedFiles.push(spResult.webUrl || filename); } else { try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); } copiedFiles.push(destPath); } } } catch (sigError: any) { console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`); let pdfBuf: Buffer; try { pdfBuf = await fs.readFile(sourcePath); } catch (_) { const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable'); const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu; const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); pdfBuf = Buffer.from(await rb.arrayBuffer()); } if (isSharePoint) { const { uploadToSharePoint } = await import('./sharepoint'); const spResult = await uploadToSharePoint( { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, pdfBuf, filename ); if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`); copiedFiles.push(spResult.webUrl || filename); } else { try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); } copiedFiles.push(destPath); } } } else { // No signature association, upload/copy as-is let pdfBuf: Buffer; try { pdfBuf = await fs.readFile(sourcePath); } catch (_) { const fu = invoice!.fileUrl; if (!fu) throw new Error('PDF introuvable'); const au = fu.startsWith('/') ? `${process.env.APP_BASE_URL||`http://localhost:${process.env.PORT||3000}`}${fu}` : fu; const rb = await fetch(au); if (!rb.ok) throw new Error(`HTTP ${rb.status}`); pdfBuf = Buffer.from(await rb.arrayBuffer()); } if (isSharePoint) { const { uploadToSharePoint } = await import('./sharepoint'); console.log(`[exportToPdf] Upload SharePoint sans signature: ${filename}`); const spResult = await uploadToSharePoint( { tenantId: (settings as any)?.azureTenantId || '', clientId: (settings as any)?.azureClientId || '', clientSecret: (settings as any)?.azureClientSecret || '', sharepointUrl: exportFolder }, pdfBuf, filename ); if (!spResult.success) throw new Error(`SharePoint: ${spResult.error}`); console.log(`[exportToPdf] Upload SharePoint réussi: ${spResult.webUrl}`); copiedFiles.push(spResult.webUrl || filename); } else { try { await fs.copyFile(sourcePath, destPath); } catch (_) { await fs.writeFile(destPath, pdfBuf); } copiedFiles.push(destPath); } } // Update export status await updateInvoice(invoice.id, { exportStatus: "exported", exportedAt: new Date(), exportMode: "manual", }); } catch (error: any) { console.error(`Error copying file for invoice ${invoice.id}:`, error); errors.push(`${invoice.supplierName || 'Inconnu'} (${invoice.invoiceNumber || 'N/A'}): ${error.message}`); // Update export status to error await updateInvoice(invoice.id, { exportStatus: "export_error", exportMode: "manual", }); } } } if (errors.length > 0) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Erreurs lors de l'export:\n${errors.join('\n')}` }); } return { success: true, exportFolder, copiedCount: copiedFiles.length, invoices: invoices.filter(Boolean).map(inv => ({ id: inv!.id, supplierName: inv!.supplierName, invoiceNumber: inv!.invoiceNumber, invoiceDate: inv!.invoiceDate, })) }; }), exportInvoices: protectedProcedure .input(z.object({ invoiceIds: z.array(z.number()) })) .mutation(async ({ input, ctx }) => { const config = await getUserSftpConfig(ctx.user.id); if (!config) { throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" }); } // Load user settings to check recipient filter const userSettings = await getUserSettings(ctx.user.id); const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || ""; let successCount = 0; let errorCount = 0; let filteredCount = 0; for (const invoiceId of input.invoiceIds) { try { const invoice = await getInvoiceById(invoiceId); if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) { errorCount++; continue; } // Apply recipient filter if configured if (recipientFilter) { const invoiceRecipient = ((invoice as any).recipientName || "").toLowerCase(); if (!invoiceRecipient.includes(recipientFilter.toLowerCase())) { filteredCount++; continue; } } await exportInvoiceToSftp( config, invoice.fileKey, invoice.metadataFileKey, invoice.invoiceDate || new Date() ); // Update invoice export status await updateInvoice(invoiceId, { exportedAt: new Date(), exportMode: "manual", }); successCount++; } catch (error) { console.error(`[SFTP] Failed to export invoice ${invoiceId}:`, error); errorCount++; } } return { successCount, errorCount, filteredCount }; }), }), // ============= IMPORT LOGS ROUTES ============= importLogs: router({ getByUser: protectedProcedure.query(async ({ ctx }) => { // Les admins voient tous les logs d'import if (ctx.user.role === 'admin') { return getAllImportLogs(); } return getImportLogsByUser(ctx.user.id); }), deleteAll: protectedProcedure.mutation(async ({ ctx }) => { // Les admins suppriment tous les logs, les utilisateurs standard suppriment les leurs if (ctx.user.role === 'admin') { await deleteAllImportLogs(null); } else { await deleteAllImportLogs(ctx.user.id); } return { success: true }; }), }), // ============= LLM LOGS ROUTES ============= llmLogs: router({ getBySourceFile: protectedProcedure .input(z.object({ sourceFileId: z.number() })) .query(async ({ input }) => { return getLlmLogsBySourceFile(input.sourceFileId); }), getByInvoice: protectedProcedure .input(z.object({ invoiceId: z.number() })) .query(async ({ input }) => { return getLlmLogsByInvoice(input.invoiceId); }), }), // ============= FOLDER IMPORT SERVICE ROUTES ============= folderImportService: router({ start: protectedProcedure.mutation(async ({ ctx }) => { const started = await startFolderImportService(ctx.user.id); return { success: started }; }), stop: protectedProcedure.mutation(async ({ ctx }) => { stopFolderImportService(ctx.user.id); return { success: true }; }), status: protectedProcedure.query(async ({ ctx }) => { const isRunning = isFolderImportServiceRunning(ctx.user.id); return { isRunning }; }), }), // ============= EMAIL IMPORT SERVICE ROUTES ============= emailImportService: router({ start: protectedProcedure.mutation(async ({ ctx }) => { const started = await startEmailImportService(ctx.user.id); return { success: started }; }), stop: protectedProcedure.mutation(async ({ ctx }) => { stopEmailImportService(ctx.user.id); return { success: true }; }), status: protectedProcedure.query(async ({ ctx }) => { return { isRunning: isEmailImportServiceRunning(ctx.user.id) }; }), checkNow: protectedProcedure.mutation(async ({ ctx }) => { const result = await triggerEmailCheck(ctx.user.id); return result; }), }), // ============= IMPORT SETTINGS ROUTES ============= importSettings: router({ get: protectedProcedure.query(async ({ ctx }) => { const settings = await getImportSettingsByUser(ctx.user.id); // Return default settings if none exist if (!settings) { return { userId: ctx.user.id, manualImportEnabled: 1, autoImportEnabled: 0, autoImportSourcePath: null, autoImportFrequency: 60, emailImportEnabled: 0, emailImportAddress: null, emailImportPassword: null, emailImportHost: null, emailImportPort: 993, emailImportFrequency: 30, exportFolder: null, exportFolderType: "local" as const, bapExportMode: "browser" as const, azureTenantId: null, azureClientId: null, azureClientSecret: null, }; } return settings; }), update: protectedProcedure .input(z.object({ manualImportEnabled: z.number().min(0).max(1), autoImportEnabled: z.number().min(0).max(1), autoImportSourcePath: z.string().nullable().optional(), autoImportFrequency: z.number().min(1).optional(), emailImportEnabled: z.number().min(0).max(1), emailImportAddress: z.string().email().nullable().optional(), emailImportPassword: z.string().nullable().optional(), emailImportHost: z.string().nullable().optional(), emailImportPort: z.number().min(1).max(65535).optional(), emailImportFrequency: z.number().min(1).optional(), emailImportSinceDate: z.number().nullable().optional(), // Unix timestamp (s) emailImportAuthMode: z.enum(["basic", "oauth2"]).optional(), exportFolder: z.string().nullable().optional(), exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(), bapExportMode: z.enum(["browser", "folder", "both"]).optional(), azureTenantId: z.string().nullable().optional(), azureClientId: z.string().nullable().optional(), azureClientSecret: z.string().nullable().optional(), azureSecretExpiresAt: z.union([z.date(), z.string().datetime({ offset: true }).transform(s => new Date(s)), z.string().regex(/^\d{4}-\d{2}-\d{2}$/).transform(s => new Date(s + 'T12:00:00.000Z'))]).nullable().optional(), })) .mutation(async ({ input, ctx }) => { const settings = await upsertImportSettings({ userId: ctx.user.id, ...input, }); return settings; }), testAzureConnection: protectedProcedure .mutation(async ({ ctx }) => { const settings = await getImportSettingsByUser(ctx.user.id); const tenantId = (settings as any)?.azureTenantId; const clientId = (settings as any)?.azureClientId; const clientSecret = (settings as any)?.azureClientSecret; const sharepointUrl = settings?.exportFolder; if (!tenantId || !clientId || !clientSecret) { return { success: false, error: 'Credentials Azure AD manquants (Tenant ID, Client ID ou Secret)' }; } try { const { uploadToSharePoint } = await import('./sharepoint'); // Test : obtenir un token OAuth2 uniquement (sans upload) const tokenRes = await fetch( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'https://graph.microsoft.com/.default', }), } ); const tokenData = await tokenRes.json() as any; if (tokenData.error) { return { success: false, error: `Erreur Azure AD : ${tokenData.error_description || tokenData.error}` }; } // Test accès SharePoint si URL configurée if (sharepointUrl) { const urlObj = new URL(sharepointUrl); const hostname = urlObj.hostname; // ex: itinova.sharepoint.com const siteTestRes = await fetch( `https://graph.microsoft.com/v1.0/sites/${hostname}`, { headers: { Authorization: `Bearer ${tokenData.access_token}` } } ); if (!siteTestRes.ok) { const siteErr = await siteTestRes.json() as any; return { success: false, error: `Token OK mais accès SharePoint refusé : ${siteErr?.error?.message || siteTestRes.status}` }; } } return { success: true, message: 'Connexion Azure AD réussie' + (sharepointUrl ? ' et accès SharePoint vérifié' : '') }; } catch (err: any) { return { success: false, error: err.message || 'Erreur inconnue' }; } }), testEmailConnection: protectedProcedure .mutation(async ({ ctx }) => { const settings = await getImportSettingsByUser(ctx.user.id); if (!settings?.emailImportAddress || !settings?.emailImportHost) { return { success: false, message: 'Configuration IMAP incomplète (adresse email ou serveur IMAP manquant)' }; } const authMode = (settings as any).emailImportAuthMode as 'basic' | 'oauth2' || 'basic'; if (authMode === 'basic' && !settings.emailImportPassword) { return { success: false, message: 'Mot de passe IMAP manquant' }; } if (authMode === 'oauth2' && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) { return { success: false, message: 'Credentials Azure AD incomplets pour OAuth2 (Tenant ID, Client ID, Client Secret requis)' }; } const result = await testImapConnection({ userId: ctx.user.id, emailAddress: settings.emailImportAddress, password: settings.emailImportPassword || '', host: settings.emailImportHost, port: settings.emailImportPort || 993, authMode, azureTenantId: settings.azureTenantId || undefined, azureClientId: settings.azureClientId || undefined, azureClientSecret: settings.azureClientSecret || undefined, }); return result; }), testSharePointUpload: protectedProcedure .mutation(async ({ ctx }) => { const settings = await getImportSettingsByUser(ctx.user.id); const tenantId = (settings as any)?.azureTenantId; const clientId = (settings as any)?.azureClientId; const clientSecret = (settings as any)?.azureClientSecret; const sharepointUrl = settings?.exportFolder; if (!tenantId || !clientId || !clientSecret) { return { success: false, error: 'Credentials Azure AD manquants (Tenant ID, Client ID ou Secret)' }; } if (!sharepointUrl) { return { success: false, error: 'URL SharePoint non configurée dans le dossier d\'export' }; } try { const { uploadToSharePoint } = await import('./sharepoint'); // Créer un fichier test de 1 Ko const testContent = `Test upload SharePoint - ${new Date().toISOString()}\nApplication : Dématérialisation Facturation\nCe fichier peut être supprimé.`; const testBuffer = Buffer.from(testContent, 'utf-8'); const testFileName = `test-upload-${Date.now()}.txt`; const result = await uploadToSharePoint( { tenantId, clientId, clientSecret, sharepointUrl }, testBuffer, testFileName ); if (result.success) { return { success: true, message: `Fichier test déposé avec succès dans SharePoint`, webUrl: result.webUrl, fileName: testFileName, debugInfo: result.debugInfo, }; } else { return { success: false, error: result.error || 'Échec de l\'upload test', debugInfo: result.debugInfo, }; } } catch (err: any) { return { success: false, error: err.message || 'Erreur inconnue' }; } }), }), // ============= DEPARTMENT LIST ROUTES ============= departments: router({ getByUser: protectedProcedure.query(async ({ ctx }) => { return await getDepartmentsByUser(ctx.user.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), })) .mutation(async ({ input, ctx }) => { // Initialize default lists if this is the first department const existing = await getDepartmentsByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLists(ctx.user.id); } // Check if department already exists const duplicate = existing.find(d => d.name.toLowerCase() === input.name.toLowerCase()); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: `Le service "${input.name}" existe déjà` }); } return await createDepartment({ userId: ctx.user.id, name: input.name, }); }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteDepartment(input.id); return { success: true }; }), }), // ============= ACCOUNTING ALLOCATION LIST ROUTES ============= accountingAllocations: router({ getByUser: protectedProcedure.query(async ({ ctx }) => { return await getAccountingAllocationsByUser(ctx.user.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), })) .mutation(async ({ input, ctx }) => { // Initialize default lists if this is the first allocation const existing = await getAccountingAllocationsByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLists(ctx.user.id); } // Check if allocation already exists const duplicate = existing.find(a => a.name.toLowerCase() === input.name.toLowerCase()); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: `La ventilation comptable "${input.name}" existe déjà` }); } return await createAccountingAllocation({ userId: ctx.user.id, name: input.name, }); }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteAccountingAllocation(input.id); return { success: true }; }), }), // ============= AUTOMATION RULES ROUTES ============= automationRules: router({ list: protectedProcedure.query(async ({ ctx }) => { return await getAutomationRulesByUser(ctx.user.id); }), getById: protectedProcedure .input(z.object({ id: z.number(), })) .query(async ({ input }) => { return await getAutomationRuleById(input.id); }), create: protectedProcedure .input(z.object({ name: z.string().min(1), isActive: z.number().min(0).max(1).optional(), priority: z.number().optional(), conditions: z.string(), // JSON string conditionsLogic: z.enum(["AND", "OR"]).optional(), actions: z.string(), // JSON string })) .mutation(async ({ input, ctx }) => { const rule = await createAutomationRule({ userId: ctx.user.id, ...input, }); // If rule is active, reapply to existing BAP invoices if (rule.isActive === 1) { const { applyAutomationRules } = await import("./automationEngine"); const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); } } } return rule; }), update: protectedProcedure .input(z.object({ id: z.number(), name: z.string().min(1).optional(), isActive: z.number().min(0).max(1).optional(), priority: z.number().optional(), conditions: z.string().optional(), conditionsLogic: z.enum(["AND", "OR"]).optional(), actions: z.string().optional(), })) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; const updated = await updateAutomationRule(id, data); // If rule is active, reapply to existing BAP invoices if (updated && updated.isActive === 1) { const { applyAutomationRules } = await import("./automationEngine"); const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); } } } return updated; }), delete: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input }) => { await deleteAutomationRule(input.id); return { success: true }; }), test: protectedProcedure .input(z.object({ conditions: z.string(), // JSON string conditionsLogic: z.enum(["AND", "OR"]), actions: z.string(), // JSON string })) .mutation(async ({ input, ctx }) => { // Import applyAutomationRules from automationEngine const { applyAutomationRules } = await import("./automationEngine"); // Get all user's invoices const invoices = await getInvoicesByUser(ctx.user.id); // Create a temporary rule object const tempRule = { id: -1, userId: ctx.user.id, name: "Test Rule", isActive: 1, priority: 999, conditions: input.conditions, conditionsLogic: input.conditionsLogic, actions: input.actions, createdAt: new Date(), updatedAt: new Date(), }; // Test the rule on each invoice and count matches let affectedCount = 0; const affectedInvoices = []; for (const invoice of invoices) { // Manually evaluate the rule for testing const conditions: Condition[] = JSON.parse(input.conditions); const actionsObj: Actions = JSON.parse(input.actions); // Evaluate conditions let conditionsMatch = true; if (input.conditionsLogic === "AND") { conditionsMatch = conditions.every(cond => { const fieldValue = invoice[cond.field as keyof typeof invoice]; if (!fieldValue) return false; const valueStr = String(fieldValue).toLowerCase(); const condValue = cond.value.toLowerCase(); if (cond.operator === "contains") return valueStr.includes(condValue); if (cond.operator === "equals") return valueStr === condValue; if (cond.operator === "startsWith") return valueStr.startsWith(condValue); if (cond.operator === "endsWith") return valueStr.endsWith(condValue); if (cond.operator === ">") return Number(fieldValue) > Number(cond.value); if (cond.operator === "<") return Number(fieldValue) < Number(cond.value); if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value); if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value); return false; }); } else { conditionsMatch = conditions.some(cond => { const fieldValue = invoice[cond.field as keyof typeof invoice]; if (!fieldValue) return false; const valueStr = String(fieldValue).toLowerCase(); const condValue = cond.value.toLowerCase(); if (cond.operator === "contains") return valueStr.includes(condValue); if (cond.operator === "equals") return valueStr === condValue; if (cond.operator === "startsWith") return valueStr.startsWith(condValue); if (cond.operator === "endsWith") return valueStr.endsWith(condValue); if (cond.operator === ">") return Number(fieldValue) > Number(cond.value); if (cond.operator === "<") return Number(fieldValue) < Number(cond.value); if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value); if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value); return false; }); } if (conditionsMatch) { affectedCount++; affectedInvoices.push({ id: invoice.id, supplierName: invoice.supplierName, invoiceNumber: invoice.invoiceNumber, changes: actionsObj, }); } } return { totalInvoices: invoices.length, affectedCount, affectedInvoices: affectedInvoices.slice(0, 10), // Return first 10 for preview }; }), duplicate: protectedProcedure .input(z.object({ id: z.number(), })) .mutation(async ({ input, ctx }) => { const original = await getAutomationRuleById(input.id); if (!original || original.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } // Create a copy with modified name const duplicate = await createAutomationRule({ userId: ctx.user.id, name: `${original.name} (copie)`, isActive: 0, // Inactive by default priority: original.priority, conditions: original.conditions, conditionsLogic: original.conditionsLogic, actions: original.actions, }); return duplicate; }), reapplyToExistingInvoices: protectedProcedure .mutation(async ({ ctx }) => { const { applyAutomationRules } = await import("./automationEngine"); // Get all BAP invoices (isSubscription = 0) const allInvoices = await getInvoicesByUser(ctx.user.id); const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0); let updatedCount = 0; // Apply automation rules to each BAP invoice for (const invoice of bapInvoices) { const updates = await applyAutomationRules(ctx.user.id, invoice); // Check if any updates were made if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { await updateInvoice(invoice.id, updates); updatedCount++; } } return { totalBapInvoices: bapInvoices.length, updatedCount, }; }), }), // ============= LLM FIELDS CONFIG ROUTES ============= llmFieldsConfig: router({ getAll: protectedProcedure .query(async ({ ctx }) => { // Initialize default fields if none exist const existing = await getLlmFieldsConfigByUser(ctx.user.id); if (existing.length === 0) { await initializeDefaultLlmFields(ctx.user.id); return await getLlmFieldsConfigByUser(ctx.user.id); } return existing; }), updateField: protectedProcedure .input(z.object({ fieldName: z.string(), isRequired: z.number().min(0).max(1), })) .mutation(async ({ input, ctx }) => { // Get existing field config const existing = await getLlmFieldsConfigByUser(ctx.user.id); const field = existing.find(f => f.fieldName === input.fieldName); if (!field) { throw new TRPCError({ code: "NOT_FOUND", message: "Field not found" }); } // Update the field return await upsertLlmFieldConfig({ userId: ctx.user.id, fieldName: input.fieldName, displayName: field.displayName, isRequired: input.isRequired, displayOrder: field.displayOrder, }); }), }), // ============= SIGNATURES ROUTES ============= signatures: router({ list: protectedProcedure.query(async ({ ctx }) => { return await getSignaturesByUser(ctx.user.id); }), upload: protectedProcedure .input(z.object({ firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), fileName: z.string().min(1), fileData: z.string(), // Base64 encoded image mimeType: z.string().default("image/png"), })) .mutation(async ({ input, ctx }) => { const userId = ctx.user.id; const fileBuffer = Buffer.from(input.fileData, "base64"); const safeFileName = `${input.firstName}-${input.lastName}-${Date.now()}-${input.fileName}` .replace(/[^a-zA-Z0-9._-]/g, "_"); const imageKey = generateStorageKey(userId, safeFileName); const result = await localStoragePut(imageKey, fileBuffer, input.mimeType); return await createSignature({ userId, firstName: input.firstName, lastName: input.lastName, imageKey, imageUrl: result.url, }); }), create: protectedProcedure .input(z.object({ firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), imageKey: z.string().min(1), imageUrl: z.string().min(1), })) .mutation(async ({ input, ctx }) => { return await createSignature({ userId: ctx.user.id, firstName: input.firstName, lastName: input.lastName, imageKey: input.imageKey, imageUrl: input.imageUrl, }); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const sig = await getSignatureById(input.id); if (!sig || sig.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } await deleteSignature(input.id); return { success: true }; }), }), // ============= SERVICE SIGNATURES ROUTES ============= serviceSignatures: router({ list: protectedProcedure.query(async ({ ctx }) => { return await getServiceSignaturesByUser(ctx.user.id); }), upsert: protectedProcedure .input(z.object({ serviceName: z.string().min(1).max(100), signatureId: z.number(), })) .mutation(async ({ input, ctx }) => { await upsertServiceSignature({ userId: ctx.user.id, serviceName: input.serviceName, signatureId: input.signatureId, }); return { success: true }; }), delete: protectedProcedure .input(z.object({ serviceName: z.string() })) .mutation(async ({ input, ctx }) => { await deleteServiceSignature(ctx.user.id, input.serviceName); return { success: true }; }), }), // ============= LEARNINGS ROUTES ============= learnings: router({ /** Liste tous les apprentissages de l'utilisateur */ list: protectedProcedure.query(async ({ ctx }) => { return await getLearningsByUser(ctx.user.id); }), /** Enregistre ou met à jour un apprentissage suite à une correction manuelle */ upsert: protectedProcedure .input(z.object({ supplierName: z.string().min(1), fieldName: z.string().min(1), originalValue: z.string().optional(), correctedValue: z.string(), })) .mutation(async ({ input, ctx }) => { await upsertLearning({ userId: ctx.user.id, supplierName: input.supplierName, fieldName: input.fieldName, originalValue: input.originalValue, correctedValue: input.correctedValue, }); return { success: true }; }), /** Supprime un apprentissage par ID */ delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const all = await getLearningsByUser(ctx.user.id); const entry = all.find(l => l.id === input.id); if (!entry) throw new TRPCError({ code: 'NOT_FOUND' }); await deleteLearning(input.id); return { success: true }; }), /** Supprime tous les apprentissages de l'utilisateur */ deleteAll: protectedProcedure .mutation(async ({ ctx }) => { await deleteAllLearnings(ctx.user.id); return { success: true }; }), }), // ============= FREEPRO VENTILATION ============= freepro: router({ /** Importe un fichier Excel FreePro et calcule la ventilation */ import: protectedProcedure .input( z.object({ moisLabel: z.string().regex(/^\d{2}\/\d{4}$/, "Format MM/YYYY requis"), fileName: z.string(), fileBase64: z.string(), // fichier Excel encodé en base64 }) ) .mutation(async ({ input, ctx }) => { const buffer = Buffer.from(input.fileBase64, "base64"); const result = processFreeproExcel(buffer, input.moisLabel, input.fileName); const importId = await createFreeproImport( { userId: ctx.user.id, moisLabel: result.moisLabel, annee: result.annee, mois: result.mois, refPiece: result.refPiece || null, fileName: input.fileName, nbLignes: result.nbLignes, totalTtc: result.totalTtc.toFixed(2), }, result.lines.map((l) => ({ structure: l.structure ?? null, type: l.type, montantCentimes: Math.round(l.montant * 100), })) ); return { importId, ...result }; }), /** Liste tous les imports FreePro de l'utilisateur */ list: protectedProcedure.query(async ({ ctx }) => { return getFreeproImportsByUser(ctx.user.id); }), /** Récupère un import FreePro avec ses lignes de ventilation */ getById: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input, ctx }) => { const data = await getFreeproImportWithLines(input.id); if (!data) throw new TRPCError({ code: "NOT_FOUND" }); if (data.import.userId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); return data; }), /** Supprime un import FreePro */ delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const data = await getFreeproImportWithLines(input.id); if (!data) throw new TRPCError({ code: "NOT_FOUND" }); if (data.import.userId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); await deleteFreeproImport(input.id); return { success: true }; }), /** Génère le PDF côté serveur et le retourne en base64 */ generatePdf: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input, ctx }) => { const data = await getFreeproImportWithLines(input.id); if (!data) throw new TRPCError({ code: "NOT_FOUND" }); if (data.import.userId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); const { generateFreeproPdf } = await import('./freeproPdfService'); const pdfBytes = await generateFreeproPdf( { mois: data.import.moisLabel, refPiece: data.import.refPiece || '' }, data.lines.map(l => ({ structure: l.structure || '', type: l.type || '', montantCentimes: l.montantCentimes, })) ); const base64 = Buffer.from(pdfBytes).toString('base64'); const [mm, yyyy] = data.import.moisLabel.split('/'); const fileName = `FreePro - ventilation facture ${mm.padStart(2,'0')}.${yyyy.slice(2)}.pdf`; return { base64, fileName }; }), /** Récupère les paramètres de connexion automatique FreePro */ getSettings: protectedProcedure.query(async ({ ctx }) => { const s = await getFreeproSettings(ctx.user.id); // Ne pas exposer le mot de passe en clair if (s) { return { ...s, loginPassword: s.loginPassword ? '••••••••' : null, hasPassword: !!s.loginPassword, }; } return null; }), /** Sauvegarde les paramètres de connexion automatique FreePro */ saveSettings: protectedProcedure .input( z.object({ portalUrl: z.string().url().optional(), loginEmail: z.string().email().optional().or(z.literal('')), loginPassword: z.string().optional(), // vide = ne pas changer frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(), maxAnteriority: z.number().nullable().optional(), // timestamp Unix en secondes autoEnabled: z.number().min(0).max(1).optional(), }) ) .mutation(async ({ input, ctx }) => { const existing = await getFreeproSettings(ctx.user.id); const updateData: any = {}; if (input.portalUrl !== undefined) updateData.portalUrl = input.portalUrl; if (input.loginEmail !== undefined) updateData.loginEmail = input.loginEmail || null; // Ne mettre à jour le mot de passe que si une vraie valeur est fournie if (input.loginPassword && input.loginPassword !== '••••••••') { updateData.loginPassword = input.loginPassword; } if (input.frequency !== undefined) updateData.frequency = input.frequency; if (input.maxAnteriority !== undefined) updateData.maxAnteriority = input.maxAnteriority; if (input.autoEnabled !== undefined) updateData.autoEnabled = input.autoEnabled; await upsertFreeproSettings(ctx.user.id, updateData); // Gérer le job périodique const newSettings = await getFreeproSettings(ctx.user.id); if (newSettings?.autoEnabled && newSettings.frequency !== 'manual') { const ms = frequencyToMs(newSettings.frequency); if (ms > 0) startFreeproAutoJob(ctx.user.id, ms); } else { stopFreeproAutoJob(ctx.user.id); } return { success: true }; }), /** Teste la connexion au portail FreePro. * Le mot de passe est récupéré depuis la base de données (le frontend ne le reçoit jamais en clair). * L'email peut être passé en paramètre pour tester avant sauvegarde, ou laisser vide pour utiliser celui en base. */ testConnection: protectedProcedure .input( z.object({ email: z.string().optional(), password: z.string().optional(), }) ) .mutation(async ({ input, ctx }) => { // Récupérer les vrais credentials depuis la base de données const settings = await getFreeproSettings(ctx.user.id); if (!settings?.loginPassword) { return { success: false, error: "Aucun mot de passe configuré. Veuillez sauvegarder les paramètres d'abord.", }; } const email = input?.email && !input.email.includes("•") ? input.email : settings.loginEmail ?? ""; const password = input?.password && !input.password.includes("•") ? input.password : settings.loginPassword; if (!email || !email.includes("@")) { return { success: false, error: "Aucun email configuré. Veuillez sauvegarder les paramètres d'abord.", }; } return testFreeproConnection(email, password); }), /** Force la récupération immédiate des factures FreePro */ forceImport: protectedProcedure.mutation(async ({ ctx }) => { const result = await runFreeproAutoImport(ctx.user.id); return result; }), /** Exporte la ventilation FreePro vers SharePoint */ exportToSharePoint: protectedProcedure .input(z.object({ id: z.number(), pdfBase64: z.string().optional() })) .mutation(async ({ input, ctx }) => { const data = await getFreeproImportWithLines(input.id); if (!data) throw new TRPCError({ code: "NOT_FOUND" }); if (data.import.userId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); // Récupérer les paramètres SharePoint depuis importSettings const settings = await getImportSettingsByUser(ctx.user.id); const tenantId = (settings as any)?.azureTenantId || ''; const clientId = (settings as any)?.azureClientId || ''; const clientSecret = (settings as any)?.azureClientSecret || ''; const sharepointUrl = (settings as any)?.exportFolder || ''; if (!tenantId || !clientId || !clientSecret) { throw new TRPCError({ code: "BAD_REQUEST", message: "Credentials Azure AD non configurés dans les paramètres d'export" }); } if (!sharepointUrl) { throw new TRPCError({ code: "BAD_REQUEST", message: "URL SharePoint non configurée dans le dossier d'export" }); } // Construire le nom du fichier const [moisStr, anneeStr] = data.import.moisLabel.split('/'); const moisPad = moisStr.padStart(2, '0'); const anneeCourt = anneeStr.slice(2); const fileName = `FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`; // Générer le PDF côté serveur const { generateFreeproPdf } = await import('./freeproPdfService'); const pdfBytes = await generateFreeproPdf( { mois: data.import.moisLabel, refPiece: data.import.refPiece || '' }, data.lines.map(l => ({ structure: l.structure || '', type: l.type || '', montantCentimes: l.montantCentimes, })) ); const pdfBuffer = Buffer.from(pdfBytes); const { uploadToSharePoint } = await import('./sharepoint'); const result = await uploadToSharePoint( { tenantId, clientId, clientSecret, sharepointUrl }, pdfBuffer, fileName ); if (!result.success) { // Sauvegarder le statut d'erreur en base const { updateFreeproSharepointStatus } = await import('./db'); await updateFreeproSharepointStatus(input.id, 'error', null, result.error || 'Erreur inconnue'); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error || 'Erreur upload SharePoint' }); } // Sauvegarder le statut de succès en base const { updateFreeproSharepointStatus } = await import('./db'); await updateFreeproSharepointStatus(input.id, 'success', result.webUrl || null, null); return { success: true, webUrl: result.webUrl, fileName }; }), }), }); export type AppRouter = typeof appRouter;