1550 lines
57 KiB
TypeScript
1550 lines
57 KiB
TypeScript
import { z } from "zod";
|
|
import { COOKIE_NAME } from "@shared/const";
|
|
|
|
interface Condition {
|
|
field: string;
|
|
operator: string;
|
|
value: string;
|
|
}
|
|
|
|
interface Actions {
|
|
typeAchat?: string;
|
|
serviceConcerne?: string;
|
|
ventilationComptable?: string;
|
|
}
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
|
import {
|
|
createInvoice,
|
|
getInvoiceById,
|
|
getInvoicesByUser,
|
|
updateInvoice,
|
|
deleteInvoice,
|
|
searchInvoices,
|
|
getInvoiceStats,
|
|
createSourceFile,
|
|
getSourceFileById,
|
|
updateSourceFile,
|
|
getUserSettings,
|
|
upsertUserSettings,
|
|
createLocalUser,
|
|
getAllUsers,
|
|
updateUserPassword,
|
|
toggleUserActive,
|
|
deleteUser,
|
|
findDuplicateInvoice,
|
|
createImportLog,
|
|
getImportLogsByUser,
|
|
deleteAllImportLogs,
|
|
getLlmLogsBySourceFile,
|
|
getLlmLogsByInvoice,
|
|
getImportSettingsByUser,
|
|
upsertImportSettings,
|
|
getDepartmentsByUser,
|
|
createDepartment,
|
|
deleteDepartment,
|
|
getAccountingAllocationsByUser,
|
|
createAccountingAllocation,
|
|
deleteAccountingAllocation,
|
|
initializeDefaultLists,
|
|
getLlmFieldsConfigByUser,
|
|
upsertLlmFieldConfig,
|
|
initializeDefaultLlmFields,
|
|
getAutomationRulesByUser,
|
|
getAutomationRuleById,
|
|
createAutomationRule,
|
|
updateAutomationRule,
|
|
deleteAutomationRule,
|
|
getSignaturesByUser,
|
|
getSignatureById,
|
|
createSignature,
|
|
deleteSignature,
|
|
getServiceSignaturesByUser,
|
|
upsertServiceSignature,
|
|
deleteServiceSignature,
|
|
createBapHistoryEntry,
|
|
getBapHistoryByUser,
|
|
deleteBapHistoryEntry,
|
|
} from "./db";
|
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
|
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService";
|
|
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
// Admin-only procedure
|
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
|
if (ctx.user.role !== "admin") {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
|
|
}
|
|
return next({ ctx });
|
|
});
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
|
|
// ============= AUTH ROUTES =============
|
|
auth: router({
|
|
me: publicProcedure.query(opts => opts.ctx.user),
|
|
|
|
// Local login (username/email + password)
|
|
loginLocal: publicProcedure
|
|
.input(z.object({
|
|
email: z.string().min(1),
|
|
password: z.string().min(1),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const result = await loginLocal(input.email, input.password);
|
|
|
|
if (!result) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" });
|
|
}
|
|
|
|
// Set auth cookie
|
|
ctx.res.cookie("auth_token", result.token, {
|
|
httpOnly: true,
|
|
secure: false, // Désactivé pour VPS sans HTTPS
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
});
|
|
|
|
return { user: result.user };
|
|
}),
|
|
|
|
// Get Azure AD login URL
|
|
getAzureLoginUrl: publicProcedure.query(async () => {
|
|
if (!isAzureAdConfigured()) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD not configured" });
|
|
}
|
|
|
|
const url = await getAzureAuthUrl();
|
|
return { url };
|
|
}),
|
|
|
|
// Check if Azure AD is available
|
|
isAzureAdAvailable: publicProcedure.query(() => {
|
|
return { available: isAzureAdConfigured() };
|
|
}),
|
|
|
|
logout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
|
ctx.res.clearCookie("auth_token", { path: "/", maxAge: -1 });
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= INVOICE ROUTES =============
|
|
invoices: router({
|
|
// Upload and process PDF file
|
|
upload: protectedProcedure
|
|
.input(z.object({
|
|
fileName: z.string(),
|
|
fileData: z.string(), // Base64 encoded PDF
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.user.id;
|
|
|
|
// Decode base64 file data
|
|
const fileBuffer = Buffer.from(input.fileData, "base64");
|
|
console.log(`[Upload] Received file: ${input.fileName}, size: ${fileBuffer.length} bytes`);
|
|
|
|
// Store source file
|
|
const sourceFileKey = generateStorageKey(userId, input.fileName);
|
|
console.log(`[Upload] Generated storage key: ${sourceFileKey}`);
|
|
|
|
let sourceFileUrl: string;
|
|
try {
|
|
const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf");
|
|
sourceFileUrl = result.url;
|
|
console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`);
|
|
} catch (error) {
|
|
console.error(`[Upload] FAILED to store file:`, error);
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" });
|
|
}
|
|
|
|
// Create source file record
|
|
const sourceFile = await createSourceFile({
|
|
userId,
|
|
fileName: input.fileName,
|
|
fileKey: sourceFileKey,
|
|
fileUrl: sourceFileUrl,
|
|
processingStatus: "processing",
|
|
});
|
|
|
|
// Start extraction process (async - don't wait)
|
|
(async () => {
|
|
try {
|
|
// Get user settings for custom keywords
|
|
const settings = await getUserSettings(userId);
|
|
const customKeywords = settings ? {
|
|
invoiceNumber: settings.invoiceNumberKeywords,
|
|
deliveryNote: settings.deliveryNoteKeywords,
|
|
orderNumber: settings.orderNumberKeywords,
|
|
supplier: settings.supplierKeywords,
|
|
totalAmount: settings.totalAmountKeywords,
|
|
subscription: settings.subscriptionKeywords,
|
|
recipient: settings.recipientKeywords,
|
|
} : undefined;
|
|
|
|
const model = settings?.llmModel || "mistral-large-latest";
|
|
|
|
// Extract invoices
|
|
const result = await extractInvoicesWithMistral(
|
|
fileBuffer,
|
|
userId,
|
|
sourceFile.id,
|
|
model,
|
|
customKeywords
|
|
);
|
|
|
|
// Update source file with total count
|
|
await updateSourceFile(sourceFile.id, {
|
|
totalInvoicesDetected: result.invoiceCount,
|
|
processingProgress: `Extraction ${result.invoiceCount} facture(s) détectée(s)`,
|
|
});
|
|
|
|
// Process each invoice
|
|
let importedCount = 0;
|
|
let duplicatesCount = 0;
|
|
let errorsCount = 0;
|
|
const duplicateDetails: any[] = [];
|
|
const errorDetails: any[] = [];
|
|
|
|
for (let i = 0; i < result.invoices.length; i++) {
|
|
const invoiceData = result.invoices[i]!;
|
|
|
|
try {
|
|
// Update progress
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
|
});
|
|
|
|
// Check for duplicates
|
|
const duplicate = await findDuplicateInvoice(
|
|
invoiceData.supplierName,
|
|
invoiceData.invoiceNumber,
|
|
invoiceData.invoiceDate
|
|
);
|
|
|
|
if (duplicate) {
|
|
duplicatesCount++;
|
|
duplicateDetails.push({
|
|
supplierName: invoiceData.supplierName,
|
|
invoiceNumber: invoiceData.invoiceNumber,
|
|
invoiceDate: invoiceData.invoiceDate,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Generate metadata JSON
|
|
const metadataJson = generateMetadataJSON(invoiceData);
|
|
const metadataKey = generateStorageKey(userId, `${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
|
|
}
|
|
|
|
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),
|
|
});
|
|
|
|
} 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 }) => {
|
|
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 || 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 || 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 || 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 || invoice.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
// Vérifier les critères de validation BAP
|
|
const score = invoice.qualityScore || 0;
|
|
const isNotSubscription = invoice.isSubscription === 0;
|
|
const hasService = !!invoice.serviceConcerne;
|
|
const hasTypeAchat = !!invoice.typeAchat;
|
|
const hasVentilation = !!invoice.ventilationComptable;
|
|
if (score < 100) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" });
|
|
}
|
|
if (!isNotSubscription) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" });
|
|
}
|
|
if (!hasService || !hasTypeAchat || !hasVentilation) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Les champs Service, Type d'achat et Ventilation doivent être remplis" });
|
|
}
|
|
|
|
// ── Génération du PDF annoté ──────────────────────────────────────
|
|
const fs = await import('fs/promises');
|
|
const path = await import('path');
|
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
|
const { storagePut } = await import('./storage');
|
|
|
|
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
|
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
|
const exportFolder = importSettings?.exportFolder || null;
|
|
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;
|
|
|
|
try {
|
|
if (!invoice.fileKey) throw new Error('Fichier PDF source introuvable');
|
|
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
|
|
const pdfBytes = await fs.readFile(sourcePath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
const pages = pdfDoc.getPages();
|
|
const lastPage = pages[pages.length - 1];
|
|
const { width, height } = lastPage.getSize();
|
|
|
|
// ── Zone blanche BAP (bas de page, hauteur 140pt) ────────────────
|
|
const zoneHeight = 140;
|
|
const zoneX = 30;
|
|
const zoneY = 10;
|
|
const zoneW = width - 60;
|
|
|
|
// Fond blanc
|
|
lastPage.drawRectangle({
|
|
x: zoneX,
|
|
y: zoneY,
|
|
width: zoneW,
|
|
height: zoneHeight,
|
|
color: rgb(1, 1, 1),
|
|
borderColor: rgb(0.7, 0.7, 0.7),
|
|
borderWidth: 0.5,
|
|
});
|
|
|
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
|
|
// Ligne 1 : CAPEX/OPEX | BAP | Destinataire
|
|
const typeAchatText = (invoice.typeAchat || 'N/A').toUpperCase();
|
|
const recipientRaw = (invoice as any).recipientName || '';
|
|
const destinataireText = recipientRaw ? recipientRaw : 'TOUS';
|
|
const line1 = `${typeAchatText} | BON À PAYER | ${destinataireText}`;
|
|
const line1Size = 11;
|
|
const line1W = font.widthOfTextAtSize(line1, line1Size);
|
|
lastPage.drawText(line1, {
|
|
x: zoneX + (zoneW - line1W) / 2,
|
|
y: zoneY + zoneHeight - 22,
|
|
size: line1Size,
|
|
font,
|
|
color: rgb(0.1, 0.1, 0.5),
|
|
});
|
|
|
|
// Séparateur
|
|
lastPage.drawLine({
|
|
start: { x: zoneX + 10, y: zoneY + zoneHeight - 30 },
|
|
end: { x: zoneX + zoneW - 10, y: zoneY + zoneHeight - 30 },
|
|
thickness: 0.5,
|
|
color: rgb(0.7, 0.7, 0.7),
|
|
});
|
|
|
|
// Ligne 2 : Service + Ventilation
|
|
const line2 = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
|
|
lastPage.drawText(line2, {
|
|
x: zoneX + 10,
|
|
y: zoneY + zoneHeight - 48,
|
|
size: 9,
|
|
font: fontNormal,
|
|
color: rgb(0.2, 0.2, 0.2),
|
|
});
|
|
|
|
// Ligne 3 : Date de validation
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
|
lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, {
|
|
x: zoneX + 10,
|
|
y: zoneY + zoneHeight - 64,
|
|
size: 8,
|
|
font: fontNormal,
|
|
color: rgb(0.4, 0.4, 0.4),
|
|
});
|
|
|
|
// ── Signature du service ──────────────────────────────────────────
|
|
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);
|
|
const sigImageBytes = await fs.readFile(sigImagePath);
|
|
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);
|
|
}
|
|
const sigWidth = 100;
|
|
const sigHeight = 45;
|
|
lastPage.drawImage(embeddedSig, {
|
|
x: zoneX + zoneW - sigWidth - 10,
|
|
y: zoneY + 20,
|
|
width: sigWidth,
|
|
height: sigHeight,
|
|
});
|
|
lastPage.drawText(signatureName, {
|
|
x: zoneX + zoneW - sigWidth - 10,
|
|
y: zoneY + 12,
|
|
size: 8,
|
|
font: fontNormal,
|
|
color: rgb(0.3, 0.3, 0.3),
|
|
});
|
|
} catch (_) { /* ignore signature errors */ }
|
|
}
|
|
}
|
|
|
|
const signedPdfBytes = await pdfDoc.save();
|
|
const filename = path.basename(invoice.fileKey);
|
|
const bapFilename = `BAP_${Date.now()}_${filename}`;
|
|
|
|
if (bapExportMode === 'folder' && exportFolder) {
|
|
// Mode dossier : enregistrer sur le disque
|
|
await fs.mkdir(exportFolder, { recursive: true });
|
|
exportPath = path.join(exportFolder, bapFilename);
|
|
await fs.writeFile(exportPath, signedPdfBytes);
|
|
} else {
|
|
// Mode navigateur : uploader sur S3 et retourner l'URL
|
|
const { storagePut: put } = await import('./storage');
|
|
const { url } = await put(`bap-exports/${bapFilename}`, 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();
|
|
await updateInvoice(input.id, {
|
|
bapValidated: 1,
|
|
bapValidatedAt: validatedAt,
|
|
});
|
|
|
|
// ── 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,
|
|
validatedAt,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
validatedAt,
|
|
pdfUrl,
|
|
exportPath,
|
|
exportMode: bapExportMode,
|
|
};
|
|
}),
|
|
|
|
// ── Historique BAP ────────────────────────────────────────────────────
|
|
search: protectedProcedure
|
|
.input(z.object({ query: z.string() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return searchInvoices(ctx.user.id, input.query);
|
|
}),
|
|
|
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
|
return getInvoiceStats(ctx.user.id);
|
|
}),
|
|
}),
|
|
|
|
// ============= BAP HISTORY ROUTES =============
|
|
bapHistory: router({
|
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
|
return getBapHistoryByUser(ctx.user.id);
|
|
}),
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const entries = 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 };
|
|
}),
|
|
}),
|
|
|
|
// ============= 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(),
|
|
}))
|
|
.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 && 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;
|
|
|
|
// 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 || 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 it doesn't exist
|
|
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}`
|
|
});
|
|
}
|
|
|
|
// Copy 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);
|
|
|
|
// Extract filename from fileKey
|
|
const filename = path.basename(invoice.fileKey);
|
|
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);
|
|
const pdfBytes = await fs.readFile(sourcePath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
const pages = pdfDoc.getPages();
|
|
const lastPage = pages[pages.length - 1];
|
|
const { width, height } = lastPage.getSize();
|
|
|
|
// Read signature image
|
|
const sigImageBytes = await fs.readFile(sigImagePath);
|
|
const sigImageBase64 = sigImageBytes.toString('base64');
|
|
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();
|
|
await fs.writeFile(destPath, signedPdfBytes);
|
|
copiedFiles.push(destPath);
|
|
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
|
|
} else {
|
|
// Signature not found, copy without signature
|
|
await fs.copyFile(sourcePath, destPath);
|
|
copiedFiles.push(destPath);
|
|
}
|
|
} catch (sigError: any) {
|
|
console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`);
|
|
await fs.copyFile(sourcePath, destPath);
|
|
copiedFiles.push(destPath);
|
|
}
|
|
} else {
|
|
// No signature association, copy as-is
|
|
await fs.copyFile(sourcePath, destPath);
|
|
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 || 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 }) => {
|
|
return getImportLogsByUser(ctx.user.id);
|
|
}),
|
|
|
|
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
|
await deleteAllImportLogs(ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= LLM LOGS ROUTES =============
|
|
llmLogs: router({
|
|
getBySourceFile: protectedProcedure
|
|
.input(z.object({ sourceFileId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return getLlmLogsBySourceFile(input.sourceFileId);
|
|
}),
|
|
|
|
getByInvoice: protectedProcedure
|
|
.input(z.object({ invoiceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return getLlmLogsByInvoice(input.invoiceId);
|
|
}),
|
|
}),
|
|
|
|
// ============= FOLDER IMPORT SERVICE ROUTES =============
|
|
folderImportService: router({
|
|
start: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const started = await startFolderImportService(ctx.user.id);
|
|
return { success: started };
|
|
}),
|
|
|
|
stop: protectedProcedure.mutation(async ({ ctx }) => {
|
|
stopFolderImportService(ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
status: protectedProcedure.query(async ({ ctx }) => {
|
|
const isRunning = isFolderImportServiceRunning(ctx.user.id);
|
|
return { isRunning };
|
|
}),
|
|
}),
|
|
|
|
// ============= EMAIL IMPORT SERVICE ROUTES =============
|
|
emailImportService: router({
|
|
start: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const started = await startEmailImportService(ctx.user.id);
|
|
return { success: started };
|
|
}),
|
|
|
|
stop: protectedProcedure.mutation(async ({ ctx }) => {
|
|
stopEmailImportService(ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
status: protectedProcedure.query(async ({ ctx }) => {
|
|
return { isRunning: isEmailImportServiceRunning(ctx.user.id) };
|
|
}),
|
|
|
|
checkNow: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const result = await triggerEmailCheck(ctx.user.id);
|
|
return result;
|
|
}),
|
|
}),
|
|
|
|
// ============= IMPORT SETTINGS ROUTES =============
|
|
importSettings: router({
|
|
get: protectedProcedure.query(async ({ ctx }) => {
|
|
const settings = await getImportSettingsByUser(ctx.user.id);
|
|
|
|
// Return default settings if none exist
|
|
if (!settings) {
|
|
return {
|
|
userId: ctx.user.id,
|
|
manualImportEnabled: 1,
|
|
autoImportEnabled: 0,
|
|
autoImportSourcePath: null,
|
|
autoImportFrequency: 60,
|
|
emailImportEnabled: 0,
|
|
emailImportAddress: null,
|
|
emailImportPassword: null,
|
|
emailImportHost: null,
|
|
emailImportPort: 993,
|
|
emailImportFrequency: 30,
|
|
exportFolder: null,
|
|
bapExportMode: "browser" as const,
|
|
};
|
|
}
|
|
|
|
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(),
|
|
exportFolder: z.string().nullable().optional(),
|
|
bapExportMode: z.enum(["browser", "folder"]).optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const settings = await upsertImportSettings({
|
|
userId: ctx.user.id,
|
|
...input,
|
|
});
|
|
|
|
return settings;
|
|
}),
|
|
}),
|
|
|
|
// ============= DEPARTMENT LIST ROUTES =============
|
|
departments: router({
|
|
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getDepartmentsByUser(ctx.user.id);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1).max(100),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Initialize default lists if this is the first department
|
|
const existing = await getDepartmentsByUser(ctx.user.id);
|
|
if (existing.length === 0) {
|
|
await initializeDefaultLists(ctx.user.id);
|
|
}
|
|
|
|
// Check if department already exists
|
|
const duplicate = existing.find(d => d.name.toLowerCase() === input.name.toLowerCase());
|
|
if (duplicate) {
|
|
throw new TRPCError({
|
|
code: "CONFLICT",
|
|
message: `Le service "${input.name}" existe déjà`
|
|
});
|
|
}
|
|
|
|
return await createDepartment({
|
|
userId: ctx.user.id,
|
|
name: input.name,
|
|
});
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await deleteDepartment(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= ACCOUNTING ALLOCATION LIST ROUTES =============
|
|
accountingAllocations: router({
|
|
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getAccountingAllocationsByUser(ctx.user.id);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1).max(100),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Initialize default lists if this is the first allocation
|
|
const existing = await getAccountingAllocationsByUser(ctx.user.id);
|
|
if (existing.length === 0) {
|
|
await initializeDefaultLists(ctx.user.id);
|
|
}
|
|
|
|
// Check if allocation already exists
|
|
const duplicate = existing.find(a => a.name.toLowerCase() === input.name.toLowerCase());
|
|
if (duplicate) {
|
|
throw new TRPCError({
|
|
code: "CONFLICT",
|
|
message: `La ventilation comptable "${input.name}" existe déjà`
|
|
});
|
|
}
|
|
|
|
return await createAccountingAllocation({
|
|
userId: ctx.user.id,
|
|
name: input.name,
|
|
});
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await deleteAccountingAllocation(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= AUTOMATION RULES ROUTES =============
|
|
automationRules: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getAutomationRulesByUser(ctx.user.id);
|
|
}),
|
|
|
|
getById: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
return await getAutomationRuleById(input.id);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
isActive: z.number().min(0).max(1).optional(),
|
|
priority: z.number().optional(),
|
|
conditions: z.string(), // JSON string
|
|
conditionsLogic: z.enum(["AND", "OR"]).optional(),
|
|
actions: z.string(), // JSON string
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const rule = await createAutomationRule({
|
|
userId: ctx.user.id,
|
|
...input,
|
|
});
|
|
|
|
// If rule is active, reapply to existing BAP invoices
|
|
if (rule.isActive === 1) {
|
|
const { applyAutomationRules } = await import("./automationEngine");
|
|
const allInvoices = await getInvoicesByUser(ctx.user.id);
|
|
const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0);
|
|
|
|
for (const invoice of bapInvoices) {
|
|
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
|
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
|
|
await updateInvoice(invoice.id, updates);
|
|
}
|
|
}
|
|
}
|
|
|
|
return rule;
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
name: z.string().min(1).optional(),
|
|
isActive: z.number().min(0).max(1).optional(),
|
|
priority: z.number().optional(),
|
|
conditions: z.string().optional(),
|
|
conditionsLogic: z.enum(["AND", "OR"]).optional(),
|
|
actions: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { id, ...data } = input;
|
|
const updated = await updateAutomationRule(id, data);
|
|
|
|
// If rule is active, reapply to existing BAP invoices
|
|
if (updated && updated.isActive === 1) {
|
|
const { applyAutomationRules } = await import("./automationEngine");
|
|
const allInvoices = await getInvoicesByUser(ctx.user.id);
|
|
const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0);
|
|
|
|
for (const invoice of bapInvoices) {
|
|
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
|
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
|
|
await updateInvoice(invoice.id, updates);
|
|
}
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await deleteAutomationRule(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
test: protectedProcedure
|
|
.input(z.object({
|
|
conditions: z.string(), // JSON string
|
|
conditionsLogic: z.enum(["AND", "OR"]),
|
|
actions: z.string(), // JSON string
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Import applyAutomationRules from automationEngine
|
|
const { applyAutomationRules } = await import("./automationEngine");
|
|
|
|
// Get all user's invoices
|
|
const invoices = await getInvoicesByUser(ctx.user.id);
|
|
|
|
// Create a temporary rule object
|
|
const tempRule = {
|
|
id: -1,
|
|
userId: ctx.user.id,
|
|
name: "Test Rule",
|
|
isActive: 1,
|
|
priority: 999,
|
|
conditions: input.conditions,
|
|
conditionsLogic: input.conditionsLogic,
|
|
actions: input.actions,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
// Test the rule on each invoice and count matches
|
|
let affectedCount = 0;
|
|
const affectedInvoices = [];
|
|
|
|
for (const invoice of invoices) {
|
|
// Manually evaluate the rule for testing
|
|
const conditions: Condition[] = JSON.parse(input.conditions);
|
|
const actionsObj: Actions = JSON.parse(input.actions);
|
|
|
|
// Evaluate conditions
|
|
let conditionsMatch = true;
|
|
if (input.conditionsLogic === "AND") {
|
|
conditionsMatch = conditions.every(cond => {
|
|
const fieldValue = invoice[cond.field as keyof typeof invoice];
|
|
if (!fieldValue) return false;
|
|
const valueStr = String(fieldValue).toLowerCase();
|
|
const condValue = cond.value.toLowerCase();
|
|
if (cond.operator === "contains") return valueStr.includes(condValue);
|
|
if (cond.operator === "equals") return valueStr === condValue;
|
|
if (cond.operator === "startsWith") return valueStr.startsWith(condValue);
|
|
if (cond.operator === "endsWith") return valueStr.endsWith(condValue);
|
|
if (cond.operator === ">") return Number(fieldValue) > Number(cond.value);
|
|
if (cond.operator === "<") return Number(fieldValue) < Number(cond.value);
|
|
if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value);
|
|
if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value);
|
|
return false;
|
|
});
|
|
} else {
|
|
conditionsMatch = conditions.some(cond => {
|
|
const fieldValue = invoice[cond.field as keyof typeof invoice];
|
|
if (!fieldValue) return false;
|
|
const valueStr = String(fieldValue).toLowerCase();
|
|
const condValue = cond.value.toLowerCase();
|
|
if (cond.operator === "contains") return valueStr.includes(condValue);
|
|
if (cond.operator === "equals") return valueStr === condValue;
|
|
if (cond.operator === "startsWith") return valueStr.startsWith(condValue);
|
|
if (cond.operator === "endsWith") return valueStr.endsWith(condValue);
|
|
if (cond.operator === ">") return Number(fieldValue) > Number(cond.value);
|
|
if (cond.operator === "<") return Number(fieldValue) < Number(cond.value);
|
|
if (cond.operator === ">=") return Number(fieldValue) >= Number(cond.value);
|
|
if (cond.operator === "<=") return Number(fieldValue) <= Number(cond.value);
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (conditionsMatch) {
|
|
affectedCount++;
|
|
affectedInvoices.push({
|
|
id: invoice.id,
|
|
supplierName: invoice.supplierName,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
changes: actionsObj,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalInvoices: invoices.length,
|
|
affectedCount,
|
|
affectedInvoices: affectedInvoices.slice(0, 10), // Return first 10 for preview
|
|
};
|
|
}),
|
|
|
|
duplicate: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const original = await getAutomationRuleById(input.id);
|
|
if (!original || original.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
|
|
// Create a copy with modified name
|
|
const duplicate = await createAutomationRule({
|
|
userId: ctx.user.id,
|
|
name: `${original.name} (copie)`,
|
|
isActive: 0, // Inactive by default
|
|
priority: original.priority,
|
|
conditions: original.conditions,
|
|
conditionsLogic: original.conditionsLogic,
|
|
actions: original.actions,
|
|
});
|
|
|
|
return duplicate;
|
|
}),
|
|
|
|
reapplyToExistingInvoices: protectedProcedure
|
|
.mutation(async ({ ctx }) => {
|
|
const { applyAutomationRules } = await import("./automationEngine");
|
|
|
|
// Get all BAP invoices (isSubscription = 0)
|
|
const allInvoices = await getInvoicesByUser(ctx.user.id);
|
|
const bapInvoices = allInvoices.filter(inv => inv.isSubscription === 0);
|
|
|
|
let updatedCount = 0;
|
|
|
|
// Apply automation rules to each BAP invoice
|
|
for (const invoice of bapInvoices) {
|
|
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
|
|
|
// Check if any updates were made
|
|
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
|
|
await updateInvoice(invoice.id, updates);
|
|
updatedCount++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalBapInvoices: bapInvoices.length,
|
|
updatedCount,
|
|
};
|
|
}),
|
|
}),
|
|
|
|
// ============= LLM FIELDS CONFIG ROUTES =============
|
|
llmFieldsConfig: router({
|
|
getAll: protectedProcedure
|
|
.query(async ({ ctx }) => {
|
|
// Initialize default fields if none exist
|
|
const existing = await getLlmFieldsConfigByUser(ctx.user.id);
|
|
if (existing.length === 0) {
|
|
await initializeDefaultLlmFields(ctx.user.id);
|
|
return await getLlmFieldsConfigByUser(ctx.user.id);
|
|
}
|
|
return existing;
|
|
}),
|
|
|
|
updateField: protectedProcedure
|
|
.input(z.object({
|
|
fieldName: z.string(),
|
|
isRequired: z.number().min(0).max(1),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Get existing field config
|
|
const existing = await getLlmFieldsConfigByUser(ctx.user.id);
|
|
const field = existing.find(f => f.fieldName === input.fieldName);
|
|
|
|
if (!field) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Field not found" });
|
|
}
|
|
|
|
// Update the field
|
|
return await upsertLlmFieldConfig({
|
|
userId: ctx.user.id,
|
|
fieldName: input.fieldName,
|
|
displayName: field.displayName,
|
|
isRequired: input.isRequired,
|
|
displayOrder: field.displayOrder,
|
|
});
|
|
}),
|
|
}),
|
|
|
|
// ============= SIGNATURES ROUTES =============
|
|
signatures: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getSignaturesByUser(ctx.user.id);
|
|
}),
|
|
|
|
upload: protectedProcedure
|
|
.input(z.object({
|
|
firstName: z.string().min(1).max(100),
|
|
lastName: z.string().min(1).max(100),
|
|
fileName: z.string().min(1),
|
|
fileData: z.string(), // Base64 encoded image
|
|
mimeType: z.string().default("image/png"),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const userId = ctx.user.id;
|
|
const fileBuffer = Buffer.from(input.fileData, "base64");
|
|
const safeFileName = `${input.firstName}-${input.lastName}-${Date.now()}-${input.fileName}`
|
|
.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
const imageKey = generateStorageKey(userId, safeFileName);
|
|
const result = await localStoragePut(imageKey, fileBuffer, input.mimeType);
|
|
return await createSignature({
|
|
userId,
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
imageKey,
|
|
imageUrl: result.url,
|
|
});
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
firstName: z.string().min(1).max(100),
|
|
lastName: z.string().min(1).max(100),
|
|
imageKey: z.string().min(1),
|
|
imageUrl: z.string().min(1),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
return await createSignature({
|
|
userId: ctx.user.id,
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
imageKey: input.imageKey,
|
|
imageUrl: input.imageUrl,
|
|
});
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const sig = await getSignatureById(input.id);
|
|
if (!sig || sig.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
await deleteSignature(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============= SERVICE SIGNATURES ROUTES =============
|
|
serviceSignatures: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return await getServiceSignaturesByUser(ctx.user.id);
|
|
}),
|
|
|
|
upsert: protectedProcedure
|
|
.input(z.object({
|
|
serviceName: z.string().min(1).max(100),
|
|
signatureId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await upsertServiceSignature({
|
|
userId: ctx.user.id,
|
|
serviceName: input.serviceName,
|
|
signatureId: input.signatureId,
|
|
});
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ serviceName: z.string() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await deleteServiceSignature(ctx.user.id, input.serviceName);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
export type AppRouter = typeof appRouter;
|