Files
demat-facturation/server/db.ts
Manus 72c3f503a3 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.
2026-02-11 12:35:26 -05:00

609 lines
20 KiB
TypeScript

import { eq, and, desc, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
users,
sourceFiles,
InsertSourceFile,
SourceFile,
invoices,
InsertInvoice,
Invoice,
userSettings,
InsertUserSettings,
UserSettings,
importLogs,
InsertImportLog,
ImportLog,
llmLogs,
InsertLlmLog,
LlmLog,
importSettings,
InsertImportSettings,
ImportSettings,
departmentList,
InsertDepartment,
Department,
accountingAllocationList,
InsertAccountingAllocation,
AccountingAllocation,
automationRules,
InsertAutomationRule,
AutomationRule
} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
_db = drizzle(process.env.DATABASE_URL);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
// ============= USER OPERATIONS =============
export async function upsertUser(user: InsertUser): Promise<void> {
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
}
try {
const values: InsertUser = {
email: user.email,
loginMethod: user.loginMethod,
};
const updateSet: Record<string, unknown> = {};
if (user.openId !== undefined) {
values.openId = user.openId;
updateSet.openId = user.openId;
}
if (user.azureAdId !== undefined) {
values.azureAdId = user.azureAdId;
updateSet.azureAdId = user.azureAdId;
}
if (user.name !== undefined) {
values.name = user.name;
updateSet.name = user.name;
}
if (user.passwordHash !== undefined) {
values.passwordHash = user.passwordHash;
updateSet.passwordHash = user.passwordHash;
}
if (user.isActive !== undefined) {
values.isActive = user.isActive;
updateSet.isActive = user.isActive;
}
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) {
values.lastSignedIn = new Date();
}
if (Object.keys(updateSet).length === 0) {
updateSet.lastSignedIn = new Date();
}
await db.insert(users).values(values).onDuplicateKeyUpdate({
set: updateSet,
});
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserByEmail(email: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserByAzureAdId(azureAdId: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.azureAdId, azureAdId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createLocalUser(email: string, passwordHash: string, name: string, role: "user" | "admin" = "user") {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(users).values({
email,
passwordHash,
name,
loginMethod: "local",
role,
isActive: 1,
});
return getUserByEmail(email);
}
export async function getAllUsers() {
const db = await getDb();
if (!db) return [];
return db.select().from(users).orderBy(desc(users.createdAt));
}
export async function updateUserPassword(userId: number, newPasswordHash: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users).set({ passwordHash: newPasswordHash }).where(eq(users.id, userId));
}
export async function toggleUserActive(userId: number, isActive: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users).set({ isActive }).where(eq(users.id, userId));
}
export async function deleteUser(userId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(users).where(eq(users.id, userId));
}
// ============= SOURCE FILE OPERATIONS =============
export async function createSourceFile(data: InsertSourceFile): Promise<SourceFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(sourceFiles).values(data);
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(sourceFiles).where(eq(sourceFiles.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function getSourceFileById(id: number): Promise<SourceFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(sourceFiles).where(eq(sourceFiles.id, id)).limit(1);
return result[0];
}
export async function updateSourceFile(id: number, data: Partial<SourceFile>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(sourceFiles).set(data).where(eq(sourceFiles.id, id));
}
export async function getSourceFilesByUserId(userId: number): Promise<SourceFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(sourceFiles).where(eq(sourceFiles.userId, userId)).orderBy(desc(sourceFiles.createdAt));
}
// ============= INVOICE OPERATIONS =============
export async function createInvoice(data: InsertInvoice): Promise<Invoice> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(invoices).values(data);
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(invoices).where(eq(invoices.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function getInvoiceById(id: number): Promise<Invoice | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
return result[0];
}
export async function getInvoicesByUserId(userId: number): Promise<Invoice[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(invoices).where(eq(invoices.userId, userId)).orderBy(desc(invoices.createdAt));
}
export async function getInvoicesByUser(userId: number): Promise<Invoice[]> {
return getInvoicesByUserId(userId);
}
export async function updateInvoice(id: number, data: Partial<Invoice>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(invoices).set(data).where(eq(invoices.id, id));
}
export async function deleteInvoice(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(invoices).where(eq(invoices.id, id));
}
export async function searchInvoices(userId: number, query: string): Promise<Invoice[]> {
const db = await getDb();
if (!db) return [];
const searchPattern = `%${query}%`;
return db.select().from(invoices)
.where(
and(
eq(invoices.userId, userId),
sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`
)
)
.orderBy(desc(invoices.createdAt));
}
export async function getInvoiceStats(userId: number) {
const db = await getDb();
if (!db) return {
total: 0,
completed: 0,
processing: 0,
error: 0,
totalAmount: 0,
averageScore: 0,
lastActivity: null,
weeklyData: [],
topSuppliers: [],
suppliersList: []
};
const allInvoices = await getInvoicesByUserId(userId);
// Calculate basic stats
const completed = allInvoices.filter(i => i.status === "completed");
const totalAmount = completed.reduce((sum, inv) => sum + (Number(inv.totalAmount) || 0), 0);
const averageScore = completed.length > 0
? completed.reduce((sum, inv) => sum + (inv.qualityScore || 0), 0) / completed.length
: 0;
// Get last activity
const sortedByDate = [...allInvoices].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const lastActivity = sortedByDate[0]?.createdAt || null;
// Calculate weekly data (last 30 days)
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const weeklyMap = new Map<string, number>();
allInvoices.forEach(inv => {
const date = new Date(inv.createdAt);
if (date >= thirtyDaysAgo) {
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay()); // Start of week
const weekKey = `${weekStart.getDate().toString().padStart(2, '0')}/${(weekStart.getMonth() + 1).toString().padStart(2, '0')}`;
weeklyMap.set(weekKey, (weeklyMap.get(weekKey) || 0) + 1);
}
});
const weeklyData = Array.from(weeklyMap.entries())
.map(([week, count]) => ({ week, count }))
.sort((a, b) => {
const [dayA, monthA] = a.week.split('/').map(Number);
const [dayB, monthB] = b.week.split('/').map(Number);
return (monthA! * 100 + dayA!) - (monthB! * 100 + dayB!);
});
// Calculate top suppliers
const supplierMap = new Map<string, { count: number; amount: number }>();
completed.forEach(inv => {
const supplier = inv.supplierName || 'Inconnu';
const current = supplierMap.get(supplier) || { count: 0, amount: 0 };
supplierMap.set(supplier, {
count: current.count + 1,
amount: current.amount + (Number(inv.totalAmount) || 0)
});
});
const topSuppliers = Array.from(supplierMap.entries())
.map(([name, data]) => ({
name,
count: data.count,
amount: data.amount,
percentage: completed.length > 0 ? (data.count / completed.length * 100) : 0
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const suppliersList = Array.from(supplierMap.entries())
.map(([name, data]) => ({
name,
count: data.count,
amount: data.amount
}))
.sort((a, b) => b.count - a.count);
return {
total: allInvoices.length,
completed: completed.length,
processing: allInvoices.filter(i => i.status === "processing").length,
error: allInvoices.filter(i => i.status === "error").length,
totalAmount,
averageScore: Math.round(averageScore),
lastActivity,
weeklyData,
topSuppliers,
suppliersList
};
}
export async function findDuplicateInvoice(
supplierName: string | null,
invoiceNumber: string | null,
invoiceDate: Date | null
): Promise<Invoice | undefined> {
if (!supplierName || !invoiceNumber || !invoiceDate) return undefined;
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(invoices)
.where(
and(
eq(invoices.supplierName, supplierName),
eq(invoices.invoiceNumber, invoiceNumber),
eq(invoices.invoiceDate, invoiceDate)
)
)
.limit(1);
return result[0];
}
// ============= USER SETTINGS OPERATIONS =============
export async function getUserSettings(userId: number): Promise<UserSettings | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(userSettings).where(eq(userSettings.userId, userId)).limit(1);
return result[0];
}
export async function upsertUserSettings(data: InsertUserSettings) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existing = await getUserSettings(data.userId);
if (existing) {
await db.update(userSettings).set(data).where(eq(userSettings.userId, data.userId));
} else {
await db.insert(userSettings).values(data);
}
}
// ============= IMPORT LOG OPERATIONS =============
export async function createImportLog(data: InsertImportLog): Promise<ImportLog> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(importLogs).values(data);
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(importLogs).where(eq(importLogs.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function getImportLogsByUser(userId: number): Promise<ImportLog[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(importLogs).where(eq(importLogs.userId, userId)).orderBy(desc(importLogs.importedAt));
}
export async function deleteAllImportLogs(userId: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(importLogs).where(eq(importLogs.userId, userId));
}
// ============= LLM LOG OPERATIONS =============
export async function createLlmLog(data: InsertLlmLog): Promise<LlmLog> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(llmLogs).values(data);
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(llmLogs).where(eq(llmLogs.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function getLlmLogsBySourceFile(sourceFileId: number): Promise<LlmLog[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(llmLogs).where(eq(llmLogs.sourceFileId, sourceFileId)).orderBy(desc(llmLogs.createdAt));
}
export async function getLlmLogsByInvoice(invoiceId: number): Promise<LlmLog[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(llmLogs).where(eq(llmLogs.invoiceId, invoiceId)).orderBy(desc(llmLogs.createdAt));
}
// ============= IMPORT SETTINGS OPERATIONS =============
export async function getImportSettingsByUser(userId: number): Promise<ImportSettings | null> {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(importSettings).where(eq(importSettings.userId, userId)).limit(1);
return result[0] || null;
}
export async function upsertImportSettings(data: InsertImportSettings): Promise<ImportSettings> {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Check if settings exist for this user
const existing = await getImportSettingsByUser(data.userId!);
if (existing) {
// Update existing settings
await db.update(importSettings)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(importSettings.userId, data.userId!));
const updated = await getImportSettingsByUser(data.userId!);
return updated!;
} else {
// Insert new settings
const result = await db.insert(importSettings).values(data);
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(importSettings).where(eq(importSettings.id, insertedId)).limit(1);
return inserted[0]!;
}
}
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(userId: number): Promise<Department[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name);
}
export async function createDepartment(data: InsertDepartment): Promise<Department> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [department] = await db.insert(departmentList).values(data).$returningId();
return await db.select().from(departmentList).where(eq(departmentList.id, department.id)).then(rows => rows[0]);
}
export async function deleteDepartment(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(departmentList).where(eq(departmentList.id, id));
}
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name);
}
export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [allocation] = await db.insert(accountingAllocationList).values(data).$returningId();
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.id, allocation.id)).then(rows => rows[0]);
}
export async function deleteAccountingAllocation(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
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> {
const db = await getDb();
if (!db) return;
// Initialize default departments
const defaultDepartments = ["DSI", "TRAVAUX", "AUTRE"];
const existingDepts = await db.select().from(departmentList).where(eq(departmentList.userId, userId));
const existingDeptNames = new Set(existingDepts.map(d => d.name));
for (const name of defaultDepartments) {
if (!existingDeptNames.has(name)) {
try {
await db.insert(departmentList).values({ userId, name });
} catch (error) {
// Ignore errors
}
}
}
// Initialize default accounting allocations
const defaultAllocations = ["TOUS", "PA", "HEP", "SANITAIRE", "AUTRE"];
const existingAllocs = await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId));
const existingAllocNames = new Set(existingAllocs.map(a => a.name));
for (const name of defaultAllocations) {
if (!existingAllocNames.has(name)) {
try {
await db.insert(accountingAllocationList).values({ userId, name });
} catch (error) {
// Ignore errors
}
}
}
}