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

View file

@ -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() {

View file

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