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:
parent
391966da07
commit
358fb23d7e
3 changed files with 264 additions and 134 deletions
|
|
@ -12,6 +12,13 @@ import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
except ImportError:
|
||||||
|
fuzz = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pypdfium2 as pdfium
|
import pypdfium2 as pdfium
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -88,6 +95,68 @@ def clean_vat_number(raw_vat: str) -> str:
|
||||||
return re.sub(r'[\s\.]', '', raw_vat).upper()
|
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:
|
class PdfQuoteParser:
|
||||||
"""
|
"""
|
||||||
Parseur local de devis / offres PDF pour la création de bons de commande.
|
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]]:
|
def _match_products(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Rapproche chaque article extrait avec les produits du catalogue Loko.
|
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(
|
active_products = list(
|
||||||
Product.objects.filter(is_active=True).values('id', 'code', 'name', 'sku', 'price', 'unit', 'supplier_reference')
|
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_name = None
|
||||||
matched_code = None
|
matched_code = None
|
||||||
match_type = None
|
match_type = None
|
||||||
|
match_score = 0.0
|
||||||
|
top_matches = []
|
||||||
|
|
||||||
# 1. Correspondance exacte sur référence fournisseur
|
# 1. Correspondance exacte sur référence fournisseur
|
||||||
if ref:
|
if ref:
|
||||||
|
|
@ -499,6 +570,7 @@ class PdfQuoteParser:
|
||||||
matched_name = p['name']
|
matched_name = p['name']
|
||||||
matched_code = p['code']
|
matched_code = p['code']
|
||||||
match_type = 'exact_supplier_ref'
|
match_type = 'exact_supplier_ref'
|
||||||
|
match_score = 100.0
|
||||||
break
|
break
|
||||||
|
|
||||||
# 2. Correspondance exacte sur SKU ou Code interne
|
# 2. Correspondance exacte sur SKU ou Code interne
|
||||||
|
|
@ -509,37 +581,60 @@ class PdfQuoteParser:
|
||||||
matched_name = p['name']
|
matched_name = p['name']
|
||||||
matched_code = p['code']
|
matched_code = p['code']
|
||||||
match_type = 'exact_sku_or_code'
|
match_type = 'exact_sku_or_code'
|
||||||
|
match_score = 100.0
|
||||||
break
|
break
|
||||||
|
|
||||||
# 3. Correspondance exacte sur le nom
|
# 3. Correspondance exacte sur le nom (insensible casse et accents)
|
||||||
if not matched_id and name:
|
if not matched_id and name:
|
||||||
|
_, clean_name = clean_tokens_for_matching(name)
|
||||||
for p in active_products:
|
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_id = p['id']
|
||||||
matched_name = p['name']
|
matched_name = p['name']
|
||||||
matched_code = p['code']
|
matched_code = p['code']
|
||||||
match_type = 'exact_name'
|
match_type = 'exact_name'
|
||||||
|
match_score = 100.0
|
||||||
break
|
break
|
||||||
|
|
||||||
# 4. Correspondance floue (similarité textuelle >= 80%)
|
# 4. Correspondance souple basée sur les mots présents et la similarité
|
||||||
if not matched_id and name:
|
candidates = []
|
||||||
best_ratio = 0.0
|
if name:
|
||||||
best_prod = None
|
|
||||||
for p in active_products:
|
for p in active_products:
|
||||||
ratio = difflib.SequenceMatcher(None, name.lower(), p['name'].lower()).ratio()
|
if matched_id and p['id'] == matched_id:
|
||||||
if ratio > best_ratio and ratio >= 0.80:
|
continue
|
||||||
best_ratio = ratio
|
sim_score, common_words = compute_name_similarity(name, p['name'])
|
||||||
best_prod = p
|
if sim_score >= 35.0:
|
||||||
if best_prod:
|
candidates.append({
|
||||||
matched_id = best_prod['id']
|
'id': p['id'],
|
||||||
matched_name = best_prod['name']
|
'code': p['code'],
|
||||||
matched_code = best_prod['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_type = 'fuzzy_name'
|
||||||
|
match_score = best['score']
|
||||||
|
|
||||||
item['matched_product_id'] = matched_id
|
item['matched_product_id'] = matched_id
|
||||||
item['matched_product_name'] = matched_name
|
item['matched_product_name'] = matched_name
|
||||||
item['matched_product_code'] = matched_code
|
item['matched_product_code'] = matched_code
|
||||||
item['match_type'] = match_type
|
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
|
return items
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
|
|
||||||
{% elif step == 'review' %}
|
{% elif step == 'review' %}
|
||||||
<!-- ÉTAPE 2 : REVUE ET VALIDATION DU BON DE COMMANDE -->
|
<!-- É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 %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="confirm_order">
|
<input type="hidden" name="action" value="confirm_order">
|
||||||
<input type="hidden" name="warehouse_id" value="{{ selected_warehouse.id }}">
|
<input type="hidden" name="warehouse_id" value="{{ selected_warehouse.id }}">
|
||||||
|
|
@ -248,6 +248,15 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- TABLEAU DES ARTICLES EXTRAITS -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<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">
|
<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)">
|
<input type="checkbox" id="master-select-checkbox" class="form-check-input" checked onchange="toggleSelectAllItems(this.checked)">
|
||||||
</th>
|
</th>
|
||||||
<th style="min-width: 250px;">{% translate "Référence & Désignation fournisseur" %}</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: 100px;" class="text-end">{% translate "Quantité" %}</th>
|
||||||
<th style="width: 120px;" class="text-end">{% translate "Prix net HT" %}</th>
|
<th style="width: 120px;" class="text-end">{% translate "Prix net HT" %}</th>
|
||||||
<th style="width: 90px;" class="text-end">{% translate "TVA" %}</th>
|
<th style="width: 90px;" class="text-end">{% translate "TVA" %}</th>
|
||||||
|
|
@ -287,10 +296,17 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in parse_result.items %}
|
{% for item in parse_result.items %}
|
||||||
{% with idx=forloop.counter0 %}
|
{% 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 -->
|
<!-- Checkbox inclusion -->
|
||||||
<td class="text-center">
|
<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 }}_ref" value="{{ item.reference }}">
|
||||||
<input type="hidden" name="item_{{ idx }}_name" value="{{ item.name }}">
|
<input type="hidden" name="item_{{ idx }}_name" value="{{ item.name }}">
|
||||||
<input type="hidden" name="item_{{ idx }}_qty" value="{{ item.quantity }}">
|
<input type="hidden" name="item_{{ idx }}_qty" value="{{ item.quantity }}">
|
||||||
|
|
@ -310,67 +326,75 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Rapprochement Catalogue Produit -->
|
<!-- Rapprochement Catalogue Produit (Exclusif aux produits existants) -->
|
||||||
<td>
|
<td>
|
||||||
{% if not item.is_new %}
|
<div class="mb-1 d-flex align-items-center gap-1">
|
||||||
<!-- Produit existant reconnu -->
|
{% if item.matched_product_id %}
|
||||||
<div class="d-flex align-items-center mb-1">
|
{% if item.match_type == 'exact_supplier_ref' %}
|
||||||
<span class="badge bg-success text-white me-2">
|
<span class="badge bg-success text-white">
|
||||||
<i class="bi bi-check-circle"></i> {% translate "Produit trouvé" %}
|
<i class="bi bi-check-circle me-1"></i>{% translate "Réf. fournisseur exacte" %}
|
||||||
|
</span>
|
||||||
|
{% 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>
|
</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 %}
|
{% else %}
|
||||||
<!-- Produit non trouvé : validation magasinier -->
|
<span class="badge bg-info text-dark">
|
||||||
<div class="p-2 rounded border bg-white">
|
<i class="bi bi-stars me-1"></i>{% translate "Similarité trouvée" %} ({{ item.match_score|floatformat:0 }}%)
|
||||||
<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" %}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">{% translate "Non répertorié dans le catalogue" %}</span>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 %}
|
{% 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>
|
||||||
|
|
||||||
|
<!-- 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 %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
<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>
|
</td>
|
||||||
|
|
||||||
<!-- Quantité -->
|
<!-- Quantité -->
|
||||||
|
|
@ -497,19 +521,53 @@ function toggleSelectAllItems(checked) {
|
||||||
updateTotals();
|
updateTotals();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleProductRowAction(idx) {
|
function handleProductSelection(idx) {
|
||||||
const isNew = document.getElementById('action_new_' + idx).checked;
|
const select = document.getElementById('item_' + idx + '_product_id');
|
||||||
const newFields = document.getElementById('new_prod_fields_' + idx);
|
const cb = document.getElementById('item_' + idx + '_include');
|
||||||
const existingField = document.getElementById('existing_prod_field_' + idx);
|
const row = document.getElementById('item-row-' + idx);
|
||||||
if (newFields && existingField) {
|
const help = document.getElementById('item_' + idx + '_help');
|
||||||
if (isNew) {
|
|
||||||
newFields.classList.remove('d-none');
|
if (select && select.value && select.value.trim() !== '') {
|
||||||
existingField.classList.add('d-none');
|
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 {
|
} else {
|
||||||
newFields.classList.add('d-none');
|
if (cb) cb.checked = false;
|
||||||
existingField.classList.remove('d-none');
|
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() {
|
function updateTotals() {
|
||||||
|
|
|
||||||
|
|
@ -1713,10 +1713,8 @@ def purchase_order_from_pdf(request):
|
||||||
status=order_status,
|
status=order_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
new_products_created = []
|
|
||||||
created_items_count = 0
|
created_items_count = 0
|
||||||
base_product_num = _next_product_number()
|
unassociated_items = []
|
||||||
new_prod_offset = 0
|
|
||||||
|
|
||||||
for i in range(item_count):
|
for i in range(item_count):
|
||||||
# Vérifier si la ligne est sélectionnée
|
# Vérifier si la ligne est sélectionnée
|
||||||
|
|
@ -1728,6 +1726,14 @@ def purchase_order_from_pdf(request):
|
||||||
if not name:
|
if not name:
|
||||||
name = ref or _("Article sans nom")
|
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:
|
try:
|
||||||
qty = int(float(request.POST.get(f'item_{i}_qty', 1)))
|
qty = int(float(request.POST.get(f'item_{i}_qty', 1)))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -1735,9 +1741,6 @@ def purchase_order_from_pdf(request):
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
qty = 1
|
qty = 1
|
||||||
|
|
||||||
raw_unit = request.POST.get(f'item_{i}_unit', Product.UNIT_PC)
|
|
||||||
unit = normalize_unit(raw_unit)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unit_price = Decimal(str(request.POST.get(f'item_{i}_price', '0')).replace(',', '.'))
|
unit_price = Decimal(str(request.POST.get(f'item_{i}_price', '0')).replace(',', '.'))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1753,40 +1756,6 @@ def purchase_order_from_pdf(request):
|
||||||
if not location:
|
if not location:
|
||||||
location = warehouse.locations.filter(is_default=True).first() or warehouse.locations.first()
|
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
|
# Créer la ligne de commande
|
||||||
PurchaseOrderItem.objects.create(
|
PurchaseOrderItem.objects.create(
|
||||||
purchase_order=order,
|
purchase_order=order,
|
||||||
|
|
@ -1798,6 +1767,14 @@ def purchase_order_from_pdf(request):
|
||||||
)
|
)
|
||||||
created_items_count += 1
|
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
|
# Attacher le fichier PDF original comme document justificatif
|
||||||
if temp_pdf_path and os.path.exists(temp_pdf_path):
|
if temp_pdf_path and os.path.exists(temp_pdf_path):
|
||||||
try:
|
try:
|
||||||
|
|
@ -1820,13 +1797,13 @@ def purchase_order_from_pdf(request):
|
||||||
% {'code': order.code, 'items': created_items_count}
|
% {'code': order.code, 'items': created_items_count}
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_products_created:
|
if unassociated_items:
|
||||||
messages.warning(
|
messages.info(
|
||||||
request,
|
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),
|
'count': len(unassociated_items),
|
||||||
'prods': ', '.join(p.name for p in new_products_created[:3]) + ('...' if len(new_products_created) > 3 else '')
|
'names': ', '.join(unassociated_items[:3]) + ('...' if len(unassociated_items) > 3 else '')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue