invoiceDetector.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. const pdfParse = require('pdf-parse');
  2. const fs = require('fs');
  3. // Known suppliers with their aliases and associated charge categories
  4. const SUPPLIERS = [
  5. { name: 'EDF', patterns: [/\bedf\b/i, /\bélectricité de france\b/i], category: 'electricite' },
  6. { name: 'Engie', patterns: [/\bengie\b/i, /\bgdf\s*suez\b/i], category: 'gaz' },
  7. { name: 'TotalEnergies', patterns: [/\btotal\s*energies?\b/i, /\btotal\s*direct\s*energie\b/i], category: 'electricite' },
  8. { name: 'Eni', patterns: [/\beni\s/i, /\beni\.com\b/i], category: 'gaz' },
  9. { name: 'Veolia', patterns: [/\bveolia\b/i], category: 'eau' },
  10. { name: 'Suez', patterns: [/\bsuez\b/i, /\blyonnaise des eaux\b/i], category: 'eau' },
  11. { name: 'Saur', patterns: [/\bsaur\b/i], category: 'eau' },
  12. { name: 'Eau de Paris', patterns: [/\beau de paris\b/i], category: 'eau' },
  13. { name: 'GRDF', patterns: [/\bgrdf\b/i], category: 'gaz' },
  14. { name: 'Enedis', patterns: [/\benedis\b/i, /\berdf\b/i], category: 'electricite' },
  15. { name: 'Orange', patterns: [/\borange\b/i, /\bfrance\s*t[eé]l[eé]com\b/i], category: 'autre' },
  16. { name: 'SFR', patterns: [/\bsfr\b/i], category: 'autre' },
  17. { name: 'Free', patterns: [/\bfree\s*mobile\b/i, /\bfree\.fr\b/i, /\biliad\b/i], category: 'autre' },
  18. { name: 'Bouygues Telecom', patterns: [/\bbouygues\s*tel/i], category: 'autre' },
  19. { name: 'Axa', patterns: [/\baxa\b/i], category: 'assurance' },
  20. { name: 'Maif', patterns: [/\bmaif\b/i], category: 'assurance' },
  21. { name: 'MACIF', patterns: [/\bmacif\b/i], category: 'assurance' },
  22. { name: 'Groupama', patterns: [/\bgroupama\b/i], category: 'assurance' },
  23. { name: 'Allianz', patterns: [/\ballianz\b/i], category: 'assurance' },
  24. { name: 'MMA', patterns: [/\bmma\b/i], category: 'assurance' },
  25. { name: 'MAAF', patterns: [/\bmaaf\b/i], category: 'assurance' },
  26. { name: 'Generali', patterns: [/\bgenerali\b/i], category: 'assurance' },
  27. { name: 'Matmut', patterns: [/\bmatmut\b/i], category: 'assurance' },
  28. { name: 'Otis', patterns: [/\botis\b/i], category: 'ascenseur' },
  29. { name: 'Schindler', patterns: [/\bschindler\b/i], category: 'ascenseur' },
  30. { name: 'Kone', patterns: [/\bkone\b/i], category: 'ascenseur' },
  31. { name: 'ThyssenKrupp', patterns: [/\bthyssen\s*krupp\b/i], category: 'ascenseur' },
  32. ];
  33. // Category keywords for fallback detection
  34. const CATEGORY_KEYWORDS = {
  35. eau: [/\beau\b/i, /\bassainissement\b/i, /\bconsommation\s*d'eau\b/i],
  36. gaz: [/\bgaz\b/i, /\bgaz\s*naturel\b/i],
  37. electricite: [/\b[eé]lectricit[eé]\b/i, /\bconsommation\s*[eé]lectrique\b/i, /\bkwh\b/i],
  38. entretien: [/\bentretien\b/i, /\bmaintenance\b/i, /\bnettoyage\b/i, /\bravalement\b/i, /\br[eé]paration\b/i],
  39. ascenseur: [/\bascenseur\b/i, /\b[eé]l[eé]vateur\b/i],
  40. ordures: [/\bordures\b/i, /\bd[eé]chets\b/i, /\bom\b/i, /\bteom\b/i, /\btaxe.*enl[eè]vement\b/i],
  41. assurance: [/\bassurance\b/i, /\bprime\b/i, /\bcotisation\b/i, /\bsinistre\b/i, /\bgarantie\b/i],
  42. };
  43. // Amount patterns (French format: 1 234,56 € or 1234.56)
  44. const AMOUNT_PATTERNS = [
  45. /(?:total\s*(?:ttc|à\s*payer|net|facture|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
  46. /(?:montant\s*(?:ttc|total|à\s*payer|net|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
  47. /(?:net\s*à\s*payer)\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
  48. /(?:total\s*(?:ttc|à\s*payer|net|facture|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
  49. /(?:montant\s*(?:ttc|total|à\s*payer|net|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
  50. /(?:net\s*à\s*payer)\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
  51. /(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)\s*(?:ttc)/i,
  52. /(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
  53. ];
  54. function parseAmount(raw) {
  55. if (!raw) return null;
  56. const cleaned = raw.replace(/\s/g, '').replace(',', '.');
  57. const val = parseFloat(cleaned);
  58. return isNaN(val) ? null : Math.round(val * 100) / 100;
  59. }
  60. function detectSupplier(text) {
  61. for (const supplier of SUPPLIERS) {
  62. for (const pattern of supplier.patterns) {
  63. if (pattern.test(text)) {
  64. return { name: supplier.name, category: supplier.category };
  65. }
  66. }
  67. }
  68. return null;
  69. }
  70. function detectCategory(text) {
  71. for (const [category, patterns] of Object.entries(CATEGORY_KEYWORDS)) {
  72. for (const pattern of patterns) {
  73. if (pattern.test(text)) return category;
  74. }
  75. }
  76. return 'autre';
  77. }
  78. function detectAmount(text) {
  79. for (const pattern of AMOUNT_PATTERNS) {
  80. const match = text.match(pattern);
  81. if (match) {
  82. const amount = parseAmount(match[1]);
  83. if (amount && amount > 0 && amount < 100000) return amount;
  84. }
  85. }
  86. return null;
  87. }
  88. async function analyzeFile(filePath) {
  89. const ext = filePath.toLowerCase().split('.').pop();
  90. if (ext === 'pdf') {
  91. return analyzePdf(filePath);
  92. }
  93. // For images (jpg, png, webp) - extract info from filename only
  94. return analyzeFromFilename(filePath);
  95. }
  96. async function analyzePdf(filePath) {
  97. const buffer = fs.readFileSync(filePath);
  98. let text = '';
  99. try {
  100. const data = await pdfParse(buffer);
  101. text = data.text || '';
  102. } catch (err) {
  103. console.warn(`Could not parse PDF ${filePath}:`, err.message);
  104. return analyzeFromFilename(filePath);
  105. }
  106. const supplierInfo = detectSupplier(text);
  107. const detectedAmount = detectAmount(text);
  108. const detectedCategory = supplierInfo?.category || detectCategory(text);
  109. const detectedSupplier = supplierInfo?.name || null;
  110. // Try to build a label
  111. let label = '';
  112. if (detectedSupplier) label = `Facture ${detectedSupplier}`;
  113. else if (detectedCategory !== 'autre') {
  114. const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance' };
  115. label = `Facture ${catLabels[detectedCategory] || detectedCategory}`;
  116. } else {
  117. label = 'Facture à identifier';
  118. }
  119. return {
  120. detected_supplier: detectedSupplier,
  121. detected_category: detectedCategory,
  122. detected_amount: detectedAmount,
  123. detected_label: label,
  124. };
  125. }
  126. function analyzeFromFilename(filePath) {
  127. const basename = filePath.split('/').pop().replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
  128. const supplierInfo = detectSupplier(basename);
  129. const detectedCategory = supplierInfo?.category || detectCategory(basename) || 'autre';
  130. const detectedSupplier = supplierInfo?.name || null;
  131. // Try to find amount in filename (e.g., "EDF_150.50_2024.pdf")
  132. const amountMatch = basename.match(/(\d+[.,]\d{2})/);
  133. const detectedAmount = amountMatch ? parseAmount(amountMatch[1]) : null;
  134. let label = detectedSupplier ? `Facture ${detectedSupplier}` : 'Facture à identifier';
  135. return {
  136. detected_supplier: detectedSupplier,
  137. detected_category: detectedCategory,
  138. detected_amount: detectedAmount,
  139. detected_label: label,
  140. };
  141. }
  142. module.exports = { analyzeFile, detectSupplier, detectCategory, detectAmount, SUPPLIERS };