Checkpoint: Système de gestion des automatismes pour remplissage automatique des factures

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.
This commit is contained in:
Manus
2026-02-11 12:35:26 -05:00
parent 6e04cbe798
commit 72c3f503a3
11 changed files with 2050 additions and 5 deletions

137
server/automationEngine.ts Normal file
View File

@@ -0,0 +1,137 @@
import { getAutomationRulesByUser } from "./db";
import { Invoice } from "../drizzle/schema";
interface Condition {
field: string;
operator: string;
value: string;
}
interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
}
/**
* Evaluate a single condition against an invoice
*/
function evaluateCondition(invoice: Invoice, condition: Condition): boolean {
const fieldValue = invoice[condition.field as keyof Invoice];
if (fieldValue === null || fieldValue === undefined) {
return false;
}
const valueStr = String(fieldValue).toLowerCase();
const conditionValue = condition.value.toLowerCase();
switch (condition.operator) {
case "contains":
return valueStr.includes(conditionValue);
case "equals":
return valueStr === conditionValue;
case "startsWith":
return valueStr.startsWith(conditionValue);
case "endsWith":
return valueStr.endsWith(conditionValue);
case ">":
return Number(fieldValue) > Number(condition.value);
case "<":
return Number(fieldValue) < Number(condition.value);
case ">=":
return Number(fieldValue) >= Number(condition.value);
case "<=":
return Number(fieldValue) <= Number(condition.value);
default:
return false;
}
}
/**
* Evaluate all conditions for a rule based on the logic (AND/OR)
*/
function evaluateConditions(
invoice: Invoice,
conditions: Condition[],
logic: "AND" | "OR"
): boolean {
if (conditions.length === 0) {
return false;
}
if (logic === "AND") {
return conditions.every((condition) => evaluateCondition(invoice, condition));
} else {
return conditions.some((condition) => evaluateCondition(invoice, condition));
}
}
/**
* Apply automation rules to an invoice and return the fields to update
* Returns an object with the fields that should be updated based on matching rules
*/
export async function applyAutomationRules(
userId: number,
invoice: Invoice
): Promise<Partial<Invoice>> {
const rules = await getAutomationRulesByUser(userId);
const updates: Partial<Invoice> = {};
// Process rules in priority order (already sorted by priority in getAutomationRulesByUser)
for (const rule of rules) {
// Skip inactive rules
if (rule.isActive !== 1) {
continue;
}
try {
const conditions: Condition[] = JSON.parse(rule.conditions);
const actions: Actions = JSON.parse(rule.actions);
// Evaluate conditions
const conditionsMatch = evaluateConditions(
invoice,
conditions,
rule.conditionsLogic
);
if (conditionsMatch) {
// Apply actions (only if the field is not already set by a higher priority rule)
if (actions.typeAchat && !updates.typeAchat) {
updates.typeAchat = actions.typeAchat as "CAPEX" | "OPEX";
}
if (actions.serviceConcerne && !updates.serviceConcerne) {
updates.serviceConcerne = actions.serviceConcerne;
}
if (actions.ventilationComptable && !updates.ventilationComptable) {
updates.ventilationComptable = actions.ventilationComptable;
}
}
} catch (error) {
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);
// Continue with next rule on error
}
}
return updates;
}
/**
* Apply automation rules to multiple invoices
*/
export async function applyAutomationRulesToBatch(
userId: number,
invoices: Invoice[]
): Promise<Map<number, Partial<Invoice>>> {
const results = new Map<number, Partial<Invoice>>();
for (const invoice of invoices) {
const updates = await applyAutomationRules(userId, invoice);
if (Object.keys(updates).length > 0) {
results.set(invoice.id, updates);
}
}
return results;
}

View File

@@ -26,7 +26,10 @@ import {
Department,
accountingAllocationList,
InsertAccountingAllocation,
AccountingAllocation
AccountingAllocation,
automationRules,
InsertAutomationRule,
AutomationRule
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -529,6 +532,44 @@ export async function deleteAccountingAllocation(id: number): Promise<void> {
await db.delete(accountingAllocationList).where(eq(accountingAllocationList.id, id));
}
// ============= AUTOMATION RULES OPERATIONS =============
export async function getAutomationRulesByUser(userId: number): Promise<AutomationRule[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(automationRules).where(eq(automationRules.userId, userId)).orderBy(automationRules.priority);
}
export async function getAutomationRuleById(id: number): Promise<AutomationRule | null> {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(automationRules).where(eq(automationRules.id, id)).limit(1);
return result[0] || null;
}
export async function createAutomationRule(data: InsertAutomationRule): Promise<AutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(automationRules).values(data);
const insertedId = result[0].insertId;
const newRule = await getAutomationRuleById(insertedId);
return newRule!;
}
export async function updateAutomationRule(id: number, data: Partial<InsertAutomationRule>): Promise<AutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(automationRules).set(data).where(eq(automationRules.id, id));
const updated = await getAutomationRuleById(id);
return updated!;
}
export async function deleteAutomationRule(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(automationRules).where(eq(automationRules.id, id));
}
// ============= INITIALIZE DEFAULT VALUES =============
export async function initializeDefaultLists(userId: number): Promise<void> {

View File

@@ -36,6 +36,11 @@ import {
createAccountingAllocation,
deleteAccountingAllocation,
initializeDefaultLists,
getAutomationRulesByUser,
getAutomationRuleById,
createAutomationRule,
updateAutomationRule,
deleteAutomationRule,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
@@ -220,7 +225,7 @@ export const appRouter = router({
);
// Create invoice record
await createInvoice({
const newInvoice = await createInvoice({
userId,
sourceFileId: sourceFile.id,
invoiceIndexInFile: i + 1,
@@ -242,6 +247,20 @@ export const appRouter = router({
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++;
@@ -753,6 +772,63 @@ export const appRouter = router({
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;