From 967f34ae7f1f31ff4bef7060f8669e6dce8c5762 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Wed, 13 Aug 2025 10:18:51 +0000 Subject: [PATCH] Add NocoDB invoice verification to reconcile deliveries automatically Integrate NocoDB for automated invoice verification against delivery records, supporting both invoice reference and Bill of Lading number matching, with caching and improved UI feedback. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 7c468936-2a88-4686-ac0a-84921a45222d Replit-Commit-Checkpoint-Type: full_checkpoint --- client/src/pages/BLReconciliation.tsx | 126 +++++++++++- server/invoiceVerification.ts | 277 ++++++++++++++++++++++++++ server/routes.ts | 51 +++++ shared/schema.ts | 25 ++- 4 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 server/invoiceVerification.ts diff --git a/client/src/pages/BLReconciliation.tsx b/client/src/pages/BLReconciliation.tsx index 4931724..43fc194 100644 --- a/client/src/pages/BLReconciliation.tsx +++ b/client/src/pages/BLReconciliation.tsx @@ -12,7 +12,7 @@ import { useStore } from "@/components/Layout"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { usePermissions } from "@shared/permissions"; import { Pagination, usePagination } from "@/components/ui/pagination"; -import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload } from "lucide-react"; +import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; @@ -57,12 +57,99 @@ export default function BLReconciliation() { const [invoiceType, setInvoiceType] = useState(""); const [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); + + // État pour le système de vérification de facture + const [verificationResults, setVerificationResults] = useState>({}); + const [verifyingDeliveries, setVerifyingDeliveries] = useState>(new Set()); // Récupérer les fournisseurs pour la logique automatique const { data: suppliers = [] } = useQuery({ queryKey: ['/api/suppliers'], }); + // Fonction de vérification de facture + const verifyInvoiceMutation = useMutation({ + mutationFn: async ({ deliveryId, invoiceReference, blNumber }: { deliveryId: number; invoiceReference?: string; blNumber?: string }) => { + const response = await apiRequest(`/api/deliveries/${deliveryId}/verify-invoice`, { + method: 'POST', + body: JSON.stringify({ invoiceReference, blNumber }), + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Erreur de vérification'); + } + + return response.json(); + }, + onSuccess: (result, variables) => { + setVerificationResults(prev => ({ + ...prev, + [variables.deliveryId]: result + })); + + // Auto-remplissage si facture trouvée via BL + if (result.exists && result.matchType === 'bl_number' && result.invoiceReference) { + // Auto-remplir les champs dans la livraison + updateDeliveryMutation.mutate({ + id: variables.deliveryId, + invoiceReference: result.invoiceReference, + invoiceAmount: result.invoiceAmount + }); + } + + setVerifyingDeliveries(prev => { + const newSet = new Set(prev); + newSet.delete(variables.deliveryId); + return newSet; + }); + }, + onError: (error, variables) => { + console.error('Erreur vérification facture:', error); + setVerificationResults(prev => ({ + ...prev, + [variables.deliveryId]: { + exists: false, + matchType: 'none', + errorMessage: error instanceof Error ? error.message : 'Erreur inconnue' + } + })); + + setVerifyingDeliveries(prev => { + const newSet = new Set(prev); + newSet.delete(variables.deliveryId); + return newSet; + }); + + toast({ + title: "Erreur de vérification", + description: error instanceof Error ? error.message : 'Erreur inconnue', + variant: "destructive", + }); + } + }); + + // Fonction pour déclencher la vérification + const handleVerifyInvoice = (delivery: any) => { + if (!delivery.group?.webhookUrl) { + toast({ + title: "Vérification non disponible", + description: "Ce magasin n'a pas de configuration NocoDB", + variant: "destructive", + }); + return; + } + + setVerifyingDeliveries(prev => new Set(prev).add(delivery.id)); + + verifyInvoiceMutation.mutate({ + deliveryId: delivery.id, + invoiceReference: delivery.invoiceReference, + blNumber: delivery.blNumber + }); + }; + // Récupérer les livraisons validées avec BL const { data: deliveriesWithBL = [], isLoading } = useQuery({ queryKey: ['/api/deliveries/bl', selectedStoreId], @@ -536,9 +623,40 @@ export default function BLReconciliation() { -
- {delivery.invoiceReference || ( - Non renseigné +
+
+ {delivery.invoiceReference || ( + Non renseigné + )} +
+ + {/* Icône de vérification de facture */} + {delivery.group?.webhookUrl && ( +
+ {verifyingDeliveries.has(delivery.id) ? ( + + ) : verificationResults[delivery.id] ? ( + verificationResults[delivery.id].exists ? ( + + ) : ( + + ) + ) : ( + + )} +
)}
diff --git a/server/invoiceVerification.ts b/server/invoiceVerification.ts new file mode 100644 index 0000000..99a3ab9 --- /dev/null +++ b/server/invoiceVerification.ts @@ -0,0 +1,277 @@ +import { db } from './db.js'; +import { nocodbConfig, invoiceVerificationCache, groups } from '@shared/schema'; +import { eq, and, desc } from 'drizzle-orm'; + +interface InvoiceVerificationResult { + exists: boolean; + matchType: 'invoice_ref' | 'bl_number' | 'none'; + invoiceReference?: string; + invoiceAmount?: string; + supplierMatch: boolean; + errorMessage?: string; + apiCallTime?: number; + cacheHit: boolean; +} + +interface NocodbInvoice { + Id: number; + [key: string]: any; // Pour les colonnes dynamiques +} + +class InvoiceVerificationService { + private async getNocodbConfig(): Promise { + const [config] = await db + .select() + .from(nocodbConfig) + .where(eq(nocodbConfig.isActive, true)) + .limit(1); + + if (!config) { + throw new Error('Aucune configuration NocoDB active trouvée'); + } + + return config; + } + + private generateCacheKey(groupId: number, invoiceRef: string, supplierName: string): string { + return `${groupId}:${invoiceRef}:${supplierName}`.toLowerCase(); + } + + private async getCachedResult(cacheKey: string): Promise { + try { + const [cached] = await db + .select() + .from(invoiceVerificationCache) + .where(and( + eq(invoiceVerificationCache.cacheKey, cacheKey), + // Cache valide pour 24 heures + eq(invoiceVerificationCache.expiresAt, new Date(Date.now() + 24 * 60 * 60 * 1000)) + )) + .limit(1); + + if (cached && new Date() < new Date(cached.expiresAt)) { + console.log('🎯 Cache hit pour:', cacheKey); + return { + exists: cached.exists, + matchType: cached.matchType as any, + invoiceReference: cached.invoiceReference, + supplierMatch: !!cached.supplierName, + cacheHit: true, + apiCallTime: cached.apiCallTime || 0 + }; + } + } catch (error) { + console.error('❌ Erreur lors de la lecture du cache:', error); + } + + return null; + } + + private async setCachedResult( + cacheKey: string, + groupId: number, + result: InvoiceVerificationResult, + invoiceRef: string, + supplierName: string + ): Promise { + try { + await db.insert(invoiceVerificationCache).values({ + cacheKey, + groupId, + invoiceReference: invoiceRef, + supplierName, + exists: result.exists, + matchType: result.matchType, + errorMessage: result.errorMessage, + cacheHit: false, + apiCallTime: result.apiCallTime || 0, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h + }); + + console.log('💾 Résultat mis en cache:', cacheKey); + } catch (error) { + console.error('❌ Erreur lors de la mise en cache:', error); + } + } + + private async searchInNocodb( + config: any, + group: any, + searchValue: string, + searchType: 'invoice' | 'bl' + ): Promise<{ found: boolean; invoice?: any }> { + const startTime = Date.now(); + + try { + // Définir la colonne de recherche selon le groupe + const searchColumn = searchType === 'invoice' + ? group.invoiceColumnName + : group.nocodbBlColumnName; + + if (!searchColumn) { + throw new Error(`Colonne ${searchType} non configurée pour ce magasin`); + } + + const url = `${config.baseUrl}/api/v2/tables/${group.nocodbTableName}/records`; + const params = new URLSearchParams({ + where: `(${searchColumn},eq,${searchValue})` + }); + + const response = await fetch(`${url}?${params}`, { + headers: { + 'xc-token': config.apiToken, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`NocoDB API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const invoices = data.list || []; + + console.log(`🔍 Recherche NocoDB ${searchType}:`, { + searchValue, + found: invoices.length > 0, + table: group.nocodbTableName, + column: searchColumn, + apiTime: Date.now() - startTime + }); + + return { + found: invoices.length > 0, + invoice: invoices[0] || null + }; + } catch (error) { + console.error(`❌ Erreur recherche NocoDB ${searchType}:`, error); + throw error; + } + } + + async verifyInvoiceReference( + invoiceRef: string, + blNumber: string, + supplierName: string, + groupId: number + ): Promise { + const startTime = Date.now(); + + try { + // 1. Vérifier le cache d'abord + const cacheKey = this.generateCacheKey(groupId, invoiceRef, supplierName); + const cachedResult = await this.getCachedResult(cacheKey); + if (cachedResult) { + return cachedResult; + } + + // 2. Récupérer la configuration NocoDB + const config = await this.getNocodbConfig(); + + // 3. Récupérer les informations du groupe + const [group] = await db + .select() + .from(groups) + .where(eq(groups.id, groupId)) + .limit(1); + + if (!group || !group.nocodbTableName) { + throw new Error('Configuration NocoDB manquante pour ce magasin'); + } + + let result: InvoiceVerificationResult = { + exists: false, + matchType: 'none', + supplierMatch: false, + cacheHit: false, + apiCallTime: 0 + }; + + // 4. Recherche primaire : par référence facture + if (invoiceRef && invoiceRef.trim()) { + const searchResult = await this.searchInNocodb(config, group, invoiceRef, 'invoice'); + + if (searchResult.found) { + // Vérifier la correspondance du fournisseur + const supplierColumn = group.nocodbSupplierColumnName; + const invoiceSupplier = supplierColumn ? searchResult.invoice[supplierColumn] : ''; + const supplierMatch = !supplierColumn || + invoiceSupplier.toLowerCase().includes(supplierName.toLowerCase()) || + supplierName.toLowerCase().includes(invoiceSupplier.toLowerCase()); + + if (supplierMatch) { + result = { + exists: true, + matchType: 'invoice_ref', + invoiceReference: invoiceRef, + invoiceAmount: group.nocodbAmountColumnName ? + searchResult.invoice[group.nocodbAmountColumnName] : undefined, + supplierMatch: true, + cacheHit: false, + apiCallTime: Date.now() - startTime + }; + } else { + result.errorMessage = 'Référence trouvée mais fournisseur ne correspond pas'; + } + } + } + + // 5. Recherche secondaire : par numéro de BL si pas de résultat avec la facture + if (!result.exists && blNumber && blNumber.trim()) { + const searchResult = await this.searchInNocodb(config, group, blNumber, 'bl'); + + if (searchResult.found) { + // Vérifier la correspondance du fournisseur + const supplierColumn = group.nocodbSupplierColumnName; + const invoiceSupplier = supplierColumn ? searchResult.invoice[supplierColumn] : ''; + const supplierMatch = !supplierColumn || + invoiceSupplier.toLowerCase().includes(supplierName.toLowerCase()) || + supplierName.toLowerCase().includes(invoiceSupplier.toLowerCase()); + + if (supplierMatch) { + const foundInvoiceRef = group.invoiceColumnName ? + searchResult.invoice[group.invoiceColumnName] : ''; + const foundAmount = group.nocodbAmountColumnName ? + searchResult.invoice[group.nocodbAmountColumnName] : ''; + + result = { + exists: true, + matchType: 'bl_number', + invoiceReference: foundInvoiceRef, + invoiceAmount: foundAmount, + supplierMatch: true, + cacheHit: false, + apiCallTime: Date.now() - startTime + }; + } else { + result.errorMessage = 'BL trouvé mais fournisseur ne correspond pas'; + } + } + } + + // Si rien trouvé + if (!result.exists) { + result.apiCallTime = Date.now() - startTime; + result.errorMessage = result.errorMessage || 'Aucune correspondance trouvée'; + } + + // 6. Mettre en cache le résultat + await this.setCachedResult(cacheKey, groupId, result, invoiceRef, supplierName); + + return result; + + } catch (error) { + console.error('❌ Erreur vérification facture:', error); + return { + exists: false, + matchType: 'none', + supplierMatch: false, + cacheHit: false, + errorMessage: error instanceof Error ? error.message : 'Erreur inconnue', + apiCallTime: Date.now() - startTime + }; + } + } +} + +export const invoiceVerificationService = new InvoiceVerificationService(); \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 537fb0f..004fc76 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -37,6 +37,7 @@ import { } from "@shared/schema"; import { hasPermission } from "@shared/permissions"; import { z } from "zod"; +import { invoiceVerificationService } from "./invoiceVerification.js"; export async function registerRoutes(app: Express): Promise { // Health check endpoint for Docker @@ -831,6 +832,56 @@ export async function registerRoutes(app: Express): Promise { } }); + // Route de vérification de facture NocoDB + app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => { + try { + const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const deliveryId = parseInt(req.params.id); + const delivery = await storage.getDelivery(deliveryId); + + if (!delivery) { + return res.status(404).json({ message: "Delivery not found" }); + } + + // Check permissions + if (!hasPermission(user.role, 'deliveries', 'view')) { + return res.status(403).json({ message: "Insufficient permissions" }); + } + + if (user.role !== 'admin') { + const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; + if (!userGroupIds.includes(delivery.groupId)) { + return res.status(403).json({ message: "Access denied to this group" }); + } + } + + const { invoiceReference, blNumber } = req.body; + + if (!delivery.supplier || !delivery.group) { + return res.status(400).json({ message: "Delivery missing supplier or group information" }); + } + + const result = await invoiceVerificationService.verifyInvoiceReference( + invoiceReference || delivery.invoiceReference || '', + blNumber || delivery.blNumber || '', + delivery.supplier.name, + delivery.groupId + ); + + res.json(result); + } catch (error) { + console.error("Error verifying invoice:", error); + res.status(500).json({ + message: "Failed to verify invoice", + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + app.post('/api/deliveries/:id/validate', isAuthenticated, async (req: any, res) => { try { const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); diff --git a/shared/schema.ts b/shared/schema.ts index 6848689..7f49911 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -152,7 +152,24 @@ export const nocodbConfig = pgTable("nocodb_config", { apiToken: varchar("api_token").notNull(), // Personal API Token description: text("description"), // Description de la configuration isActive: boolean("is_active").default(true), // Configuration active ou non - createdBy: varchar("created_by").notNull(), + createdBy: varchar("created_by"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +// Cache de vérification des factures pour éviter les re-vérifications +export const invoiceVerificationCache = pgTable("invoice_verification_cache", { + id: serial("id").primaryKey(), + cacheKey: varchar("cache_key", { length: 255 }).notNull(), // Clé unique de cache + groupId: integer("group_id").notNull(), // ID du magasin + invoiceReference: varchar("invoice_reference", { length: 255 }).notNull(), // Référence facture + supplierName: varchar("supplier_name", { length: 255 }), // Nom du fournisseur + exists: boolean("exists").notNull(), // Facture trouvée ou non + matchType: varchar("match_type", { length: 50 }).notNull(), // Type de correspondance (invoice_ref, bl_number) + errorMessage: text("error_message"), // Message d'erreur si applicable + cacheHit: boolean("cache_hit").default(false), // Cache hit ou non + apiCallTime: integer("api_call_time"), // Temps d'appel API en ms + expiresAt: timestamp("expires_at").notNull(), // Date d'expiration du cache createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), }); @@ -444,6 +461,12 @@ export const insertNocodbConfigSchema = createInsertSchema(nocodbConfig).omit({ updatedAt: true, }); +export const insertInvoiceVerificationCacheSchema = createInsertSchema(invoiceVerificationCache).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + export const insertCustomerOrderSchema = createInsertSchema(customerOrders).omit({ id: true, createdAt: true,