mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 04:06:44 +02:00
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:
1 parent
695cd8ad7c
commit
967f34ae7f
4 files changed
+474
-5
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user