Nouvelle fonctionnalité majeure : ✅ Page "Automatismes" complète pour gérer les règles de remplissage automatique ✅ Système de règles conditionnelles SI...ALORS pour automatiser le remplissage des champs métier ✅ Support de conditions multiples avec logique AND/OR ✅ Application automatique des règles lors de l'import de factures ✅ Gestion de la priorité des règles (ordre d'exécution) ✅ Activation/désactivation des règles individuelles Architecture technique : - drizzle/schema.ts : Table automationRules avec conditions JSON et actions JSON - server/db.ts : Fonctions CRUD complètes pour les règles (create, update, delete, list) - server/routers.ts : Routes tRPC pour gérer les automatismes - server/automationEngine.ts : Moteur d'évaluation et d'application des règles - client/src/pages/AutomationRules.tsx : Interface complète de gestion des règles - Migration 0009_regular_warhawk.sql appliquée avec succès Fonctionnement des règles : 1. **Conditions (SI)** : Définissez des critères basés sur les champs de la facture - Champs disponibles : Fournisseur, N° Facture, Montant, Date, N° Commande, N° Bon de livraison - Opérateurs : contient, égal à, commence par, finit par, >, <, >=, <= - Logique : AND (toutes les conditions) ou OR (au moins une condition) 2. **Actions (ALORS)** : Définissez les champs à remplir automatiquement - Type d'achat (CAPEX/OPEX) - Service concerné (depuis la liste des services) - Ventilation comptable (depuis la liste des ventilations) 3. **Priorité** : Les règles sont appliquées dans l'ordre de priorité - Une règle de priorité supérieure ne peut pas être écrasée par une règle de priorité inférieure 4. **Application automatique** : Les règles s'appliquent automatiquement lors de l'import de factures Exemples d'utilisation : - SI Fournisseur contient "Microsoft" ET Montant > 1000 ALORS Type d'achat = CAPEX, Service = DSI - SI Fournisseur contient "EDF" ALORS Type d'achat = OPEX, Ventilation = TOUS - SI N° Facture commence par "AB" ALORS Service = Administration Interface utilisateur : - Liste des règles avec nom, conditions, actions et statut - Formulaire de création/édition avec interface intuitive - Boutons d'activation/désactivation rapide - Suppression avec confirmation - Affichage lisible des conditions et actions Cette fonctionnalité permet de gagner un temps considérable en automatisant le remplissage des champs métier selon des règles métier prédéfinies.
835 lines
28 KiB
TypeScript
835 lines
28 KiB
TypeScript
import { z } from "zod";
|
|
import { COOKIE_NAME } from "@shared/const";
|
|
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,
|
|
getAutomationRulesByUser,
|
|
getAutomationRuleById,
|
|
createAutomationRule,
|
|
updateAutomationRule,
|
|
deleteAutomationRule,
|
|
} 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 (email + password)
|
|
loginLocal: publicProcedure
|
|
.input(z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(6),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const result = await loginLocal(input.email, input.password);
|
|
|
|
if (!result) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
|
|
}
|
|
|
|
// Set auth cookie
|
|
ctx.res.cookie("auth_token", result.token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
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,
|
|
} : 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(),
|
|
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);
|
|
await updateSourceFile(sourceFile.id, {
|
|
processingStatus: "error",
|
|
processingProgress: `Erreur: ${error.message}`,
|
|
});
|
|
}
|
|
})();
|
|
|
|
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(),
|
|
serviceConcerne: z.string().optional(),
|
|
typeAchat: z.enum(["CAPEX", "OPEX"]).optional(),
|
|
ventilationComptable: z.string().optional(),
|
|
isSubscription: z.number().min(0).max(1).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 };
|
|
}),
|
|
|
|
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);
|
|
}),
|
|
}),
|
|
|
|
// ============= 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(),
|
|
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().email(),
|
|
password: z.string().min(6),
|
|
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 }) => {
|
|
// 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\u00e9 de 100% pour \u00eatre export\u00e9es"
|
|
});
|
|
}
|
|
|
|
// Update export status
|
|
for (const invoice of invoices) {
|
|
if (invoice) {
|
|
await updateInvoice(invoice.id, {
|
|
exportStatus: "exported",
|
|
exportedAt: new Date(),
|
|
exportMode: "manual",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Return the file URLs for PDF generation on client side
|
|
return {
|
|
success: true,
|
|
invoices: invoices.filter(Boolean).map(inv => ({
|
|
id: inv!.id,
|
|
fileUrl: inv!.fileUrl,
|
|
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" });
|
|
}
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (const invoiceId of input.invoiceIds) {
|
|
try {
|
|
const invoice = await getInvoiceById(invoiceId);
|
|
if (!invoice || invoice.userId !== ctx.user.id) {
|
|
errorCount++;
|
|
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 };
|
|
}),
|
|
}),
|
|
|
|
// ============= 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,
|
|
};
|
|
}
|
|
|
|
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(),
|
|
}))
|
|
.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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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,
|
|
});
|
|
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 }) => {
|
|
const { id, ...data } = input;
|
|
const updated = await updateAutomationRule(id, data);
|
|
return updated;
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await deleteAutomationRule(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|