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
This commit is contained in:
michaelschal committed 2025-08-13 10:18:51 +00:00
1 parent 695cd8ad7c
commit 967f34ae7f
4 files changed
+474 -5

No files matched your search

+122 -4
View File
@@ -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<string>("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// État pour le système de vérification de facture
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [] } = useQuery<any[]>({
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() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`text-sm ${delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{delivery.invoiceReference || (
<span className="text-gray-400 italic">Non renseigné</span>
<div className="flex items-center space-x-2">
<div className={`text-sm ${delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}`}>
{delivery.invoiceReference || (
<span className="text-gray-400 italic">Non renseigné</span>
)}
</div>
{/* Icône de vérification de facture */}
{delivery.group?.webhookUrl && (
<div className="flex items-center">
{verifyingDeliveries.has(delivery.id) ? (
<Clock className="h-4 w-4 text-blue-500 animate-spin" title="Vérification en cours..." />
) : verificationResults[delivery.id] ? (
verificationResults[delivery.id].exists ? (
<CheckCircle
className="h-4 w-4 text-green-500 cursor-help"
title={`Facture vérifiée (${verificationResults[delivery.id].matchType === 'invoice_ref' ? 'référence directe' : 'via BL'})`}
/>
) : (
<XCircle
className="h-4 w-4 text-red-500 cursor-help"
title={`Facture non trouvée: ${verificationResults[delivery.id].errorMessage || 'Aucune correspondance'}`}
/>
)
) : (
<button
onClick={() => handleVerifyInvoice(delivery)}
className="h-4 w-4 text-gray-400 hover:text-blue-500 transition-colors"
title="Cliquer pour vérifier la facture"
>
<Search className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
</td>
+277
View File
@@ -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<any> {
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<InvoiceVerificationResult | null> {
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<void> {
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<InvoiceVerificationResult> {
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();
+51
View File
@@ -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<Server> {
// Health check endpoint for Docker
@@ -831,6 +832,56 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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);
+24 -1
View File
@@ -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,