mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Merge pull request #78 from R0m1k3/1.1
Add NocoDB invoice verification to reconcile deliveries automatically
This commit is contained in:
4 files changed
+474
-5
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user