Merge pull request #78 from R0m1k3/1.1

Add NocoDB invoice verification to reconcile deliveries automatically
This commit is contained in:
LogiFlow authored and GitHub committed 2025-08-13 12:57:06 +02:00
commit 70635946b2
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,