From 32cb1548152e756c416206eb85692064c9797b77 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 1 Oct 2026 14:46:09 +0200 Subject: [PATCH] feat(stock): support photo/scan quotes via local OCR and propose supplier creation - Enable image uploads (.png, .jpg, .jpeg, .webp, .tiff) alongside PDFs - Process photos and scans with local RapidOCR and adaptive bounding box clustering - Propose new supplier creation with pre-filled fields when quote supplier is not found - Retain manual catalog product association safeguard - Add automated unit and integration tests --- loko/stock/pdf_parser.py | 224 ++++++++++++++++-- .../stock/purchase_order_from_pdf.html | 204 +++++++++++----- .../templates/stock/stock_purchases.html | 2 +- loko/stock/tests.py | 126 ++++++++++ loko/stock/views.py | 95 ++++---- 5 files changed, 526 insertions(+), 125 deletions(-) diff --git a/loko/stock/pdf_parser.py b/loko/stock/pdf_parser.py index 9167dba..2e9c3e2 100644 --- a/loko/stock/pdf_parser.py +++ b/loko/stock/pdf_parser.py @@ -29,6 +29,12 @@ try: except ImportError: np = None +try: + from PIL import Image, ImageOps +except ImportError: + Image = None + ImageOps = None + from django.db.models import Q from .models import Supplier, Product, WarehouseLocation @@ -159,13 +165,50 @@ def compute_name_similarity(name_a: str, name_b: str) -> Tuple[float, set]: class PdfQuoteParser: """ - Parseur local de devis / offres PDF pour la création de bons de commande. + Parseur local de devis / offres au format PDF ou image (scan, photo). + - Utilise pypdfium2 pour l'extraction vectorielle directe sur PDF numérique (rapide et exact). + - Bascule automatiquement sur RapidOCR (moteur 100% local CPU) pour les PDF scannés et les photos/images. """ + IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif') + def __init__(self, file_or_path): self.file_or_path = file_or_path self._temp_path = None self.is_ocr = False + self.is_image = False + + def _is_image(self) -> bool: + """Détecte si le fichier fourni est une image ou une photo.""" + if isinstance(self.file_or_path, str): + ext = os.path.splitext(self.file_or_path)[1].lower() + if ext in self.IMAGE_EXTENSIONS: + return True + elif hasattr(self.file_or_path, 'name'): + ext = os.path.splitext(self.file_or_path.name)[1].lower() + if ext in self.IMAGE_EXTENSIONS: + return True + + # Détection par signature d'octets si disponible + if hasattr(self.file_or_path, 'read'): + header = self.file_or_path.read(16) + if hasattr(self.file_or_path, 'seek'): + self.file_or_path.seek(0) + if header.startswith(b'\x89PNG') or header.startswith(b'\xff\xd8\xff') or header.startswith(b'RIFF'): + return True + if header.startswith(b'%PDF'): + return False + + if isinstance(self.file_or_path, str) and os.path.exists(self.file_or_path): + try: + with open(self.file_or_path, 'rb') as f: + header = f.read(16) + if header.startswith(b'\x89PNG') or header.startswith(b'\xff\xd8\xff') or header.startswith(b'RIFF'): + return True + except OSError: + pass + + return False def _get_document(self) -> Tuple[Any, str]: """Ouvre le document PDF via pypdfium2.""" @@ -201,9 +244,14 @@ class PdfQuoteParser: def parse(self) -> Dict[str, Any]: """ - Extrait les métadonnées et la liste des articles d'un devis. + Extrait les métadonnées et la liste des articles d'un devis (PDF ou Image). Effectue le rapprochement automatique avec le stock existant (fournisseur et produits). """ + if self._is_image(): + self.is_ocr = True + self.is_image = True + return self._parse_image() + doc = None try: doc, path = self._get_document() @@ -214,7 +262,7 @@ class PdfQuoteParser: # Vérifier si le document possède du texte vectoriel ou nécessite un OCR total_char_count = sum(len(page.get_textpage().get_text_range()) for page in doc) if total_char_count < 50: - logger.info(f"PDF sans couche texte suffisante ({total_char_count} chars). Utilisation du moteur OCR.") + logger.info(f"PDF sans couche texte suffisante ({total_char_count} chars). Utilisation du moteur OCR local.") self.is_ocr = True return self._parse_with_ocr(doc) else: @@ -227,6 +275,78 @@ class PdfQuoteParser: pass self.close() + def _parse_image(self) -> Dict[str, Any]: + """Analyse directe d'une photo ou d'un scan d'offre via RapidOCR local.""" + if Image is None: + raise RuntimeError("Le module PIL/Pillow n'est pas disponible pour l'analyse d'images.") + + try: + from rapidocr_onnxruntime import RapidOCR + ocr = RapidOCR() + except ImportError: + logger.error("RapidOCR n'est pas disponible pour l'OCR local.") + raise RuntimeError("Le module OCR local n'est pas installé dans l'environnement.") + + # Charger l'image avec gestion de l'orientation EXIF des smartphones + if isinstance(self.file_or_path, str): + pil_img = Image.open(self.file_or_path) + elif hasattr(self.file_or_path, 'temporary_file_path'): + pil_img = Image.open(self.file_or_path.temporary_file_path()) + elif hasattr(self.file_or_path, 'read'): + pil_img = Image.open(self.file_or_path) + if hasattr(self.file_or_path, 'seek'): + self.file_or_path.seek(0) + else: + raise ValueError("Type d'image non supporté.") + + if ImageOps is not None: + pil_img = ImageOps.exif_transpose(pil_img) + pil_img = pil_img.convert('RGB') + + # Redimensionnement maîtrisé si image très volumineuse (ex: photo smartphone 48MP) + max_dim = max(pil_img.size) + if max_dim > 2500: + scale = 2500.0 / max_dim + new_size = (int(pil_img.width * scale), int(pil_img.height * scale)) + pil_img = pil_img.resize(new_size, Image.Resampling.LANCZOS) + + ocr_res, _ = ocr(np.array(pil_img)) + rects = [] + h = pil_img.height + + if ocr_res: + for item in ocr_res: + box, txt, conf = item + if not txt.strip(): + continue + left = min(pt[0] for pt in box) + right = max(pt[0] for pt in box) + top = min(pt[1] for pt in box) + bottom = max(pt[1] for pt in box) + rects.append((left, h - bottom, right, h - top, txt.strip())) + + rects.sort(key=lambda x: -x[3]) + avg_h = float(np.mean([r[3] - r[1] for r in rects])) if rects else 15.0 + y_tol = max(8.0, avg_h * 0.45) + + page_lines = self._cluster_lines(rects, y_tol=y_tol) + full_text = "\n".join(" ".join(r[4] for r in l) for l in page_lines) + + metadata = self._extract_metadata(full_text) + items = self._extract_items_from_clustered_lines([page_lines]) + + matched_supplier = self._match_supplier(metadata) + enriched_items = self._match_products(items) + + return { + 'is_ocr': True, + 'is_image': True, + 'metadata': metadata, + 'items': enriched_items, + 'matched_supplier': matched_supplier, + 'total_items': len(enriched_items), + } + def _parse_vector(self, doc: Any) -> Dict[str, Any]: """Analyse un PDF avec couche texte vectorielle (recherche spatiale précise).""" full_text = "" @@ -236,12 +356,12 @@ class PdfQuoteParser: metadata = self._extract_metadata(full_text, doc) items = self._extract_items_vector(doc) - # Rapprochement avec le catalogue matched_supplier = self._match_supplier(metadata) enriched_items = self._match_products(items) return { 'is_ocr': False, + 'is_image': False, 'metadata': metadata, 'items': enriched_items, 'matched_supplier': matched_supplier, @@ -267,6 +387,7 @@ class PdfQuoteParser: continue rects = [] + h = pil_img.height for item in ocr_res: box, txt, conf = item if not txt.strip(): @@ -275,12 +396,13 @@ class PdfQuoteParser: right = max(pt[0] for pt in box) top = min(pt[1] for pt in box) bottom = max(pt[1] for pt in box) - # Invert y for bottom-up coordinate alignment - h = pil_img.height rects.append((left, h - bottom, right, h - top, txt.strip())) rects.sort(key=lambda x: -x[3]) - page_lines = self._cluster_lines(rects) + avg_h = float(np.mean([r[3] - r[1] for r in rects])) if rects else 15.0 + y_tol = max(8.0, avg_h * 0.45) + + page_lines = self._cluster_lines(rects, y_tol=y_tol) ocr_pages_data.append(page_lines) for l in page_lines: full_text_lines.append(' '.join(item[4] for item in l)) @@ -294,6 +416,7 @@ class PdfQuoteParser: return { 'is_ocr': True, + 'is_image': False, 'metadata': metadata, 'items': enriched_items, 'matched_supplier': matched_supplier, @@ -308,9 +431,11 @@ class PdfQuoteParser: for r in rects: y = (r[1] + r[3]) / 2.0 - if current_y is None or abs(current_y - y) <= y_tol: + h_box = r[3] - r[1] + tol = max(y_tol, h_box * 0.45) + if current_y is None or abs(current_y - y) <= tol: current_line.append(r) - current_y = y + current_y = (current_y + y) / 2.0 if current_y else y else: current_line.sort(key=lambda x: x[0]) lines.append(current_line) @@ -321,7 +446,7 @@ class PdfQuoteParser: lines.append(current_line) return lines - def _extract_metadata(self, full_text: str, doc: pdfium.PdfDocument) -> Dict[str, Any]: + def _extract_metadata(self, full_text: str, doc: Optional[Any] = None) -> Dict[str, Any]: """Extrait les métadonnées de l'en-tête (Fournisseur, Devis n°, Date, Totaux).""" meta = { 'supplier_name': '', @@ -338,7 +463,9 @@ class PdfQuoteParser: } # 1. Numéro de TVA / Entreprise - vat_m = re.search(r'(?:TVA|BTW|VAT)\s*:\s*(BE\s*0?\d{3}[\.\s]?\d{3}[\.\s]?\d{3})', full_text, re.I) + vat_m = re.search(r'(?:TVA|BTW|VAT|N°\s*d\'entreprise)[\s.:-]*(BE\s*0?\d{3}[\.\s]?\d{3}[\.\s]?\d{3})', full_text, re.I) + if not vat_m: + vat_m = re.search(r'\b(BE\s*0?\d{3}[\.\s]?\d{3}[\.\s]?\d{3})\b', full_text, re.I) if vat_m: meta['supplier_vat'] = vat_m.group(1).strip() @@ -348,12 +475,12 @@ class PdfQuoteParser: meta['supplier_email'] = email_m.group(0).strip() # 3. Téléphone - phone_m = re.search(r'(?:Tel|Tél|Phone)[\s:]*([+\d\s\.\(\)\/-]{8,25})', full_text, re.I) + phone_m = re.search(r'(?:Tel|Tél|Phone|Gsm)[\s.:-]*([+\d\s\.\(\)\/-]{8,25})', full_text, re.I) if phone_m: meta['supplier_phone'] = phone_m.group(1).strip().rstrip(' -') - # 4. Numéro de devis / offre (ex: "OF 2026000723/", "DEV-12345", etc.) - quote_m = re.search(r'\b(OF\s*[-#]?\s*\d+[\w\/-]*|DEV(?:IS)?\s*[-#]?\s*\d+[\w\/-]*|QUO(?:TE)?\s*[-#]?\s*\d+[\w\/-]*)\b', full_text, re.I) + # 4. Numéro de devis / offre (ex: "OF 2026000723", "DEV-12345", etc.) + quote_m = re.search(r'\b(OF\s*[-#]?\s*\d+[\w\/-]*|DEV(?:IS)?\s*[-#]?\s*\d+[\w\/-]*|QUO(?:TE)?\s*[-#]?\s*\d+[\w\/-]*|OFFRE\s*[-#]?\s*\d+[\w\/-]*)\b', full_text, re.I) if quote_m: meta['quote_number'] = quote_m.group(0).strip().rstrip('/') else: @@ -367,15 +494,35 @@ class PdfQuoteParser: meta['date'] = date_m.group(1).replace('-', '/').replace('.', '/') # 6. Nom du fournisseur - name_m = re.search(r'(?:sa|nv|srl|sprl|bvba|bv|s\.a\.|n\.v\.)\s+([A-Za-z\s0-9\.-]+?)(?:\r|\n|Rue|Avenue|Chaussée|Boulevard|Lentestraat)', full_text, re.I) - if name_m: - meta['supplier_name'] = name_m.group(0).strip() - else: - domain_m = re.search(r'([\w-]+\.(?:com|be|fr|eu))', full_text, re.I) - if domain_m: - meta['supplier_name'] = domain_m.group(1) + LEGAL = r'(?:s\.?a\.?|n\.?v\.?|s\.?r\.?l\.?|s\.?p\.?r\.?l\.?|b\.?v\.?b\.?a\.?|b\.?v\.?|s\.?a\.?s\.?|s\.?a\.?r\.?l\.?)' + for line in full_text.split('\n')[:30]: + line_s = line.strip() + # Chercher une ligne contenant une forme légale avec nom substantiel + m1 = re.search(rf'^\b{LEGAL}\b\s+([A-Za-z0-9\ \.-]{{3,60}})', line_s, re.I) + if m1: + cand = m1.group(1).strip() + cand_clean = re.sub(rf'\b{LEGAL}\b', '', cand, flags=re.I).strip(' .-') + if len(cand_clean) >= 4: + meta['supplier_name'] = line_s + break + m2 = re.search(rf'^([A-Za-z0-9\ \.-]{{3,60}})\s+\b{LEGAL}\b', line_s, re.I) + if m2: + cand = m2.group(1).strip() + cand_clean = re.sub(rf'\b{LEGAL}\b', '', cand, flags=re.I).strip(' .-') + if len(cand_clean) >= 4: + meta['supplier_name'] = line_s + break + + if not meta['supplier_name']: + name_m = re.search(r'(?:sa|nv|srl|sprl|bvba|bv|s\.a\.|n\.v\.)\s+([A-Za-z\s0-9\.-]+?)(?:\r|\n|Rue|Avenue|Chaussée|Boulevard|Lentestraat)', full_text, re.I) + if name_m: + meta['supplier_name'] = name_m.group(0).strip() else: - meta['supplier_name'] = "Fournisseur inconnu" + domain_m = re.search(r'([\w-]+\.(?:com|be|fr|eu))', full_text, re.I) + if domain_m: + meta['supplier_name'] = domain_m.group(1) + else: + meta['supplier_name'] = "" # Adresse addr_m = re.search(r'((?:Rue|Avenue|Boulevard|Chaussée|Straat|Lentestraat)[^\n\r]+[\d]{4}\s+[A-Za-zÀ-ÿ]+)', full_text, re.I) @@ -414,7 +561,6 @@ class PdfQuoteParser: rects.sort(key=lambda x: -x[3]) - # Délimiter verticalement la zone de tableau sur la page table_top = None table_bottom = 50.0 @@ -443,11 +589,18 @@ class PdfQuoteParser: items = [] current_item = None - # Modèle de ligne produit : + # Modèle 1 (vectoriel classique) : # description | qté | unité | prix unitaire | [remise%] | total | tva% row_pattern = re.compile( r'^(.*?)\s*\|\s*(\d+[.,]\d+)\s*\|\s*([A-Za-zÀ-ÿ]+)\s*\|\s*(\d+[.,]\d+)\s*(?:\|\s*(\d+[.,]?\d*%)|\s*)\s*\|\s*(\d+[.,]\d+)\s*\|\s*(\d+%)' ) + # Modèle 2 (OCR / Scan souple où qté+unité ou total+tva peuvent être regroupés sans pipe) : + ocr_row_base = re.compile( + r'^(.*?)\s*\|\s*(\d+[.,]\d+)\s*(?:\|\s*|\s+)([A-Za-zÀ-ÿ]+)\s*\|\s*(\d+[.,]\d+)(.*?)$' + ) + ocr_row_tail = re.compile( + r'(?:\|\s*(\d+[.,]?\d*%)\s*)?\|\s*(\d+[.,]\d+)(?:[\|\s]+(\d+%))?' + ) stop_keywords = [ 'Total hors', 'Total EUR', 'Total TTC', 'Total HT', @@ -473,7 +626,23 @@ class PdfQuoteParser: discount_str = m.group(5) if m.group(5) else '0%' total_val = float(m.group(6).replace(',', '.')) vat_val = float(m.group(7).replace('%', '')) + else: + m_ocr = ocr_row_base.match(line_str) + if m_ocr: + desc_part = m_ocr.group(1).strip() + qty_val = float(m_ocr.group(2).replace(',', '.')) + raw_unit = m_ocr.group(3).strip() + gross_price = float(m_ocr.group(4).replace(',', '.')) + tail = m_ocr.group(5) + tail_m = ocr_row_tail.search(tail) if tail else None + discount_str = tail_m.group(1) if tail_m and tail_m.group(1) else '0%' + total_val = float(tail_m.group(2).replace(',', '.')) if tail_m and tail_m.group(2) else gross_price * qty_val + vat_val = float(tail_m.group(3).replace('%', '')) if tail_m and tail_m.group(3) else 21.0 + m = m_ocr + else: + m = None + if m: # Séparer la référence fournisseur de la description parts = desc_part.split(' | ') if len(parts) >= 2: @@ -511,10 +680,9 @@ class PdfQuoteParser: if continuation and not any(k in continuation for k in ['RECUPEL', 'ING - BE95', 'BNP Paribas', '1/2', '2/2']): current_item['name'] += ' ' + continuation - # Nettoyage final des libellés (suppression des indicateurs de pagination orphelins) + # Nettoyage final des libellés for it in items: it['name'] = re.sub(r'\s*\b[1-9]/[1-9]\b\s*$', '', it['name']).strip() - # Nettoyer les paramètres URL résiduels éventuels it['name'] = re.sub(r'categoryId=\d+.*', '', it['name']).strip() return items @@ -638,3 +806,7 @@ class PdfQuoteParser: item['is_new'] = not bool(matched_id) return items + + +# Alias pour usage universel (PDF, scans, photos) +QuoteDocumentParser = PdfQuoteParser diff --git a/loko/stock/templates/stock/purchase_order_from_pdf.html b/loko/stock/templates/stock/purchase_order_from_pdf.html index 0b94c1b..3d3337b 100644 --- a/loko/stock/templates/stock/purchase_order_from_pdf.html +++ b/loko/stock/templates/stock/purchase_order_from_pdf.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load i18n static %} -{% block title %}{% translate "Créer un bon de commande depuis une offre PDF" %} — Loko{% endblock %} +{% block title %}{% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %} — Loko{% endblock %} {% block content %}
@@ -11,12 +11,13 @@

- {% translate "Créer un bon de commande depuis une offre PDF" %} + + {% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %}

@@ -27,7 +28,7 @@
{% if step == 'upload' %} - +
@@ -40,7 +41,7 @@

{% translate "Importer une offre ou un devis fournisseur" %}

- {% translate "Déposez le fichier PDF reçu du fournisseur. Le système analyse automatiquement le document, extrait les articles, compare avec le stock existant et prépare le bon de commande." %} + {% translate "Déposez un devis au format PDF, un scan ou une photo prise avec un smartphone. Le système analyse automatiquement le document avec reconnaissance OCR locale, extrait les articles, compare avec le stock existant et prépare le bon de commande." %}

@@ -63,16 +64,22 @@
- +
- - {% translate "Cliquez ou glissez-déposez le devis PDF ici" %} - {% translate "Formats acceptés : PDF numérique ou scanné (OCR automatique)" %} - +
+ + +
+ {% translate "Cliquez ou glissez-déposez le document ici" %} + {% translate "Formats acceptés : PDF (numérique ou scanné), photos & scans (JPG, PNG, WEBP, TIFF)" %} + + {% translate "Reconnaissance OCR 100% locale" %} + +
@@ -83,8 +90,8 @@
- {% translate "Règle de gestion des articles :" %}
- {% translate "Les articles trouvés dans le stock Loko seront directement rattachés. Pour tout article non répertorié, vous pourrez confirmer sa création en tant que nouveau produit avec un statut spécial 'En attente de validation superviseur'." %} + {% translate "Règle de gestion des articles & du fournisseur :" %}
+ {% translate "Les articles du devis sont rapprochés avec les produits existants de votre catalogue Loko. Si le fournisseur n'est pas encore enregistré, vous pourrez confirmer sa création directement lors de la revue." %}
@@ -123,7 +130,7 @@ {% else %} - {% translate "Nouveau fournisseur détecté" %} + {% translate "Fournisseur non trouvé" %} {% endif %}
@@ -153,56 +160,75 @@
{% else %} - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ +
+ +
+ {% translate "Fournisseur non trouvé dans le répertoire Loko" %}
+ {% translate "Le fournisseur détecté n'a pas été retrouvé. Souhaitez-vous le créer dans Loko ou rattacher un fournisseur existant ?" %}
-
-
- -
@@ -237,9 +263,24 @@
{{ parse_result.metadata.date|default:"—" }}
-
- - {% translate "Pièce justificative attachée :" %} {{ orig_pdf_name }} +
+
+ + {% translate "Pièce justificative attachée :" %} {{ orig_pdf_name }} +
+ {% if parse_result.is_image %} + + {% translate "Photo / Scan (OCR local)" %} + + {% elif parse_result.is_ocr %} + + {% translate "PDF scanné (OCR local)" %} + + {% else %} + + {% translate "PDF numérique" %} + + {% endif %}
@@ -504,16 +545,43 @@ document.addEventListener('DOMContentLoaded', function() { function updateFileDisplay() { if (fileInput.files.length > 0) { const file = fileInput.files[0]; + const isImg = file.type.startsWith('image/') || /\.(png|jpe?g|webp|bmp|tiff?)$/i.test(file.name); + const icon = selectedFileDiv.querySelector('i'); + if (icon) { + icon.className = isImg ? 'bi bi-camera me-1 text-primary' : 'bi bi-file-earmark-pdf me-1 text-danger'; + } selectedFileDiv.querySelector('.file-text').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)'; selectedFileDiv.classList.remove('d-none'); } } } + // Initialisation affichage fournisseur si non identifié + toggleSupplierChoice(); + // Calcul initial des totaux updateTotals(); }); +function toggleSupplierChoice() { + const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked; + const cardNew = document.getElementById('container_create_supplier'); + const cardExisting = document.getElementById('container_existing_supplier'); + if (cardNew && cardExisting) { + if (isNew) { + cardNew.classList.remove('opacity-75', 'bg-light-subtle'); + cardNew.classList.add('border-primary', 'shadow-sm', 'bg-white'); + cardExisting.classList.remove('border-primary', 'shadow-sm', 'bg-white'); + cardExisting.classList.add('opacity-75', 'bg-light-subtle'); + } else { + cardExisting.classList.remove('opacity-75', 'bg-light-subtle'); + cardExisting.classList.add('border-primary', 'shadow-sm', 'bg-white'); + cardNew.classList.remove('border-primary', 'shadow-sm', 'bg-white'); + cardNew.classList.add('opacity-75', 'bg-light-subtle'); + } + } +} + function toggleSelectAllItems(checked) { document.querySelectorAll('.item-include-cb').forEach(cb => { cb.checked = checked; @@ -542,6 +610,26 @@ function handleProductSelection(idx) { } function validateOrderForm() { + // 1. Validation du fournisseur + const supChoiceNew = document.getElementById('sup_choice_new'); + const supChoicePick = document.getElementById('sup_choice_pick'); + if (supChoiceNew && supChoiceNew.checked) { + const nameInput = document.getElementById('id_new_supplier_name') || document.querySelector('input[name="new_supplier_name"]'); + if (!nameInput || !nameInput.value.trim()) { + alert("{% translate 'Veuillez saisir le nom du nouveau fournisseur à créer.' %}"); + if (nameInput) nameInput.focus(); + return false; + } + } else if (supChoicePick && supChoicePick.checked) { + const supSelect = document.getElementById('id_supplier_id') || document.querySelector('select[name="supplier_id"]'); + if (!supSelect || !supSelect.value.trim()) { + alert("{% translate 'Veuillez sélectionner un fournisseur existant dans la liste déroulante.' %}"); + if (supSelect) supSelect.focus(); + return false; + } + } + + // 2. Validation des articles const includeCbs = document.querySelectorAll('.item-include-cb:checked'); if (includeCbs.length === 0) { alert("{% translate 'Veuillez cocher au moins une ligne d\'article à inclure dans le bon de commande.' %}"); diff --git a/loko/stock/templates/stock/stock_purchases.html b/loko/stock/templates/stock/stock_purchases.html index 3e8241b..be7aa5e 100644 --- a/loko/stock/templates/stock/stock_purchases.html +++ b/loko/stock/templates/stock/stock_purchases.html @@ -8,7 +8,7 @@ {% if can_create_purchase_order %}
- {% translate "Créer depuis une offre (PDF)" %} + {% translate "Créer depuis une offre (PDF, Scan, Photo)" %} {% translate "Nouvelle demande d'offre" %} diff --git a/loko/stock/tests.py b/loko/stock/tests.py index 58e3f8c..aac34cf 100644 --- a/loko/stock/tests.py +++ b/loko/stock/tests.py @@ -2636,12 +2636,138 @@ class PreparationOrderNumberCodificationTests(TestCase): self.assertEqual(po_old.order_number, "BP-I113392-01") +class PurchaseOrderFromDocumentTests(TestCase): + def setUp(self): + from django.contrib.auth.models import User + from common.models import UserConfig + from decimal import Decimal + from .models import Product, Supplier, Warehouse, WarehouseLocation + self.warehouse = Warehouse.objects.create( + code="WH-DOC-TEST", + name="Entrepôt Document Test", + manager="Denis", + ) + self.location = WarehouseLocation.objects.create( + warehouse=self.warehouse, + name="Emplacement Test", + code="LOC-DOC-TEST", + is_default=True, + ) + self.supplier = Supplier.objects.create( + name="Fournisseur Existant Test", + enterprise_number="BE 0111.222.333", + ) + self.product = Product.objects.create( + code="PROD-DOC-1", + name="Mètre Ruban CLB Magnétique 5m x 25mm", + sku="SKU-DOC-1", + supplier_reference="ZE 45525", + price=Decimal("9.28"), + ) + self.superuser = User.objects.create_superuser( + username="admin_doc", password="password", email="admin_doc@test.com" + ) + self.superuser_config = UserConfig.objects.create( + user=self.superuser, is_intern=True + ) + def test_parser_detects_image_extension(self): + from .pdf_parser import PdfQuoteParser + parser_png = PdfQuoteParser("test_scan.png") + self.assertTrue(parser_png._is_image()) + parser_jpg = PdfQuoteParser("photo_quote.jpg") + self.assertTrue(parser_jpg._is_image()) + parser_pdf = PdfQuoteParser("quote.pdf") + self.assertFalse(parser_pdf._is_image()) + def test_parser_extract_metadata_supplier_name_clean(self): + from .pdf_parser import PdfQuoteParser + sample_ocr_text = ( + "INTERNATIONAL TOOLS SERVICE\n" + "S.A. N.V.\n" + "ITS-tools.com\n" + "sa International Tools Service nv\n" + "Rue du Printemps 39-43, Lentestraat - Bruxelles 1050 Brussel\n" + "TVA-BTW-VAT : BE 0447.966.784\n" + "Tel : +32 2 640 58 60\n" + "OF2026000723/\n" + "31/03/2026\n" + ) + parser = PdfQuoteParser("dummy.png") + meta = parser._extract_metadata(sample_ocr_text) + self.assertEqual(meta["supplier_vat"], "BE 0447.966.784") + self.assertEqual(meta["supplier_phone"], "+32 2 640 58 60") + self.assertEqual(meta["quote_number"], "OF2026000723") + self.assertEqual(meta["date"], "31/03/2026") + self.assertIn("International Tools Service", meta["supplier_name"]) + def test_view_confirm_order_creates_new_supplier(self): + from django.urls import reverse + from .models import PurchaseOrder, Supplier + self.client.force_login(self.superuser) + url = reverse("stock:purchase_order_from_pdf") + data = { + "action": "confirm_order", + "warehouse_id": self.warehouse.pk, + "supplier_choice": "new", + "new_supplier_name": "Nouveau Fournisseur SRL", + "new_supplier_vat": "BE 0999.888.777", + "new_supplier_email": "contact@nouveau.be", + "new_supplier_phone": "+32 470 00 00 00", + "new_supplier_address": "10 Rue Neuve, 1000 Bruxelles", + "item_count": "1", + "item_0_include": "on", + "item_0_product_id": self.product.pk, + "item_0_ref": "ZE 45525", + "item_0_name": "Mètre ruban", + "item_0_qty": "5", + "item_0_price": "9.28", + "item_0_vat": "21.00", + "item_0_location": self.location.pk, + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + created_sup = Supplier.objects.filter(name="Nouveau Fournisseur SRL").first() + self.assertIsNotNone(created_sup) + self.assertEqual(created_sup.enterprise_number, "BE 0999.888.777") + self.assertEqual(created_sup.contact_email, "contact@nouveau.be") + po = PurchaseOrder.objects.filter(supplier=created_sup).first() + self.assertIsNotNone(po) + self.assertEqual(po.items.count(), 1) + self.assertEqual(po.items.first().product, self.product) + self.assertEqual(po.items.first().quantity, 5) + + def test_view_confirm_order_links_existing_supplier(self): + from django.urls import reverse + from .models import PurchaseOrder + + self.client.force_login(self.superuser) + url = reverse("stock:purchase_order_from_pdf") + + data = { + "action": "confirm_order", + "warehouse_id": self.warehouse.pk, + "supplier_choice": "existing", + "supplier_id": self.supplier.pk, + "item_count": "1", + "item_0_include": "on", + "item_0_product_id": self.product.pk, + "item_0_qty": "2", + "item_0_price": "9.28", + "item_0_vat": "21.00", + "item_0_location": self.location.pk, + } + + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + + po = PurchaseOrder.objects.filter(supplier=self.supplier).first() + self.assertIsNotNone(po) + self.assertEqual(po.items.count(), 1) + self.assertEqual(po.items.first().quantity, 2) diff --git a/loko/stock/views.py b/loko/stock/views.py index da31196..f490104 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -1629,45 +1629,53 @@ def purchase_order_from_pdf(request): action = request.POST.get('action', '') if action == 'parse_pdf': - pdf_file = request.FILES.get('pdf_file') + doc_file = request.FILES.get('pdf_file') or request.FILES.get('document_file') warehouse_id = request.POST.get('warehouse') - if not pdf_file: - messages.error(request, _("Veuillez sélectionner un fichier PDF.")) - elif not pdf_file.name.lower().endswith('.pdf'): - messages.error(request, _("Le fichier doit obligatoirement être au format PDF.")) + ALLOWED_EXTENSIONS = ('.pdf', '.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif') + if not doc_file: + messages.error(request, _("Veuillez sélectionner un document (PDF, scan ou photo).")) else: - warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first() - if not warehouse: - warehouse = warehouses.first() + file_ext = os.path.splitext(doc_file.name.lower())[1] + if file_ext not in ALLOWED_EXTENSIONS: + messages.error( + request, + _("Format de fichier non supporté. Veuillez téléverser un PDF ou une image (JPG, PNG, WEBP, TIFF).") + ) + else: + warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first() + if not warehouse: + warehouse = warehouses.first() - # Sauvegarder dans un fichier temporaire pour pouvoir l'attacher après validation - fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_', suffix='.pdf') - with os.fdopen(fd, 'wb') as f: - for chunk in pdf_file.chunks(): - f.write(chunk) - temp_pdf_path = tmp_path - orig_pdf_name = pdf_file.name + # Sauvegarder dans un fichier temporaire pour pouvoir l'attacher après validation + fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_', suffix=file_ext) + with os.fdopen(fd, 'wb') as f: + for chunk in doc_file.chunks(): + f.write(chunk) + temp_pdf_path = tmp_path + orig_pdf_name = doc_file.name - try: - from .pdf_parser import PdfQuoteParser - parser = PdfQuoteParser(temp_pdf_path) - parse_result = parser.parse() - step = 'review' - if parse_result.get('is_ocr'): - messages.info(request, _("Le document a été analysé via le moteur OCR local (document scanné ou image).")) - else: - messages.success(request, _("Le devis a été analysé avec succès. Veuillez vérifier et valider les articles ci-dessous.")) - except Exception as e: - import logging - logging.getLogger(__name__).exception("Erreur lors de l'analyse du devis PDF") - messages.error(request, _("Erreur lors de l'analyse du PDF : %(err)s") % {'err': str(e)}) - if temp_pdf_path and os.path.exists(temp_pdf_path): - try: - os.remove(temp_pdf_path) - except OSError: - pass - temp_pdf_path = '' - step = 'upload' + try: + from .pdf_parser import PdfQuoteParser + parser = PdfQuoteParser(temp_pdf_path) + parse_result = parser.parse() + step = 'review' + if parse_result.get('is_image'): + messages.info(request, _("L'image/photo a été analysée avec succès via le moteur OCR local.")) + elif parse_result.get('is_ocr'): + messages.info(request, _("Le document PDF scanné a été analysé avec succès via le moteur OCR local.")) + else: + messages.success(request, _("Le devis a été analysé avec succès. Veuillez vérifier et valider les articles ci-dessous.")) + except Exception as e: + import logging + logging.getLogger(__name__).exception("Erreur lors de l'analyse du document d'achat") + messages.error(request, _("Erreur lors de l'analyse du document : %(err)s") % {'err': str(e)}) + if temp_pdf_path and os.path.exists(temp_pdf_path): + try: + os.remove(temp_pdf_path) + except OSError: + pass + temp_pdf_path = '' + step = 'upload' elif action == 'confirm_order': warehouse_id = request.POST.get('warehouse_id') @@ -1684,12 +1692,19 @@ def purchase_order_from_pdf(request): supplier = None if supplier_choice == 'existing': supplier_id = request.POST.get('supplier_id') - supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first() - - if not supplier: + if supplier_id: + supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first() + if not supplier: + messages.error( + request, + _("Veuillez sélectionner un fournisseur existant valide dans la liste ou choisir de le créer.") + ) + return redirect('stock:purchase_order_from_pdf') + else: new_sup_name = request.POST.get('new_supplier_name', '').strip() if not new_sup_name: - new_sup_name = _("Fournisseur sans nom") + messages.error(request, _("Veuillez renseigner le nom du nouveau fournisseur à créer.")) + return redirect('stock:purchase_order_from_pdf') supplier = Supplier.objects.create( name=new_sup_name, enterprise_number=request.POST.get('new_supplier_vat', '').strip() or None, @@ -1698,7 +1713,7 @@ def purchase_order_from_pdf(request): address=request.POST.get('new_supplier_address', '').strip() or None, is_active=True, ) - messages.info(request, _("Nouveau fournisseur créé : %(name)s") % {'name': supplier.name}) + messages.success(request, _("Nouveau fournisseur « %(name)s » créé avec succès.") % {'name': supplier.name}) try: item_count = int(request.POST.get('item_count', 0))