mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
5 files changed
+248
-80
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user