Merge pull request #487 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-03 18:12:26 +01:00
commit a449cc1db7
5 files changed
+248 -80

No files matched your search

+72
View File
@@ -98,6 +98,61 @@ export default function BLReconciliation() {
[variables.deliveryId]: result
}));
// Toast de confirmation avec détails de la vérification
if (result.exists) {
// Facture trouvée - afficher les détails
// Formater la date d'échéance si présente
let dueDateText = '';
if (result.dueDate) {
try {
const date = new Date(result.dueDate);
dueDateText = date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
} catch (e) {
dueDateText = result.dueDate; // Afficher tel quel si le format n'est pas reconnu
}
}
// Formater le montant avec garde contre NaN
const amountText = result.invoiceAmount ?
(() => {
const amount = parseFloat(result.invoiceAmount);
return isNaN(amount) ? 'Format invalide' : `${amount.toFixed(2)}€`;
})() :
'Non disponible';
toast({
title: "✅ Facture vérifiée avec succès",
description: (
<div className="space-y-1 text-sm">
<div><strong>Référence :</strong> {result.invoiceReference || 'Non disponible'}</div>
<div><strong>Montant :</strong> {amountText}</div>
{result.dueDate ? (
<div className="text-green-600 font-medium">
<strong>📅 Échéance :</strong> {dueDateText}
</div>
) : (
<div className="text-orange-600 font-medium">
⚠️ Aucune date d'échéance trouvée
</div>
)}
</div>
),
duration: 5000,
});
} else {
// Facture non trouvée - afficher message d'erreur
toast({
title: "⚠️ Facture non trouvée",
description: result.errorMessage || 'La facture n\'a pas été trouvée dans la base de données',
variant: "destructive",
duration: 5000,
});
}
// Auto-remplissage si facture trouvée via BL
if (result.exists && result.matchType === 'bl_number' && result.invoiceReference) {
// Auto-remplir les champs dans la livraison via API
@@ -789,6 +844,9 @@ export default function BLReconciliation() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Montant Fact.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Échéance
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Écart
</th>
@@ -907,6 +965,17 @@ export default function BLReconciliation() {
}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm">
{delivery.dueDate ? (
<span className={delivery.reconciled !== true ? 'font-medium text-gray-900' : 'text-gray-600'}>
{safeFormat(delivery.dueDate, 'dd/MM/yyyy')}
</span>
) : (
<span className="text-gray-400 italic text-xs">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{(() => {
const blAmount = delivery.blAmount ? parseFloat(delivery.blAmount) : 0;
@@ -1133,6 +1202,9 @@ export default function BLReconciliation() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Montant Fact.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Échéance
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Écart
</th>
+1 -1
View File
@@ -40,7 +40,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu
- **Reporting:** Statistical reporting, including monthly summaries of deliveries.
- **UI Harmonization:** All detail modals across modules (orders, deliveries, calendar) use a unified component for consistent information display.
- **Analyse Vente (Sales Analysis):** A dedicated module for admins and directors that displays a configurable external URL/IP in an iframe. Configuration is managed via a dedicated tab in the Utilities page, allowing administrators to set or update the sales analysis dashboard URL. The menu is visible only to admin and directeur roles, ensuring proper access control. Includes intelligent error handling with informative messages when the URL is not configured. Features a refresh button to force complete iframe reloads.
- **Échéancier des Paiements (Payment Schedule):** A comprehensive payment deadline tracking system integrated with NocoDB. Displays monthly aggregations of supplier payment schedules with breakdown by payment method (Virement, Traite, Traite Magnétique, Chèque). Features include: role-based access (admin/director), group-based filtering with selectedStoreId, Zod validation for security, robust supplier name normalization (case/accent/whitespace handling), monthly statistics with payment method breakdowns, and configurable NocoDB column mapping for due dates. Requires configuration of nocodbDueDateColumnName in Groups settings and paymentMethod in Suppliers. **Security (November 2025)**: Implements strict authorization - directors can only access their assigned groups, preventing cross-group data access.
- **Échéancier des Paiements (Payment Schedule):** A comprehensive payment deadline tracking system with hybrid architecture for optimal performance. **Architecture Refactorization (November 2025):** Due dates are now stored locally in deliveries table (dueDate column) as the single source of truth, retrieved once during invoice verification from NocoDB and cached locally for fast queries. Features include: role-based access (admin/director), group-based filtering with selectedStoreId, intelligent fallback to NocoDB for historical deliveries without cached dates, automatic date normalization handling multiple formats (ISO, French DD/MM/YYYY, American MM/DD/YYYY), monthly aggregations with payment method breakdowns (Virement, Traite, Traite Magnétique, Chèque), Zod validation for security, and robust supplier name normalization. Requires configuration of nocodbDueDateColumnName in Groups settings and paymentMethod in Suppliers. **Data Flow:** Invoice verification → NocoDB (fetch dueDate) → deliveries (local storage) → payment-schedule (fast local reads with NocoDB fallback). **Security (November 2025)**: Implements strict authorization - directors can only access their assigned groups, preventing cross-group data access.
## External Dependencies
- **API Météo:** Visual Crossing API
+8 -3
View File
@@ -242,6 +242,7 @@ export class InvoiceVerificationService {
invoiceReference?: string;
invoiceAmount?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
}> {
try {
@@ -396,7 +397,8 @@ export class InvoiceVerificationService {
matchType: 'invoice_reference' as const,
invoiceReference: matchResult.data.invoice_reference || invoiceReference,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu'
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu',
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
@@ -432,7 +434,8 @@ export class InvoiceVerificationService {
matchType: 'bl_number' as const,
invoiceReference: matchResult.data.invoice_reference || `BL_${invoiceReference}`,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu'
supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu',
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
@@ -599,6 +602,7 @@ export class InvoiceVerificationService {
invoiceReference?: string;
invoiceAmount?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
}> {
try {
@@ -795,7 +799,8 @@ export class InvoiceVerificationService {
matchType: 'bl_number' as const,
invoiceReference: matchResult.data[group.invoiceColumnName || 'RefFacture'] || `BL_${blNumber}`,
invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'),
supplierName: foundSupplier || supplierName
supplierName: foundSupplier || supplierName,
dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null
};
// Sauvegarder en cache si succès
+166 -76
View File
@@ -7,6 +7,79 @@ import { db, pool } from "./db";
console.log('🔍 Using development storage and authentication');
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
function normalizeDateString(dateString: string | null | undefined): string | null {
if (!dateString || typeof dateString !== 'string') return null;
const trimmed = dateString.trim();
if (!trimmed) return null;
try {
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (slashMatch) {
const [, first, second, year] = slashMatch;
const firstNum = parseInt(first);
const secondNum = parseInt(second);
// Validation basique des valeurs
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
return null;
}
// Déterminer le format en fonction des valeurs
let day: string, month: string;
if (firstNum > 12) {
// first > 12 → forcément DD/MM (format français/européen)
day = first.padStart(2, '0');
month = second.padStart(2, '0');
} else if (secondNum > 12) {
// second > 12 → forcément MM/DD (format américain)
day = second.padStart(2, '0');
month = first.padStart(2, '0');
} else {
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
day = first.padStart(2, '0');
month = second.padStart(2, '0');
}
// Validation finale : mois entre 1-12, jour entre 1-31
const monthNum = parseInt(month);
const dayNum = parseInt(day);
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
return null;
}
return `${year}-${month}-${day}`;
}
// Essayer de parser avec Date (format ISO complet avec heures)
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
return null;
} catch (error) {
console.error('❌ Erreur normalisation date:', error);
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
}
}
// Simple hash password function using crypto
async function hashPasswordSimple(password: string) {
const crypto = await import('crypto');
@@ -309,93 +382,76 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
// Récupérer le groupe avec la configuration NocoDB
// Récupérer le groupe
const group = await storage.getGroup(groupId);
if (!group || !group.nocodbConfigId || !group.nocodbTableId) {
return res.json({ schedules: [], message: 'Pas de configuration NocoDB pour ce groupe' });
if (!group) {
return res.json({ schedules: [], message: 'Groupe non trouvé' });
}
// Récupérer la configuration NocoDB
const nocodbConfig = await storage.getNocodbConfig(group.nocodbConfigId);
if (!nocodbConfig) {
return res.json({ schedules: [], message: 'Configuration NocoDB non trouvée' });
}
console.log('📅 Récupération échéances depuis deliveries:', { groupId, groupName: group.name });
// Vérifier que les colonnes nécessaires sont configurées
if (!group.nocodbDueDateColumnName || !group.invoiceColumnName || !group.nocodbAmountColumnName) {
return res.json({
schedules: [],
message: 'Configuration incomplète: colonnes date échéance, facture ou montant manquantes'
});
}
// Récupérer toutes les livraisons du groupe
const allDeliveries = await storage.getDeliveries();
const groupDeliveries = allDeliveries.filter((d: any) => d.groupId === groupId && d.invoiceReference);
// Récupérer tous les enregistrements de NocoDB
const url = `${nocodbConfig.baseUrl}/api/v1/db/data/noco/${nocodbConfig.projectId}/${group.nocodbTableId}`;
// Séparer les livraisons avec et sans dueDate
const deliveriesWithDueDate = groupDeliveries.filter((d: any) => d.dueDate);
const deliveriesWithoutDueDate = groupDeliveries.filter((d: any) => !d.dueDate);
console.log(`📅 Livraisons avec échéance: ${deliveriesWithDueDate.length}, sans échéance: ${deliveriesWithoutDueDate.length}`);
// FALLBACK : Pour les livraisons sans dueDate, interroger NocoDB
const { InvoiceVerificationService } = await import('./invoiceVerification.js');
const verificationService = new InvoiceVerificationService();
console.log('📅 Récupération échéances NocoDB:', {
url,
groupId,
dueDateColumn: group.nocodbDueDateColumnName
});
const response = await fetch(url, {
method: "GET",
headers: {
"xc-token": nocodbConfig.apiToken,
"Content-Type": "application/json",
},
});
if (!response.ok) {
console.error('❌ Erreur NocoDB:', response.status, response.statusText);
return res.status(500).json({ error: 'Erreur lors de la récupération des données NocoDB' });
for (const delivery of deliveriesWithoutDueDate) {
try {
const result = await verificationService.verifyInvoice(
delivery.invoiceReference!,
delivery.groupId,
false, // Ne pas forcer le refresh, utiliser cache si disponible
delivery.reconciled || false
);
if (result.exists && result.dueDate) {
// Normaliser la date avant de la stocker
const normalizedDate = normalizeDateString(result.dueDate);
if (normalizedDate) {
// Mettre à jour deliveries avec la date normalisée (caching)
await storage.updateDelivery(delivery.id, { dueDate: normalizedDate });
delivery.dueDate = normalizedDate;
console.log(`📅 Fallback: échéance récupérée et normalisée pour livraison #${delivery.id}`);
}
}
} catch (error) {
console.error(`❌ Fallback échéance échoué pour livraison #${delivery.id}:`, error);
}
}
const data = await response.json();
// Récupérer tous les fournisseurs pour le mapping
// Combiner toutes les livraisons qui ont maintenant une dueDate
const allDeliveriesWithDueDate = [...deliveriesWithDueDate, ...deliveriesWithoutDueDate.filter((d: any) => d.dueDate)];
// Récupérer tous les fournisseurs pour le mapping du mode de paiement
const allSuppliers = await storage.getSuppliers();
// Fonction de normalisation pour comparer les noms de fournisseurs
const normalizeName = (name: string) => {
return name
.toLowerCase()
.trim()
.normalize("NFD") // Décompose les accents
.replace(/[\u0300-\u036f]/g, "") // Supprime les diacritiques
.replace(/\s+/g, ' '); // Normalise les espaces multiples
};
// Créer une map avec noms normalisés
const supplierMap = new Map(allSuppliers.map(s => [normalizeName(s.name), s]));
const supplierMap = new Map(allSuppliers.map((s: any) => [s.id, s]));
// Traiter les données et filtrer les enregistrements avec une date d'échéance
const schedules = (data.list || [])
.map((record: any) => {
const dueDate = record[group.nocodbDueDateColumnName!];
const invoiceRef = record[group.invoiceColumnName!];
const amount = record[group.nocodbAmountColumnName!];
const supplierName = record[group.nocodbSupplierColumnName || 'Fournisseur'];
if (!dueDate) return null;
// Chercher le fournisseur correspondant avec normalisation
const normalizedSupplierName = normalizeName(supplierName || '');
const supplier = supplierMap.get(normalizedSupplierName);
return {
id: record.Id || record.id,
invoiceReference: invoiceRef,
dueDate: dueDate,
amount: amount ? parseFloat(amount) : 0,
supplierName: supplierName,
paymentMethod: supplier?.paymentMethod || null,
groupId: group.id,
groupName: group.name
};
})
.filter((s: any) => s !== null);
// Formatter les échéances
const schedules = allDeliveriesWithDueDate.map((delivery: any) => {
const supplier = supplierMap.get(delivery.supplierId);
return {
id: delivery.id,
invoiceReference: delivery.invoiceReference,
dueDate: delivery.dueDate,
amount: delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0,
supplierName: supplier?.name || 'Fournisseur inconnu',
paymentMethod: supplier?.paymentMethod || null,
groupId: group.id,
groupName: group.name
};
});
console.log(`📅 Total échéances retournées: ${schedules.length}`);
res.json({ schedules });
} catch (error: any) {
@@ -1374,6 +1430,40 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
// GESTION ÉCHÉANCE : Si la référence facture change, mettre à jour la date d'échéance
if (data.invoiceReference !== undefined && data.invoiceReference !== delivery.invoiceReference) {
if (data.invoiceReference && data.invoiceReference.trim()) {
// Nouvelle référence facture : reverifier dans NocoDB pour récupérer l'échéance
try {
const { InvoiceVerificationService } = await import('./invoiceVerification.js');
const verificationService = new InvoiceVerificationService();
const result = await verificationService.verifyInvoice(
data.invoiceReference,
delivery.groupId,
true, // forceRefresh
delivery.reconciled || false
);
if (result.exists && result.dueDate) {
// Normaliser la date avant de la stocker
data.dueDate = normalizeDateString(result.dueDate);
console.log(`📅 Date d'échéance récupérée et normalisée: ${data.dueDate} (original: ${result.dueDate})`);
} else {
data.dueDate = null;
console.log(`📅 Aucune date d'échéance trouvée dans NocoDB`);
}
} catch (error) {
console.error('❌ Erreur récupération échéance:', error);
// Ne pas bloquer la mise à jour, juste ne pas avoir d'échéance
data.dueDate = null;
}
} else {
// Référence facture vidée : vider aussi l'échéance
data.dueDate = null;
console.log(`📅 Référence facture vidée, échéance également vidée`);
}
}
const updatedDelivery = await storage.updateDelivery(id, data);
console.log('✅ Delivery updated successfully:', { id, updatedDelivery });
+1
View File
@@ -119,6 +119,7 @@ export const deliveries = pgTable("deliveries", {
blAmount: decimal("bl_amount", { precision: 10, scale: 2 }), // Montant BL
invoiceReference: varchar("invoice_reference"), // Référence facture
invoiceAmount: decimal("invoice_amount", { precision: 10, scale: 2 }), // Montant facture
dueDate: date("due_date"), // Date d'échéance de paiement (récupérée depuis NocoDB)
reconciled: boolean("reconciled").default(false), // Rapprochement effectué
validatedAt: timestamp("validated_at"), // Date de validation de la livraison
// Champs pour le contrôle des livraisons (fournisseurs nécessitant un contrôle)