Переглянути джерело

feat: ajout de la date de facture lors de la saisie depuis le scanner

- Détection automatique de la date depuis le contenu PDF (formats français)
- Nouveau champ 'Date de facture' dans le formulaire d'édition du scanner
- La date détectée est utilisée comme date de la charge à l'approbation
- Fallback sur année-01-01 si aucune date n'est saisie
- Migration DB : colonne detected_date sur scanned_invoices

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
jeremy 4 місяців тому
батько
коміт
9ab524397d

+ 1 - 0
backend/src/db.js

@@ -186,6 +186,7 @@ async function initDb() {
   await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS irl_reference_year INTEGER`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable INTEGER NOT NULL DEFAULT 1`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable_type TEXT DEFAULT 'recoverable'`);
+  await pool.query(`ALTER TABLE scanned_invoices ADD COLUMN IF NOT EXISTS detected_date TEXT`);
   // Backfill recoverable_type from legacy integer column
   await pool.query(`UPDATE charges SET recoverable_type = 'none' WHERE recoverable = 0 AND (recoverable_type IS NULL OR recoverable_type = 'recoverable')`);
   await pool.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS charge_regularization REAL DEFAULT 0`);

+ 4 - 4
backend/src/routes/scan.js

@@ -71,7 +71,7 @@ router.get('/invoices/:id/preview', async (req, res) => {
 
 // PUT /api/scan/invoices/:id - Update detected fields before approval
 router.put('/invoices/:id', async (req, res) => {
-  const { detected_supplier, detected_category, detected_amount, detected_label, year, notes } = req.body;
+  const { detected_supplier, detected_category, detected_amount, detected_label, year, notes, detected_date } = req.body;
   const inv = (await pool.query(
     'SELECT id FROM scanned_invoices WHERE id = $1 AND user_id = $2 AND status = $3',
     [req.params.id, req.userId, 'pending']
@@ -79,8 +79,8 @@ router.put('/invoices/:id', async (req, res) => {
   if (!inv) return res.status(404).json({ error: 'Facture non trouvée ou déjà traitée' });
 
   await pool.query(
-    `UPDATE scanned_invoices SET detected_supplier=$1, detected_category=$2, detected_amount=$3, detected_label=$4, year=$5, notes=$6 WHERE id=$7`,
-    [detected_supplier, detected_category, detected_amount, detected_label, year, notes || null, req.params.id]
+    `UPDATE scanned_invoices SET detected_supplier=$1, detected_category=$2, detected_amount=$3, detected_label=$4, year=$5, notes=$6, detected_date=$7 WHERE id=$8`,
+    [detected_supplier, detected_category, detected_amount, detected_label, year, notes || null, detected_date || null, req.params.id]
   );
   res.json({ success: true });
 });
@@ -99,7 +99,7 @@ router.post('/invoices/:id/approve', async (req, res) => {
 
   // Create charge entry - link to original file in scan directory (no copy)
   const recoverableType = req.body.recoverable_type || 'recoverable';
-  const chargeDate = inv.year ? `${inv.year}-01-01` : new Date().toISOString().slice(0, 10);
+  const chargeDate = inv.detected_date || (inv.year ? `${inv.year}-01-01` : new Date().toISOString().slice(0, 10));
   const chargeResult = await pool.query(
     `INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable, recoverable_type)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,

+ 57 - 1
backend/src/services/invoiceDetector.js

@@ -82,6 +82,59 @@ function detectCategory(text) {
   return 'autre';
 }
 
+// Date patterns for French invoices (dd/mm/yyyy, dd-mm-yyyy, dd.mm.yyyy, "15 janvier 2024", etc.)
+const DATE_PATTERNS = [
+  // "Date de facture : 15/03/2024" or "Date : 15/03/2024"
+  /(?:date\s*(?:de\s*)?(?:facture|facturation|émission|[eé]mission))\s*[:\s]*(\d{1,2})[/.\-](\d{1,2})[/.\-](20\d{2})/i,
+  /(?:date)\s*[:\s]*(\d{1,2})[/.\-](\d{1,2})[/.\-](20\d{2})/i,
+  // "Facturé le 15/03/2024" or "Émise le 15/03/2024"
+  /(?:factur[eé]e?\s*le|[eé]mise?\s*le|le)\s*(\d{1,2})[/.\-](\d{1,2})[/.\-](20\d{2})/i,
+  // French month names: "15 mars 2024", "1er janvier 2024"
+  /(\d{1,2})(?:er)?\s+(janvier|f[eé]vrier|mars|avril|mai|juin|juillet|ao[uû]t|septembre|octobre|novembre|d[eé]cembre)\s+(20\d{2})/i,
+  // Standalone dd/mm/yyyy near top of document (fallback)
+  /(\d{1,2})[/.\-](\d{1,2})[/.\-](20\d{2})/,
+];
+
+const FRENCH_MONTHS = {
+  'janvier': '01', 'février': '02', 'fevrier': '02',
+  'mars': '03', 'avril': '04', 'mai': '05', 'juin': '06',
+  'juillet': '07', 'août': '08', 'aout': '08',
+  'septembre': '09', 'octobre': '10', 'novembre': '11',
+  'décembre': '12', 'decembre': '12',
+};
+
+function detectDate(text) {
+  for (const pattern of DATE_PATTERNS) {
+    const match = text.match(pattern);
+    if (!match) continue;
+
+    let day, month, year;
+
+    // Check if it's a French month name pattern
+    const monthName = match[2]?.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
+    const monthLookup = Object.entries(FRENCH_MONTHS).find(([k]) =>
+      k.normalize('NFD').replace(/[\u0300-\u036f]/g, '') === monthName
+    );
+
+    if (monthLookup) {
+      day = match[1].padStart(2, '0');
+      month = monthLookup[1];
+      year = match[3];
+    } else {
+      day = match[1].padStart(2, '0');
+      month = match[2].padStart(2, '0');
+      year = match[3];
+    }
+
+    // Validate
+    const d = parseInt(day), m = parseInt(month), y = parseInt(year);
+    if (d < 1 || d > 31 || m < 1 || m > 12 || y < 2000 || y > 2099) continue;
+
+    return `${year}-${month}-${day}`;
+  }
+  return null;
+}
+
 function detectAmount(text) {
   for (const pattern of AMOUNT_PATTERNS) {
     const match = text.match(pattern);
@@ -119,6 +172,7 @@ async function analyzePdf(filePath) {
   const detectedAmount = detectAmount(text);
   const detectedCategory = supplierInfo?.category || detectCategory(text);
   const detectedSupplier = supplierInfo?.name || null;
+  const detectedDate = detectDate(text);
 
   // Try to build a label
   let label = '';
@@ -135,6 +189,7 @@ async function analyzePdf(filePath) {
     detected_category: detectedCategory,
     detected_amount: detectedAmount,
     detected_label: label,
+    detected_date: detectedDate,
   };
 }
 
@@ -156,7 +211,8 @@ function analyzeFromFilename(filePath) {
     detected_category: detectedCategory,
     detected_amount: detectedAmount,
     detected_label: label,
+    detected_date: null,
   };
 }
 
-module.exports = { analyzeFile, detectSupplier, detectCategory, detectAmount, SUPPLIERS };
+module.exports = { analyzeFile, detectSupplier, detectCategory, detectAmount, detectDate, SUPPLIERS };

+ 4 - 3
backend/src/services/scanner.js

@@ -84,11 +84,12 @@ async function scanProperty(propertyId, userId) {
       const analysis = await analyzeFile(filePath);
 
       await pool.query(
-        `INSERT INTO scanned_invoices (user_id, property_id, file_path, file_hash, year, detected_supplier, detected_category, detected_amount, detected_label, status)
-         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`,
+        `INSERT INTO scanned_invoices (user_id, property_id, file_path, file_hash, year, detected_supplier, detected_category, detected_amount, detected_label, detected_date, status)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending')`,
         [userId, propertyId, filePath, fileHash, year,
          analysis.detected_supplier, analysis.detected_category,
-         analysis.detected_amount, analysis.detected_label]
+         analysis.detected_amount, analysis.detected_label,
+         analysis.detected_date]
       );
 
       existingHashes.add(fileHash);

+ 5 - 0
frontend/src/pages/InvoiceScanner.jsx

@@ -112,6 +112,7 @@ export default function InvoiceScanner() {
       year: inv.year || new Date().getFullYear(),
       notes: inv.notes || '',
       recoverable_type: 'recoverable',
+      detected_date: inv.detected_date || '',
     })
     setEditInvoice(inv)
     // Load preview for edit modal
@@ -483,6 +484,10 @@ export default function InvoiceScanner() {
                   <label className="label">Année</label>
                   <input className="input" type="number" min="2000" max="2099" value={editForm.year} onChange={e => setEditForm(f => ({ ...f, year: parseInt(e.target.value) || '' }))} />
                 </div>
+                <div>
+                  <label className="label">Date de facture</label>
+                  <input className="input" type="date" value={editForm.detected_date} onChange={e => setEditForm(f => ({ ...f, detected_date: e.target.value }))} />
+                </div>
                 <div>
                   <label className="label">Type de charge</label>
                   <select className="input" value={editForm.recoverable_type} onChange={e => setEditForm(f => ({ ...f, recoverable_type: e.target.value }))}>