From 358fb23d7e36112d55e33e3b27ce465cd0f9df47 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 1 Oct 2026 14:23:01 +0200 Subject: [PATCH] refactor(stock): restrict PDF purchase order to existing products and loosen matching similarity - Replace auto-creation of new products with strict association to existing stock items - Add dimension normalization and token-based similarity matching (rapidfuzz/difflib) - Provide ranked candidate suggestions in product dropdown during quote review - Enforce validation preventing purchase order creation with unassociated products --- loko/stock/pdf_parser.py | 129 +++++++++-- .../stock/purchase_order_from_pdf.html | 202 +++++++++++------- loko/stock/views.py | 67 ++---- 3 files changed, 264 insertions(+), 134 deletions(-) diff --git a/loko/stock/pdf_parser.py b/loko/stock/pdf_parser.py index bce6d35..9167dba 100644 --- a/loko/stock/pdf_parser.py +++ b/loko/stock/pdf_parser.py @@ -12,6 +12,13 @@ import logging from io import BytesIO from typing import Dict, List, Any, Optional, Tuple +import unicodedata + +try: + from rapidfuzz import fuzz +except ImportError: + fuzz = None + try: import pypdfium2 as pdfium except ImportError: @@ -88,6 +95,68 @@ def clean_vat_number(raw_vat: str) -> str: return re.sub(r'[\s\.]', '', raw_vat).upper() +STOP_WORDS = { + 'de', 'du', 'la', 'le', 'les', 'des', 'en', 'et', 'au', 'aux', 'd', 'l', + 'un', 'une', 'pour', 'par', 'sur', 'avec', 'sans', 'sous', 'dans', 'clb' +} + + +def clean_tokens_for_matching(text: str) -> Tuple[set, str]: + """ + Normalise le texte pour la comparaison sémantique de produits : + - Minuscules et décomposition d'accents + - Normalisation des dimensions et unités collées/séparées (5 m -> 5m, 25 mm -> 25mm, 1000 gr -> 1000g) + - Élimination de la ponctuation et caractères spéciaux + - Retrait des stop-words + Retourne (set_de_tokens, chaine_nettoyee). + """ + if not text: + return set(), "" + t = text.lower() + # Rapprocher les dimensions: '5 m' -> '5m', '25 mm' -> '25mm', '1000 gr' -> '1000g' + t = re.sub(r'(\d+)\s*(mm|cm|m|g|gr|kg|l|ml|v|w|ah|a)\b', r'\1\2', t) + t = re.sub(r'(\d+)gr\b', r'\1g', t) + t = unicodedata.normalize('NFKD', t) + t = ''.join(c for c in t if not unicodedata.combining(c)) + t = re.sub(r'[^a-z0-9]', ' ', t) + raw_tokens = t.split() + meaningful = [w for w in raw_tokens if w not in STOP_WORDS and len(w) > 1] + return set(meaningful), ' '.join(meaningful) + + +def compute_name_similarity(name_a: str, name_b: str) -> Tuple[float, set]: + """ + Calcule un score de similarité (0-100) basé sur les mots communs et la distance floue. + Permet d'associer des descriptions comme : + 'Mètre Ruban CLB Magnétique 5m x 25mm' et 'Mètre ruban, ABS, 5 m x 25 mm, jaune/noir' + """ + tok_a, str_a = clean_tokens_for_matching(name_a) + tok_b, str_b = clean_tokens_for_matching(name_b) + + if not tok_a or not tok_b: + return 0.0, set() + + common = tok_a.intersection(tok_b) + if not common: + return 0.0, set() + + overlap_a = len(common) / len(tok_a) + overlap_b = len(common) / len(tok_b) + max_overlap = max(overlap_a, overlap_b) + min_overlap = min(overlap_a, overlap_b) + + if fuzz is not None: + tsr = fuzz.token_set_ratio(str_a, str_b) + else: + tsr = difflib.SequenceMatcher(None, str_a, str_b).ratio() * 100 + + score = (max_overlap * 40.0) + (min_overlap * 20.0) + (tsr * 0.4) + if len(common) >= 3: + score = min(100.0, score + 10.0) + + return score, common + + class PdfQuoteParser: """ Parseur local de devis / offres PDF pour la création de bons de commande. @@ -477,7 +546,7 @@ class PdfQuoteParser: def _match_products(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Rapproche chaque article extrait avec les produits du catalogue Loko. - Cherche par référence fournisseur, code interne, SKU, ou nom similaire. + Cherche par référence fournisseur, code interne, SKU, ou mots/similarité souple. """ active_products = list( Product.objects.filter(is_active=True).values('id', 'code', 'name', 'sku', 'price', 'unit', 'supplier_reference') @@ -490,6 +559,8 @@ class PdfQuoteParser: matched_name = None matched_code = None match_type = None + match_score = 0.0 + top_matches = [] # 1. Correspondance exacte sur référence fournisseur if ref: @@ -499,6 +570,7 @@ class PdfQuoteParser: matched_name = p['name'] matched_code = p['code'] match_type = 'exact_supplier_ref' + match_score = 100.0 break # 2. Correspondance exacte sur SKU ou Code interne @@ -509,37 +581,60 @@ class PdfQuoteParser: matched_name = p['name'] matched_code = p['code'] match_type = 'exact_sku_or_code' + match_score = 100.0 break - # 3. Correspondance exacte sur le nom + # 3. Correspondance exacte sur le nom (insensible casse et accents) if not matched_id and name: + _, clean_name = clean_tokens_for_matching(name) for p in active_products: - if p['name'].strip().lower() == name.lower(): + _, clean_pname = clean_tokens_for_matching(p['name']) + if clean_name and clean_name == clean_pname: matched_id = p['id'] matched_name = p['name'] matched_code = p['code'] match_type = 'exact_name' + match_score = 100.0 break - # 4. Correspondance floue (similarité textuelle >= 80%) - if not matched_id and name: - best_ratio = 0.0 - best_prod = None + # 4. Correspondance souple basée sur les mots présents et la similarité + candidates = [] + if name: for p in active_products: - ratio = difflib.SequenceMatcher(None, name.lower(), p['name'].lower()).ratio() - if ratio > best_ratio and ratio >= 0.80: - best_ratio = ratio - best_prod = p - if best_prod: - matched_id = best_prod['id'] - matched_name = best_prod['name'] - matched_code = best_prod['code'] - match_type = 'fuzzy_name' + if matched_id and p['id'] == matched_id: + continue + sim_score, common_words = compute_name_similarity(name, p['name']) + if sim_score >= 35.0: + candidates.append({ + 'id': p['id'], + 'code': p['code'], + 'name': p['name'], + 'score': round(sim_score, 1), + 'common_words': list(common_words), + }) + + # Trier les candidats par score décroissant + candidates.sort(key=lambda x: x['score'], reverse=True) + top_matches = candidates[:5] + + # Si pas de match exact, retenir le meilleur candidat au-dessus du seuil souple + if not matched_id and candidates: + best = candidates[0] + # Seuil assoupli : score >= 45% et au moins 1 mot significatif en commun + if best['score'] >= 45.0 and len(best['common_words']) >= 1: + matched_id = best['id'] + matched_name = best['name'] + matched_code = best['code'] + match_type = 'fuzzy_name' + match_score = best['score'] item['matched_product_id'] = matched_id item['matched_product_name'] = matched_name item['matched_product_code'] = matched_code item['match_type'] = match_type - item['is_new'] = (matched_id is None) + item['match_score'] = round(match_score) + item['top_matches'] = top_matches + item['is_matched'] = bool(matched_id) + item['is_new'] = not bool(matched_id) return items diff --git a/loko/stock/templates/stock/purchase_order_from_pdf.html b/loko/stock/templates/stock/purchase_order_from_pdf.html index d5a9c21..0b94c1b 100644 --- a/loko/stock/templates/stock/purchase_order_from_pdf.html +++ b/loko/stock/templates/stock/purchase_order_from_pdf.html @@ -102,7 +102,7 @@ {% elif step == 'review' %} -
+ {% csrf_token %} @@ -248,6 +248,15 @@ + +
+ +
+ {% translate "Règle de commande :" %} + {% translate "Les lignes de commande doivent obligatoirement correspondre à un produit existant dans le catalogue. Si un produit n'est pas reconnu automatiquement, sélectionnez-le dans la liste ou décochez la ligne pour l'exclure." %} +
+
+
@@ -276,7 +285,7 @@ {% translate "Référence & Désignation fournisseur" %} - {% translate "Correspondance Catalogue Loko" %} + {% translate "Correspondance Produit Stock" %} {% translate "Quantité" %} {% translate "Prix net HT" %} {% translate "TVA" %} @@ -287,10 +296,17 @@ {% for item in parse_result.items %} {% with idx=forloop.counter0 %} - + - + @@ -310,67 +326,75 @@ {% endif %} - + - {% if not item.is_new %} - - - - - {% else %} - -
-
- - {% translate "Nouveau produit" %} +
+ {% if item.matched_product_id %} + {% if item.match_type == 'exact_supplier_ref' %} + + {% translate "Réf. fournisseur exacte" %} - {% translate "Non répertorié dans le catalogue" %} -
+ {% elif item.match_type == 'exact_sku_or_code' %} + + {% translate "Code/SKU exact" %} + + {% elif item.match_type == 'exact_name' %} + + {% translate "Nom identique" %} + + {% else %} + + {% translate "Similarité trouvée" %} ({{ item.match_score|floatformat:0 }}%) + + {% endif %} + + + + {% else %} + + {% translate "Non associé" %} + + {% endif %} +
-
- - -
+ + - - {% for cat in categories %} - - {% endfor %} - -
-
+ {% if item.top_matches %} + + {% for s in item.top_matches %} + {% if s.id != item.matched_product_id %} + + {% endif %} + {% endfor %} + + {% endif %} -
- - -
-
- -
-
- {% endif %} + + {% for p in products %} + {% if p.id != item.matched_product_id %} + + {% endif %} + {% endfor %} + + +
+ {% translate "Produit requis pour commander cette ligne." %} +
@@ -497,19 +521,53 @@ function toggleSelectAllItems(checked) { updateTotals(); } -function toggleProductRowAction(idx) { - const isNew = document.getElementById('action_new_' + idx).checked; - const newFields = document.getElementById('new_prod_fields_' + idx); - const existingField = document.getElementById('existing_prod_field_' + idx); - if (newFields && existingField) { - if (isNew) { - newFields.classList.remove('d-none'); - existingField.classList.add('d-none'); - } else { - newFields.classList.add('d-none'); - existingField.classList.remove('d-none'); - } +function handleProductSelection(idx) { + const select = document.getElementById('item_' + idx + '_product_id'); + const cb = document.getElementById('item_' + idx + '_include'); + const row = document.getElementById('item-row-' + idx); + const help = document.getElementById('item_' + idx + '_help'); + + if (select && select.value && select.value.trim() !== '') { + if (cb) cb.checked = true; + select.classList.remove('is-invalid', 'border-warning'); + if (row) row.classList.remove('table-warning-subtle'); + if (help) help.classList.add('d-none'); + } else { + if (cb) cb.checked = false; + select.classList.add('border-warning'); + if (row) row.classList.add('table-warning-subtle'); + if (help) help.classList.remove('d-none'); } + updateTotals(); +} + +function validateOrderForm() { + 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.' %}"); + return false; + } + + let unassociatedCount = 0; + let firstInvalid = null; + + includeCbs.forEach(cb => { + const idx = cb.dataset.idx; + const select = document.getElementById('item_' + idx + '_product_id'); + if (select && (!select.value || select.value.trim() === '')) { + unassociatedCount++; + select.classList.add('is-invalid'); + if (!firstInvalid) firstInvalid = select; + } + }); + + if (unassociatedCount > 0) { + alert("{% translate 'Certaines lignes cochées ne sont associées à aucun produit existant. Veuillez sélectionner un produit dans la liste pour chaque ligne cochée, ou décocher les lignes non associées.' %}"); + if (firstInvalid) firstInvalid.focus(); + return false; + } + + return true; } function updateTotals() { diff --git a/loko/stock/views.py b/loko/stock/views.py index f84b0d4..da31196 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -1713,10 +1713,8 @@ def purchase_order_from_pdf(request): status=order_status, ) - new_products_created = [] created_items_count = 0 - base_product_num = _next_product_number() - new_prod_offset = 0 + unassociated_items = [] for i in range(item_count): # Vérifier si la ligne est sélectionnée @@ -1728,6 +1726,14 @@ def purchase_order_from_pdf(request): if not name: name = ref or _("Article sans nom") + # Récupérer le produit existant obligatoirement + prod_id = request.POST.get(f'item_{i}_product_id') + product = Product.objects.filter(pk=prod_id, is_active=True).first() if prod_id else None + + if not product: + unassociated_items.append(name) + continue + try: qty = int(float(request.POST.get(f'item_{i}_qty', 1))) except (ValueError, TypeError): @@ -1735,9 +1741,6 @@ def purchase_order_from_pdf(request): if qty <= 0: qty = 1 - raw_unit = request.POST.get(f'item_{i}_unit', Product.UNIT_PC) - unit = normalize_unit(raw_unit) - try: unit_price = Decimal(str(request.POST.get(f'item_{i}_price', '0')).replace(',', '.')) except Exception: @@ -1753,40 +1756,6 @@ def purchase_order_from_pdf(request): if not location: location = warehouse.locations.filter(is_default=True).first() or warehouse.locations.first() - product_action = request.POST.get(f'item_{i}_product_action', 'existing') - product = None - - if product_action == 'existing': - prod_id = request.POST.get(f'item_{i}_product_id') - product = Product.objects.filter(pk=prod_id, is_active=True).first() - - if not product: - # Le magasinier a validé la création d'un nouveau produit - # Le produit est créé avec le statut particulier STATUS_PENDING_VALIDATION - curr_num = base_product_num + new_prod_offset - new_prod_offset += 1 - code = f"PROD_{curr_num}" - sku = str(curr_num) - - category_id = request.POST.get(f'item_{i}_category') or None - - product = Product.objects.create( - code=code, - sku=sku, - name=name, - category_id=category_id, - price=unit_price if unit_price > 0 else None, - unit=unit, - threshold=10, - supplier_reference=ref or None, - status=Product.STATUS_PENDING_VALIDATION, # Statut particulier ! - created_by=request.user, - is_active=True, - ) - new_products_created.append(product) - # Notifier les superviseurs - notify_supervisors_pending_product(product, created_by=request.user) - # Créer la ligne de commande PurchaseOrderItem.objects.create( purchase_order=order, @@ -1798,6 +1767,14 @@ def purchase_order_from_pdf(request): ) created_items_count += 1 + if created_items_count == 0: + transaction.set_rollback(True) + messages.error( + request, + _("Aucun article n'a pu être ajouté car aucun produit existant n'a été associé. Veuillez sélectionner des produits existants.") + ) + return redirect('stock:purchase_order_from_pdf') + # Attacher le fichier PDF original comme document justificatif if temp_pdf_path and os.path.exists(temp_pdf_path): try: @@ -1820,13 +1797,13 @@ def purchase_order_from_pdf(request): % {'code': order.code, 'items': created_items_count} ) - if new_products_created: - messages.warning( + if unassociated_items: + messages.info( request, - _("%(count)d nouveau(x) produit(s) ont été créé(s) avec le statut 'En attente de validation superviseur' : %(prods)s. Les superviseurs ont été notifiés.") + _("%(count)d ligne(s) non associée(s) ont été ignorée(s) : %(names)s.") % { - 'count': len(new_products_created), - 'prods': ', '.join(p.name for p in new_products_created[:3]) + ('...' if len(new_products_created) > 3 else '') + 'count': len(unassociated_items), + 'names': ', '.join(unassociated_items[:3]) + ('...' if len(unassociated_items) > 3 else '') } )