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
This commit is contained in:
kdeterme 2026-10-01 14:23:01 +02:00
parent 391966da07
commit 358fb23d7e
3 changed files with 264 additions and 134 deletions

View file

@ -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

View file

@ -102,7 +102,7 @@
{% elif step == 'review' %}
<!-- ÉTAPE 2 : REVUE ET VALIDATION DU BON DE COMMANDE -->
<form method="post" id="confirm-order-form">
<form method="post" id="confirm-order-form" onsubmit="return validateOrderForm()">
{% csrf_token %}
<input type="hidden" name="action" value="confirm_order">
<input type="hidden" name="warehouse_id" value="{{ selected_warehouse.id }}">
@ -248,6 +248,15 @@
</div>
</div>
<!-- RÈGLE COMMANDE CATALOGUE -->
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-3 py-2 px-3">
<i class="bi bi-info-circle-fill text-primary fs-5 me-3"></i>
<div>
<strong>{% translate "Règle de commande :" %}</strong>
{% 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." %}
</div>
</div>
<!-- TABLEAU DES ARTICLES EXTRAITS -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
@ -276,7 +285,7 @@
<input type="checkbox" id="master-select-checkbox" class="form-check-input" checked onchange="toggleSelectAllItems(this.checked)">
</th>
<th style="min-width: 250px;">{% translate "Référence & Désignation fournisseur" %}</th>
<th style="min-width: 300px;">{% translate "Correspondance Catalogue Loko" %}</th>
<th style="min-width: 320px;">{% translate "Correspondance Produit Stock" %}</th>
<th style="width: 100px;" class="text-end">{% translate "Quantité" %}</th>
<th style="width: 120px;" class="text-end">{% translate "Prix net HT" %}</th>
<th style="width: 90px;" class="text-end">{% translate "TVA" %}</th>
@ -287,10 +296,17 @@
<tbody>
{% for item in parse_result.items %}
{% with idx=forloop.counter0 %}
<tr class="{% if item.is_new %}table-warning-subtle{% endif %}">
<tr id="item-row-{{ idx }}" class="{% if not item.matched_product_id %}table-warning-subtle{% endif %}">
<!-- Checkbox inclusion -->
<td class="text-center">
<input type="checkbox" name="item_{{ idx }}_include" class="form-check-input item-include-cb" value="1" checked onchange="updateTotals()">
<input type="checkbox"
name="item_{{ idx }}_include"
id="item_{{ idx }}_include"
class="form-check-input item-include-cb"
data-idx="{{ idx }}"
value="1"
{% if item.matched_product_id %}checked{% endif %}
onchange="updateTotals()">
<input type="hidden" name="item_{{ idx }}_ref" value="{{ item.reference }}">
<input type="hidden" name="item_{{ idx }}_name" value="{{ item.name }}">
<input type="hidden" name="item_{{ idx }}_qty" value="{{ item.quantity }}">
@ -310,67 +326,75 @@
{% endif %}
</td>
<!-- Rapprochement Catalogue Produit -->
<!-- Rapprochement Catalogue Produit (Exclusif aux produits existants) -->
<td>
{% if not item.is_new %}
<!-- Produit existant reconnu -->
<div class="d-flex align-items-center mb-1">
<span class="badge bg-success text-white me-2">
<i class="bi bi-check-circle"></i> {% translate "Produit trouvé" %}
</span>
<a href="{% url 'stock:product_detail' item.matched_product_id %}" target="_blank" class="fw-bold text-decoration-none">
{{ item.matched_product_code }} — {{ item.matched_product_name }}
</a>
</div>
<input type="hidden" name="item_{{ idx }}_product_action" value="existing">
<input type="hidden" name="item_{{ idx }}_product_id" value="{{ item.matched_product_id }}">
{% else %}
<!-- Produit non trouvé : validation magasinier -->
<div class="p-2 rounded border bg-white">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-warning text-dark me-2">
<i class="bi bi-exclamation-triangle"></i> {% translate "Nouveau produit" %}
<div class="mb-1 d-flex align-items-center gap-1">
{% if item.matched_product_id %}
{% if item.match_type == 'exact_supplier_ref' %}
<span class="badge bg-success text-white">
<i class="bi bi-check-circle me-1"></i>{% translate "Réf. fournisseur exacte" %}
</span>
<span class="small text-muted">{% translate "Non répertorié dans le catalogue" %}</span>
</div>
{% elif item.match_type == 'exact_sku_or_code' %}
<span class="badge bg-success text-white">
<i class="bi bi-check-circle me-1"></i>{% translate "Code/SKU exact" %}
</span>
{% elif item.match_type == 'exact_name' %}
<span class="badge bg-success text-white">
<i class="bi bi-check-circle me-1"></i>{% translate "Nom identique" %}
</span>
{% else %}
<span class="badge bg-info text-dark">
<i class="bi bi-stars me-1"></i>{% translate "Similarité trouvée" %} ({{ item.match_score|floatformat:0 }}%)
</span>
{% endif %}
<a href="{% url 'stock:product_detail' item.matched_product_id %}" target="_blank" class="small text-muted text-decoration-none ms-1" title="{% translate 'Voir la fiche produit' %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
{% else %}
<span class="badge bg-warning text-dark">
<i class="bi bi-exclamation-triangle me-1"></i>{% translate "Non associé" %}
</span>
{% endif %}
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="item_{{ idx }}_product_action" id="action_new_{{ idx }}" value="new" checked onchange="toggleProductRowAction({{ idx }})">
<label class="form-check-label fw-bold text-primary small" for="action_new_{{ idx }}">
{% translate "Confirmer comme nouveau produit" %}
<span class="badge bg-warning-subtle text-warning-emphasis border ms-1" style="font-size: 0.72rem;">
<i class="bi bi-clock-history"></i> {% translate "À valider par superviseur" %}
</span>
</label>
</div>
<!-- Sélecteur de produit existant -->
<select name="item_{{ idx }}_product_id"
id="item_{{ idx }}_product_id"
class="form-select form-select-sm product-select-input {% if not item.matched_product_id %}border-warning{% endif %}"
data-idx="{{ idx }}"
onchange="handleProductSelection({{ idx }})">
{% if not item.matched_product_id %}
<option value="" selected>— {% translate "Sélectionner un produit existant..." %} —</option>
{% else %}
<option value="{{ item.matched_product_id }}" selected>
{{ item.matched_product_code }} — {{ item.matched_product_name }}
</option>
<option value="">— {% translate "Ne pas associer (exclure)" %} —</option>
{% endif %}
<div class="row g-2 mb-2 ps-3" id="new_prod_fields_{{ idx }}">
<div class="col-12">
<select name="item_{{ idx }}_category" class="form-select form-select-sm">
<option value="">{% translate "— Catégorie (optionnelle) —" %}</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name_fr }}</option>
{% endfor %}
</select>
</div>
</div>
{% if item.top_matches %}
<optgroup label="{% translate 'Suggestions pertinentes' %}">
{% for s in item.top_matches %}
{% if s.id != item.matched_product_id %}
<option value="{{ s.id }}">
{{ s.code }} — {{ s.name }} ({{ s.score|floatformat:0 }}%)
</option>
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<div class="form-check">
<input class="form-check-input" type="radio" name="item_{{ idx }}_product_action" id="action_existing_{{ idx }}" value="existing" onchange="toggleProductRowAction({{ idx }})">
<label class="form-check-label small" for="action_existing_{{ idx }}">
{% translate "Associer à un produit existant :" %}
</label>
</div>
<div class="ps-3 mt-1 d-none" id="existing_prod_field_{{ idx }}">
<select name="item_{{ idx }}_product_id" class="form-select form-select-sm">
<option value="">{% translate "— Choisir un produit existant —" %}</option>
{% for p in products %}
<option value="{{ p.id }}">{{ p.code }} — {{ p.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<optgroup label="{% translate 'Tous les produits du catalogue' %}">
{% for p in products %}
{% if p.id != item.matched_product_id %}
<option value="{{ p.id }}">{{ p.code }} — {{ p.name }}</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
<div class="form-text small text-danger mt-1 {% if item.matched_product_id %}d-none{% endif %}" id="item_{{ idx }}_help">
<i class="bi bi-exclamation-circle me-1"></i>{% translate "Produit requis pour commander cette ligne." %}
</div>
</td>
<!-- Quantité -->
@ -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() {

View file

@ -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 '')
}
)