2802 lines
120 KiB
TypeScript
2802 lines
120 KiB
TypeScript
import { z } from "zod";
|
||
import { COOKIE_NAME } from "@shared/const";
|
||
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
||
|
||
interface Condition {
|
||
field: string;
|
||
operator: string;
|
||
value: string;
|
||
}
|
||
|
||
interface Actions {
|
||
typeAchat?: string;
|
||
serviceConcerne?: string;
|
||
ventilationComptable?: string;
|
||
isSubscription?: 0 | 1;
|
||
}
|
||
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,
|
||
getSourceFileByContentHash,
|
||
getSourceFileById,
|
||
updateSourceFile,
|
||
getUserSettings,
|
||
upsertUserSettings,
|
||
createLocalUser,
|
||
getAllUsers,
|
||
updateUserPassword,
|
||
toggleUserActive,
|
||
deleteUser,
|
||
findDuplicateInvoice,
|
||
isInvoiceBlacklisted,
|
||
getAllInvoices,
|
||
getAllImportLogs,
|
||
getAllImportLogsWithDetails,
|
||
getAllBapHistory,
|
||
createImportLog,
|
||
getImportLogsByUser,
|
||
getImportLogsWithDetailsByUser,
|
||
deleteAllImportLogs,
|
||
getLlmLogsBySourceFile,
|
||
getLlmLogsByInvoice,
|
||
getImportSettingsByUser,
|
||
getEmailImportAccounts,
|
||
upsertImportSettings,
|
||
getDepartmentsByUser,
|
||
createDepartment,
|
||
deleteDepartment,
|
||
getAccountingAllocationsByUser,
|
||
createAccountingAllocation,
|
||
deleteAccountingAllocation,
|
||
initializeDefaultLists,
|
||
getLlmFieldsConfigByUser,
|
||
upsertLlmFieldConfig,
|
||
initializeDefaultLlmFields,
|
||
getAutomationRulesByUser,
|
||
getAutomationRuleById,
|
||
createAutomationRule,
|
||
updateAutomationRule,
|
||
deleteAutomationRule,
|
||
getExportAutomationRulesByUser,
|
||
getExportAutomationRuleById,
|
||
createExportAutomationRule,
|
||
updateExportAutomationRule,
|
||
deleteExportAutomationRule,
|
||
getSignaturesByUser,
|
||
getSignatureById,
|
||
createSignature,
|
||
deleteSignature,
|
||
getServiceSignaturesByUser,
|
||
upsertServiceSignature,
|
||
deleteServiceSignature,
|
||
createBapHistoryEntry,
|
||
getBapHistoryByUser,
|
||
deleteBapHistoryEntry,
|
||
updateBapHistoryPdfUrl,
|
||
getLearningsByUser,
|
||
getLearningsBySupplier,
|
||
upsertLearning,
|
||
deleteLearning,
|
||
deleteAllLearnings,
|
||
getBapPdfUrlsByInvoiceIds,
|
||
getWebImportSourcesByUser,
|
||
getWebImportSourceById,
|
||
createWebImportSource,
|
||
updateWebImportSource,
|
||
deleteWebImportSource,
|
||
} from "./db";
|
||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
|
||
import fsSync from "fs";
|
||
import pathSync from "path";
|
||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||
import { calculateFileSha256 } from "./fileFingerprint";
|
||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||
import { drawBapCartouche } from "./bapCartouche";
|
||
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, 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`);
|
||
|
||
// Le contrôle sur les octets du PDF intervient avant le stockage et l'appel IA.
|
||
// Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
|
||
const contentHash = calculateFileSha256(fileBuffer);
|
||
const existingSource = await getSourceFileByContentHash(contentHash);
|
||
if (existingSource) {
|
||
throw new TRPCError({
|
||
code: "CONFLICT",
|
||
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`,
|
||
});
|
||
}
|
||
|
||
// 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,
|
||
contentHash,
|
||
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...`,
|
||
});
|
||
|
||
// Vérifier la blacklist (factures supprimées manuellement)
|
||
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
|
||
if (blacklisted) {
|
||
duplicatesCount++;
|
||
duplicateDetails.push({
|
||
supplierName: invoiceData.supplierName,
|
||
invoiceNumber: invoiceData.invoiceNumber,
|
||
totalAmount: invoiceData.totalAmount,
|
||
reason: 'blacklisted',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// 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<string, string> = {};
|
||
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",
|
||
importTrigger: "manual",
|
||
});
|
||
|
||
} 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 (!meetsBapQualityThreshold(score)) {
|
||
throw new TRPCError({ code: "BAD_REQUEST", message: `Le score de qualité doit être au moins de ${BAP_MIN_QUALITY_SCORE}% 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 resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||
const bapExportMode = resolvedDestination.exportMode;
|
||
const exportFolder = resolvedDestination.destinationPath;
|
||
const exportFolderType = resolvedDestination.destinationType;
|
||
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}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
|
||
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
|
||
// Les fichiers Teams sont déposés via le site SharePoint de l’équipe.
|
||
console.log('[BAP] Démarrage upload Microsoft 365 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) =>
|
||
meetsBapQualityThreshold(inv.qualityScore) &&
|
||
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 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;
|
||
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||
const bapExportMode = resolvedDestination.exportMode;
|
||
const exportFolder = resolvedDestination.destinationPath;
|
||
const exportFolderType = resolvedDestination.destinationType;
|
||
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' || exportFolderType === 'teams') {
|
||
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 }) => {
|
||
// Les administrateurs consultent les mêmes données globales que les listes Factures.
|
||
return getInvoiceStats(ctx.user.role === "admin" ? undefined : 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 getAllImportLogsWithDetails();
|
||
}
|
||
return getImportLogsWithDetailsByUser(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;
|
||
}),
|
||
|
||
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle d’administrateur. */
|
||
getConfiguredAccounts: adminProcedure.query(async () => {
|
||
return getEmailImportAccounts();
|
||
}),
|
||
|
||
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
|
||
checkAccountNow: adminProcedure
|
||
.input(z.object({ userId: z.number().int().positive() }))
|
||
.mutation(async ({ input }) => {
|
||
return triggerManualEmailCheck(input.userId);
|
||
}),
|
||
}),
|
||
|
||
// ============= 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.isSubscription !== undefined || 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.isSubscription !== undefined || 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,
|
||
});
|
||
}),
|
||
}),
|
||
|
||
// ============= EXPORT AUTOMATION RULES =============
|
||
exportAutomationRules: router({
|
||
list: protectedProcedure.query(async ({ ctx }) => {
|
||
return getExportAutomationRulesByUser(ctx.user.id);
|
||
}),
|
||
create: protectedProcedure
|
||
.input(z.object({
|
||
name: z.string().trim().min(1).max(255),
|
||
conditionField: z.enum(["recipientName", "ventilationComptable"]),
|
||
conditionValue: z.string().trim().min(1).max(255),
|
||
destinationType: z.enum(["local", "teams", "sharepoint"]),
|
||
destinationPath: z.string().trim().min(1),
|
||
openInBrowser: z.number().int().min(0).max(1).default(0),
|
||
isActive: z.number().int().min(0).max(1).default(1),
|
||
priority: z.number().int().min(0).default(0),
|
||
}))
|
||
.mutation(async ({ input, ctx }) => createExportAutomationRule({ userId: ctx.user.id, ...input })),
|
||
update: protectedProcedure
|
||
.input(z.object({
|
||
id: z.number().int().positive(),
|
||
name: z.string().trim().min(1).max(255).optional(),
|
||
conditionField: z.enum(["recipientName", "ventilationComptable"]).optional(),
|
||
conditionValue: z.string().trim().min(1).max(255).optional(),
|
||
destinationType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
||
destinationPath: z.string().trim().min(1).optional(),
|
||
openInBrowser: z.number().int().min(0).max(1).optional(),
|
||
isActive: z.number().int().min(0).max(1).optional(),
|
||
priority: z.number().int().min(0).optional(),
|
||
}))
|
||
.mutation(async ({ input, ctx }) => {
|
||
const existing = await getExportAutomationRuleById(input.id);
|
||
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||
throw new TRPCError({ code: "NOT_FOUND" });
|
||
}
|
||
const { id, ...data } = input;
|
||
return updateExportAutomationRule(id, data);
|
||
}),
|
||
delete: protectedProcedure
|
||
.input(z.object({ id: z.number().int().positive() }))
|
||
.mutation(async ({ input, ctx }) => {
|
||
const existing = await getExportAutomationRuleById(input.id);
|
||
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||
throw new TRPCError({ code: "NOT_FOUND" });
|
||
}
|
||
await deleteExportAutomationRule(input.id);
|
||
return { success: true };
|
||
}),
|
||
}),
|
||
|
||
// ============= 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 };
|
||
}),
|
||
}),
|
||
|
||
// ============= WEB IMPORT SOURCES =============
|
||
webImportSources: router({
|
||
list: protectedProcedure.query(async ({ ctx }) => {
|
||
return getWebImportSourcesByUser(ctx.user.id);
|
||
}),
|
||
|
||
create: protectedProcedure
|
||
.input(z.object({
|
||
name: z.string().min(1).max(100),
|
||
connectorType: z.string().min(1).max(50),
|
||
portalUrl: z.string().url(),
|
||
loginEmail: z.string().min(1),
|
||
loginPassword: z.string().min(1),
|
||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
|
||
autoEnabled: z.number().min(0).max(1).default(0),
|
||
}))
|
||
.mutation(async ({ input, ctx }) => {
|
||
return createWebImportSource({ ...input, userId: ctx.user.id });
|
||
}),
|
||
|
||
update: protectedProcedure
|
||
.input(z.object({
|
||
id: z.number(),
|
||
name: z.string().min(1).max(100).optional(),
|
||
connectorType: z.string().min(1).max(50).optional(),
|
||
portalUrl: z.string().url().optional(),
|
||
loginEmail: z.string().min(1).optional(),
|
||
loginPassword: z.string().optional(),
|
||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||
autoEnabled: z.number().min(0).max(1).optional(),
|
||
}))
|
||
.mutation(async ({ input, ctx }) => {
|
||
const source = await getWebImportSourceById(input.id);
|
||
if (!source || source.userId !== ctx.user.id) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||
}
|
||
const { id, ...data } = input;
|
||
await updateWebImportSource(id, data);
|
||
return { success: true };
|
||
}),
|
||
|
||
delete: protectedProcedure
|
||
.input(z.object({ id: z.number() }))
|
||
.mutation(async ({ input, ctx }) => {
|
||
const source = await getWebImportSourceById(input.id);
|
||
if (!source || source.userId !== ctx.user.id) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||
}
|
||
await deleteWebImportSource(input.id);
|
||
return { success: true };
|
||
}),
|
||
|
||
getToken: protectedProcedure
|
||
.input(z.object({ id: z.number() }))
|
||
.query(async ({ input, ctx }) => {
|
||
const source = await getWebImportSourceById(input.id);
|
||
if (!source || source.userId !== ctx.user.id) {
|
||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||
}
|
||
return { apiToken: source.apiToken };
|
||
}),
|
||
}),
|
||
|
||
// ============= BACKUP ROUTES =============
|
||
backup: router({
|
||
// Liste les sauvegardes existantes dans le dossier backups/
|
||
list: adminProcedure.query(async () => {
|
||
const backupDir = pathSync.resolve("backups");
|
||
if (!fsSync.existsSync(backupDir)) return [];
|
||
const files = fsSync.readdirSync(backupDir)
|
||
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
|
||
.map(f => {
|
||
const stat = fsSync.statSync(pathSync.join(backupDir, f));
|
||
return { name: f, size: stat.size, createdAt: stat.mtime };
|
||
})
|
||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||
return files;
|
||
}),
|
||
|
||
// Supprime une sauvegarde
|
||
delete: adminProcedure
|
||
.input(z.object({ name: z.string() }))
|
||
.mutation(async ({ input }) => {
|
||
const backupDir = pathSync.resolve("backups");
|
||
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
|
||
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
|
||
fsSync.unlinkSync(filePath);
|
||
return { success: true };
|
||
}),
|
||
}),
|
||
});
|
||
export type AppRouter = typeof appRouter;
|