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,