feat: add product validation workflow, supervisor notifications, and PDF purchase order parser

This commit is contained in:
kdeterme 2026-10-01 13:51:27 +02:00
parent 65624c343a
commit de6828221b
10 changed files with 1683 additions and 21 deletions

View file

@ -0,0 +1,46 @@
# Generated by Django 6.0.7 on 2026-10-01 08:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0005_alter_preparationorder_order_number'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='product',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_products', to=settings.AUTH_USER_MODEL, verbose_name='Créé par'),
),
migrations.AddField(
model_name='product',
name='status',
field=models.CharField(choices=[('validated', 'Validé'), ('pending_validation', 'En attente de validation superviseur'), ('rejected', 'Rejeté')], default='validated', help_text='Statut de validation par le superviseur', max_length=30, verbose_name='Statut'),
),
migrations.AddField(
model_name='product',
name='supplier_reference',
field=models.CharField(blank=True, help_text="Référence de l'article chez le fournisseur", max_length=100, null=True, verbose_name='Référence fournisseur'),
),
migrations.AddField(
model_name='product',
name='validated_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Validé le'),
),
migrations.AddField(
model_name='product',
name='validated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_products', to=settings.AUTH_USER_MODEL, verbose_name='Validé par'),
),
migrations.AddField(
model_name='product',
name='validation_comment',
field=models.TextField(blank=True, null=True, verbose_name='Commentaire de validation'),
),
]

View file

@ -217,6 +217,80 @@ class Product(models.Model):
help_text=_("Emplacements dans lesquels ce produit est autorisé à être stocké (vide = tous autorisés)."),
)
STATUS_VALIDATED = 'validated'
STATUS_PENDING_VALIDATION = 'pending_validation'
STATUS_REJECTED = 'rejected'
STATUS_CHOICES = [
(STATUS_VALIDATED, _('Validé')),
(STATUS_PENDING_VALIDATION, _('En attente de validation superviseur')),
(STATUS_REJECTED, _('Rejeté')),
]
status = models.CharField(
max_length=30,
choices=STATUS_CHOICES,
default=STATUS_VALIDATED,
verbose_name=_("Statut"),
help_text=_("Statut de validation par le superviseur"),
)
supplier_reference = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Référence fournisseur"),
help_text=_("Référence de l'article chez le fournisseur"),
)
created_by = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_products",
verbose_name=_("Créé par"),
)
validated_by = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="validated_products",
verbose_name=_("Validé par"),
)
validated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Validé le"),
)
validation_comment = models.TextField(
blank=True,
null=True,
verbose_name=_("Commentaire de validation"),
)
@property
def is_pending_validation(self):
return self.status == self.STATUS_PENDING_VALIDATION
def validate_product(self, user, comment=""):
"""Valide le produit par un superviseur."""
self.status = self.STATUS_VALIDATED
self.validated_by = user
self.validated_at = timezone.now()
if comment:
self.validation_comment = comment
self.save(update_fields=["status", "validated_by", "validated_at", "validation_comment"])
def reject_product(self, user, comment=""):
"""Rejette le produit par un superviseur."""
self.status = self.STATUS_REJECTED
self.is_active = False
self.validated_by = user
self.validated_at = timezone.now()
if comment:
self.validation_comment = comment
self.save(update_fields=["status", "is_active", "validated_by", "validated_at", "validation_comment"])
def __str__(self):
return self.name
@ -294,6 +368,54 @@ class Product(models.Model):
).exists()
def notify_supervisors_pending_product(product, created_by=None):
"""Notifie les superviseurs / admins qu'un nouveau produit attend validation."""
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.translation import gettext as _
from notifications.services import notify_users
from stock.permissions import user_can_manage_products
supervisors = [
u for u in User.objects.filter(is_active=True).select_related('config')
if user_can_manage_products(u) and u != created_by
]
if not supervisors:
return
action_url = reverse("stock:product_detail", args=[product.pk])
title = _("Nouveau produit à valider : %(name)s") % {"name": product.name}
creator_name = (
created_by.get_full_name() or created_by.username
if created_by
else _("Magasinier")
)
content = _(
"Le magasinier %(creator)s a créé le produit '%(name)s' (Réf: %(ref)s) "
"via un bon de commande. Ce produit nécessite la validation d'un superviseur."
) % {
"creator": creator_name,
"name": product.name,
"ref": product.supplier_reference or product.code,
}
try:
notify_users(
recipients=supervisors,
title=title,
content=content,
notification_type="task",
action_url=action_url,
action_label=_("Examiner le produit"),
source_app="stock",
sender=created_by,
is_system=True,
is_single_claim=True,
)
except Exception:
import logging
logging.getLogger(__name__).exception("Failed to notify supervisors of pending product")
class Stock(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stocks")
location = models.ForeignKey(

533
loko/stock/pdf_parser.py Normal file
View file

@ -0,0 +1,533 @@
"""
Service d'analyse locale de devis / offres au format PDF.
Utilise pypdfium2 pour l'extraction vectorielle directe (100% exacte, rapide)
et bascule sur RapidOCR (local CPU) en cas de PDF scanné / image.
"""
import os
import re
import tempfile
import difflib
import logging
from io import BytesIO
from typing import Dict, List, Any, Optional, Tuple
import pypdfium2 as pdfium
import numpy as np
from django.db.models import Q
from .models import Supplier, Product, WarehouseLocation
logger = logging.getLogger(__name__)
UNIT_MAPPING = {
'pièce': Product.UNIT_PC,
'piece': Product.UNIT_PC,
'pièces': Product.UNIT_PC,
'pieces': Product.UNIT_PC,
'pcs': Product.UNIT_PC,
'pc': Product.UNIT_PC,
'stk': Product.UNIT_PC,
'un': Product.UNIT_PC,
'unité': Product.UNIT_PC,
'unite': Product.UNIT_PC,
'boîte': Product.UNIT_PC,
'boite': Product.UNIT_PC,
'bte': Product.UNIT_PC,
'sac': Product.UNIT_PC,
'sacs': Product.UNIT_PC,
'colis': Product.UNIT_PC,
'paquet': Product.UNIT_PC,
'kg': Product.UNIT_KG,
'kilogramme': Product.UNIT_KG,
'kilogrammes': Product.UNIT_KG,
'kilo': Product.UNIT_KG,
'g': Product.UNIT_G,
'gramme': Product.UNIT_G,
'grammes': Product.UNIT_G,
't': Product.UNIT_T,
'tonne': Product.UNIT_T,
'tonnes': Product.UNIT_T,
'l': Product.UNIT_L,
'litre': Product.UNIT_L,
'litres': Product.UNIT_L,
'ltr': Product.UNIT_L,
'm': Product.UNIT_M,
'mètre': Product.UNIT_M,
'metre': Product.UNIT_M,
'mètres': Product.UNIT_M,
'metres': Product.UNIT_M,
'ml': Product.UNIT_M,
'm2': Product.UNIT_M2,
'm²': Product.UNIT_M2,
'm3': Product.UNIT_M3,
'm³': Product.UNIT_M3,
}
def normalize_unit(raw_unit: str) -> str:
"""Normalise l'unité textuelle vers les choix reconnus par Loko Product."""
if not raw_unit:
return Product.UNIT_PC
clean = raw_unit.strip().lower()
return UNIT_MAPPING.get(clean, Product.UNIT_PC)
def clean_vat_number(raw_vat: str) -> str:
"""Nettoie un numéro de TVA / entreprise pour comparaison (ex: BE 0447.966.784 -> BE0447966784)."""
if not raw_vat:
return ""
return re.sub(r'[\s\.]', '', raw_vat).upper()
class PdfQuoteParser:
"""
Parseur local de devis / offres PDF pour la création de bons de commande.
"""
def __init__(self, file_or_path):
self.file_or_path = file_or_path
self._temp_path = None
self.is_ocr = False
def _get_document(self) -> Tuple[pdfium.PdfDocument, str]:
"""Ouvre le document PDF via pypdfium2."""
if isinstance(self.file_or_path, str):
return pdfium.PdfDocument(self.file_or_path), self.file_or_path
elif hasattr(self.file_or_path, 'temporary_file_path'):
path = self.file_or_path.temporary_file_path()
return pdfium.PdfDocument(path), path
elif hasattr(self.file_or_path, 'read'):
content = self.file_or_path.read()
if hasattr(self.file_or_path, 'seek'):
self.file_or_path.seek(0)
fd, tmp_path = tempfile.mkstemp(suffix='.pdf')
with os.fdopen(fd, 'wb') as f:
f.write(content)
self._temp_path = tmp_path
return pdfium.PdfDocument(tmp_path), tmp_path
else:
raise ValueError("Type de fichier non supporté pour l'analyse PDF.")
def close(self):
"""Nettoie les fichiers temporaires si besoin."""
if self._temp_path and os.path.exists(self._temp_path):
try:
os.remove(self._temp_path)
except OSError:
pass
def parse(self) -> Dict[str, Any]:
"""
Extrait les métadonnées et la liste des articles d'un devis.
Effectue le rapprochement automatique avec le stock existant (fournisseur et produits).
"""
doc = None
try:
doc, path = self._get_document()
total_pages = len(doc)
if total_pages == 0:
raise ValueError("Le document PDF ne contient aucune page.")
# 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.")
self.is_ocr = True
return self._parse_with_ocr(doc)
else:
return self._parse_vector(doc)
finally:
if doc:
try:
doc.close()
except Exception:
pass
self.close()
def _parse_vector(self, doc: pdfium.PdfDocument) -> Dict[str, Any]:
"""Analyse un PDF avec couche texte vectorielle (recherche spatiale précise)."""
full_text = ""
for page in doc:
full_text += page.get_textpage().get_text_range() + "\n"
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,
'metadata': metadata,
'items': enriched_items,
'matched_supplier': matched_supplier,
'total_items': len(enriched_items),
}
def _parse_with_ocr(self, doc: pdfium.PdfDocument) -> Dict[str, Any]:
"""Analyse un PDF scanné via RapidOCR."""
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.")
full_text_lines = []
ocr_pages_data = []
for p_idx, page in enumerate(doc):
pil_img = page.render(scale=2.0).to_pil()
ocr_res, _ = ocr(np.array(pil_img))
if not ocr_res:
continue
rects = []
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)
# 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)
ocr_pages_data.append(page_lines)
for l in page_lines:
full_text_lines.append(' '.join(item[4] for item in l))
full_text = "\n".join(full_text_lines)
metadata = self._extract_metadata(full_text, doc)
items = self._extract_items_from_clustered_lines(ocr_pages_data)
matched_supplier = self._match_supplier(metadata)
enriched_items = self._match_products(items)
return {
'is_ocr': True,
'metadata': metadata,
'items': enriched_items,
'matched_supplier': matched_supplier,
'total_items': len(enriched_items),
}
def _cluster_lines(self, rects: List[Tuple[float, float, float, float, str]], y_tol: float = 4.5) -> List[List[Tuple]]:
"""Regroupe les fragments de texte spatialement alignés sur la même ligne horizontale."""
lines = []
current_line = []
current_y = None
for r in rects:
y = (r[1] + r[3]) / 2.0
if current_y is None or abs(current_y - y) <= y_tol:
current_line.append(r)
current_y = y
else:
current_line.sort(key=lambda x: x[0])
lines.append(current_line)
current_line = [r]
current_y = y
if current_line:
current_line.sort(key=lambda x: x[0])
lines.append(current_line)
return lines
def _extract_metadata(self, full_text: str, doc: pdfium.PdfDocument) -> Dict[str, Any]:
"""Extrait les métadonnées de l'en-tête (Fournisseur, Devis n°, Date, Totaux)."""
meta = {
'supplier_name': '',
'supplier_vat': '',
'supplier_email': '',
'supplier_phone': '',
'supplier_address': '',
'quote_number': '',
'date': '',
'total_net': None,
'total_gross': None,
'currency': 'EUR',
'customer_name': '',
}
# 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)
if vat_m:
meta['supplier_vat'] = vat_m.group(1).strip()
# 2. Email
email_m = re.search(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', full_text)
if email_m:
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)
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)
if quote_m:
meta['quote_number'] = quote_m.group(0).strip().rstrip('/')
else:
bon_m = re.search(r'Bon\s+n[°o]\s*[\r\n\s]*([A-Z0-9\s\/-]{4,25})', full_text, re.I)
if bon_m:
meta['quote_number'] = bon_m.group(1).strip().rstrip('/')
# 5. Date
date_m = re.search(r'\b(\d{2}[/\.-]\d{2}[/\.-]\d{4})\b', full_text)
if date_m:
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)
else:
meta['supplier_name'] = "Fournisseur inconnu"
# 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)
if addr_m:
meta['supplier_address'] = addr_m.group(1).strip()
# 7. Totaux
tot_ht = re.search(r'Total\s+hors\s*(?:tva)?[\r\n\s]*([0-9\.,]+)', full_text, re.I)
if tot_ht:
try:
meta['total_net'] = float(tot_ht.group(1).replace('.', '').replace(',', '.'))
except ValueError:
pass
tot_ttc = re.search(r'Total\s+(?:EUR|TTC)[\r\n\s]*([0-9\.,]+)', full_text, re.I)
if tot_ttc:
try:
meta['total_gross'] = float(tot_ttc.group(1).replace('.', '').replace(',', '.'))
except ValueError:
pass
return meta
def _extract_items_vector(self, doc: pdfium.PdfDocument) -> List[Dict[str, Any]]:
"""Extrait les lignes d'articles via l'analyse géométrique vectorielle de chaque page."""
pages_data = []
for p_idx, page in enumerate(doc):
tp = page.get_textpage()
rects = []
for i in range(tp.count_rects()):
r = tp.get_rect(i)
txt = tp.get_text_bounded(*r).strip()
if txt:
rects.append((r[0], r[1], r[2], r[3], txt))
rects.sort(key=lambda x: -x[3])
# Délimiter verticalement la zone de tableau sur la page
table_top = None
table_bottom = 50.0
for r in rects:
if any(w in r[4] for w in ['Article', 'Quantité', 'Description']):
table_top = r[1] - 4.0
break
for r in rects:
if any(k in r[4] for k in ['Total hors', 'Total HT', 'Meilleures salutations', "D'avance je vous remercie"]):
if r[3] > table_bottom and (not table_top or r[3] < table_top):
table_bottom = max(table_bottom, r[3] + 4.0)
table_rects = [
r for r in rects
if (table_top is None or r[3] < table_top) and r[1] > table_bottom
]
lines = self._cluster_lines(table_rects)
pages_data.append(lines)
return self._extract_items_from_clustered_lines(pages_data)
def _extract_items_from_clustered_lines(self, pages_lines: List[List[List[Tuple]]]) -> List[Dict[str, Any]]:
"""Extrait les dictionnaires d'articles depuis les lignes regroupées."""
items = []
current_item = None
# Modèle de ligne produit :
# 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+%)'
)
stop_keywords = [
'Total hors', 'Total EUR', 'Total TTC', 'Total HT',
'Meilleures salutations', 'Dominique Vandevelde',
"D'avance je vous remercie", 'TVA - BTW', 'SERVICE PUBLIC',
'ITS - tools', 'N°. Article', 'Concerne', 'A l\'attention'
]
for page_lines in pages_lines:
for line in page_lines:
line_str = ' | '.join(item[4] for item in line)
if any(kw in line_str for kw in stop_keywords):
current_item = None
continue
m = row_pattern.search(line_str)
if m:
desc_part = m.group(1).strip()
qty_val = float(m.group(2).replace(',', '.'))
raw_unit = m.group(3).strip()
gross_price = float(m.group(4).replace(',', '.'))
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('%', ''))
# Séparer la référence fournisseur de la description
parts = desc_part.split(' | ')
if len(parts) >= 2:
ref = parts[0].strip()
name = ' '.join(parts[1:]).strip()
else:
tokens = desc_part.split(None, 1)
if len(tokens) == 2 and len(tokens[0]) <= 25 and any(c.isdigit() for c in tokens[0]):
ref = tokens[0]
name = tokens[1]
else:
ref = ''
name = desc_part
unit_price_net = round(total_val / qty_val, 2) if qty_val > 0 else gross_price
unit_code = normalize_unit(raw_unit)
current_item = {
'reference': ref,
'name': name,
'quantity': int(qty_val) if qty_val.is_integer() else qty_val,
'unit': unit_code,
'raw_unit': raw_unit,
'gross_unit_price': gross_price,
'discount': discount_str,
'unit_price': unit_price_net,
'total_amount': total_val,
'vat_rate': vat_val,
}
items.append(current_item)
elif current_item:
# Ligne de continuation de la description
if not line_str.startswith('http') and not line_str.startswith('https') and not '---' in line_str:
continuation = line_str.replace(' | ', ' ').strip()
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)
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
def _match_supplier(self, metadata: Dict[str, Any]) -> Optional[Supplier]:
"""Tente de retrouver un fournisseur existant dans la base de données."""
vat = metadata.get('supplier_vat')
if vat:
clean_vat = clean_vat_number(vat)
# Recherche par numéro d'entreprise / TVA
for s in Supplier.objects.filter(is_active=True):
if s.enterprise_number and clean_vat_number(s.enterprise_number) == clean_vat:
return s
email = metadata.get('supplier_email')
if email:
s = Supplier.objects.filter(is_active=True, contact_email__iexact=email).first()
if s:
return s
name = metadata.get('supplier_name')
if name and name != "Fournisseur inconnu":
s = Supplier.objects.filter(is_active=True, name__icontains=name[:20]).first()
if s:
return s
return None
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.
"""
active_products = list(
Product.objects.filter(is_active=True).values('id', 'code', 'name', 'sku', 'price', 'unit', 'supplier_reference')
)
for item in items:
ref = item.get('reference', '').strip()
name = item.get('name', '').strip()
matched_id = None
matched_name = None
matched_code = None
match_type = None
# 1. Correspondance exacte sur référence fournisseur
if ref:
for p in active_products:
if p.get('supplier_reference') and p['supplier_reference'].strip().lower() == ref.lower():
matched_id = p['id']
matched_name = p['name']
matched_code = p['code']
match_type = 'exact_supplier_ref'
break
# 2. Correspondance exacte sur SKU ou Code interne
if not matched_id and ref:
for p in active_products:
if p['sku'].strip().lower() == ref.lower() or p['code'].strip().lower() == ref.lower():
matched_id = p['id']
matched_name = p['name']
matched_code = p['code']
match_type = 'exact_sku_or_code'
break
# 3. Correspondance exacte sur le nom
if not matched_id and name:
for p in active_products:
if p['name'].strip().lower() == name.lower():
matched_id = p['id']
matched_name = p['name']
matched_code = p['code']
match_type = 'exact_name'
break
# 4. Correspondance floue (similarité textuelle >= 80%)
if not matched_id and name:
best_ratio = 0.0
best_prod = None
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'
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)
return items

View file

@ -321,13 +321,15 @@ function renderProductsTable(data) {
const threshold = product.threshold || 0;
const stockColor = isInactive ? 'text-muted' : (totalStock < threshold ? 'text-danger fw-bold' : 'text-success fw-bold');
// Icone et badge pour produits inactifs
// Icone et badge pour produits inactifs ou en attente de validation
const icon = isInactive ? `<i class="bi bi-eye-slash text-muted me-1" title="${gettext('Inactif')}"></i>` : '';
const nameSuffix = isInactive ? ` <span class="badge bg-secondary text-white ms-2" style="font-size: 0.7rem; font-style: normal; font-weight: normal; vertical-align: middle;">${gettext('Inactif')}</span>` : '';
const isPending = product.status === 'pending_validation' || product.is_pending_validation;
const pendingBadge = isPending ? ` <span class="badge bg-warning text-dark ms-2" style="font-size: 0.7rem; font-style: normal; font-weight: 500; vertical-align: middle;" title="${gettext('En attente de validation superviseur')}"><i class="bi bi-clock-history me-1"></i>${gettext('À valider')}</span>` : '';
row.innerHTML = `
<td><a href="${productUrl}" class="text-decoration-none ${isInactive ? 'text-muted text-decoration-line-through' : ''}">${product.code || '-'}</a></td>
<td style="min-width: 200px;">${icon}${product.name || '-'}${nameSuffix}</td>
<td style="min-width: 200px;">${icon}${product.name || '-'}${nameSuffix}${pendingBadge}</td>
<td>${product.sku || '-'}</td>
<td>${product.category || '-'}</td>
<td class="text-end ${stockColor}">${totalStock}</td>
@ -450,38 +452,46 @@ document.addEventListener('click', (e) => {
}
});
// Récupérer les paramètres de filtres pour les produits
function getProductsFilterParams() {
const category = $('#products-category-filter').val();
const search = $('#products-search').val();
const status = $('#products-status-filter').val();
const include_inactive = $('#products-show-inactive').is(':checked');
const params = {};
if (category) params.category = category;
if (search) params.search = search;
if (status) params.status = status;
if (include_inactive) params.include_inactive = 'true';
return params;
}
// Charger les produits au clic sur l'onglet
$('#products-table-tab').on('click', function () {
if (productsData.length === 0) {
const category = $('#products-category-filter').val();
const search = $('#products-search').val();
const include_inactive = $('#products-show-inactive').is(':checked');
loadProducts({ category, search, include_inactive });
loadProducts(getProductsFilterParams());
}
});
// Filtrage automatique au changement de catégorie
$('#products-category-filter').on('change', function() {
const category = $(this).val();
const search = $('#products-search').val();
const include_inactive = $('#products-show-inactive').is(':checked');
loadProducts({ category, search, include_inactive });
loadProducts(getProductsFilterParams());
});
// Filtrage automatique par statut
$('#products-status-filter').on('change', function() {
loadProducts(getProductsFilterParams());
});
// Filtrage automatique au changement de l'état "Afficher les inactifs"
$('#products-show-inactive').on('change', function() {
const include_inactive = $(this).is(':checked');
const category = $('#products-category-filter').val();
const search = $('#products-search').val();
loadProducts({ category, search, include_inactive });
loadProducts(getProductsFilterParams());
});
// Recherche automatique après 2 caractères
let searchTimeout;
$('#products-search').on('input', function() {
const search = $(this).val();
const category = $('#products-category-filter').val();
const include_inactive = $('#products-show-inactive').is(':checked');
// Annuler le timeout précédent
clearTimeout(searchTimeout);
@ -489,7 +499,7 @@ $('#products-search').on('input', function() {
// Charger si 2+ caractères ou si vide (pour réinitialiser)
if (search.length >= 2 || search.length === 0) {
searchTimeout = setTimeout(() => {
loadProducts({ category, search, include_inactive });
loadProducts(getProductsFilterParams());
}, 300); // Délai de 300ms pour éviter trop de requêtes
}
});

View file

@ -13,7 +13,11 @@
<span class="mx-2">{{ product.code }}</span>
<span class="mx-2">-</span>
<span class="mx-2">{{ product.name }}</span>
{% if not product.is_active %}
{% if product.is_pending_validation %}
<span class="badge bg-warning text-dark ms-2"><i class="bi bi-clock-history me-1"></i>{% translate "En attente de validation superviseur" %}</span>
{% elif product.status == 'rejected' %}
<span class="badge bg-danger ms-2"><i class="bi bi-x-circle me-1"></i>{% translate "Rejeté" %}</span>
{% elif not product.is_active %}
<span class="badge bg-danger ms-2"><i class="bi bi-x-circle me-1"></i>{% translate "Inactif" %}</span>
{% endif %}
</h3>
@ -35,6 +39,83 @@
</div>
</div>
{% if product.is_pending_validation %}
<div class="alert alert-warning border-0 shadow-sm d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill fs-3 text-warning me-3"></i>
<div>
<h6 class="fw-bold mb-1">{% translate "Produit en attente de validation superviseur" %}</h6>
<p class="mb-0 small text-muted">
{% if product.created_by %}
{% blocktranslate with creator=product.created_by.get_full_name|default:product.created_by.username %}
Ce produit a été créé lors d'une commande magasinier par <strong>{{ creator }}</strong> et doit être validé par un superviseur.
{% endblocktranslate %}
{% else %}
{% translate "Ce produit a été créé lors d'une commande magasinier et doit être validé par un superviseur." %}
{% endif %}
{% if product.supplier_reference %}
<span class="ms-2"><strong>{% translate "Réf. fournisseur :" %}</strong> {{ product.supplier_reference }}</span>
{% endif %}
</p>
</div>
</div>
{% if can_validate %}
<div class="d-flex gap-2">
<form method="post" action="{% url 'stock:product_validate' product.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i> {% translate "Valider le produit" %}
</button>
</form>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#rejectProductModal">
<i class="bi bi-x-circle me-1"></i> {% translate "Rejeter" %}
</button>
</div>
{% endif %}
</div>
{% elif product.status == 'rejected' %}
<div class="alert alert-danger border-0 shadow-sm d-flex align-items-center mb-4">
<i class="bi bi-x-octagon-fill fs-3 text-danger me-3"></i>
<div>
<h6 class="fw-bold mb-1">{% translate "Produit rejeté" %}</h6>
<p class="mb-0 small">
{% translate "Ce produit a été rejeté par un superviseur et est désactivé." %}
{% if product.validation_comment %}<br><strong>{% translate "Motif :" %}</strong> {{ product.validation_comment }}{% endif %}
</p>
</div>
</div>
{% endif %}
{% if can_validate %}
<!-- Modal rejet produit -->
<div class="modal fade" id="rejectProductModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:product_reject' product.pk %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title text-danger">
<i class="bi bi-x-circle me-1"></i> {% translate "Rejeter ce produit" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous sûr de vouloir rejeter ce produit ? Il sera marqué comme rejeté et désactivé." %}</p>
<div class="mb-3">
<label for="id_reject_comment" class="form-label small fw-semibold">{% translate "Commentaire / Motif du rejet (optionnel)" %}</label>
<textarea name="comment" id="id_reject_comment" class="form-control" rows="3" placeholder="{% translate 'Précisez la raison du rejet...' %}"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-danger">{% translate "Confirmer le rejet" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% if is_stock_manager %}
<!-- Modal suppression -->
<div class="modal fade" id="deleteProductModal" tabindex="-1" aria-hidden="true">

View file

@ -0,0 +1,541 @@
{% extends "base.html" %}
{% load i18n static %}
{% block title %}{% translate "Créer un bon de commande depuis une offre PDF" %} — Loko{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<!-- Fil d'Ariane & Titre -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="{% url 'stock:stock_purchases' %}">{% translate "Achats" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Import d'offre PDF" %}</li>
</ol>
</nav>
<h1 class="h3 d-flex align-items-center mb-0">
<i class="bi bi-file-earmark-pdf text-danger me-2"></i>
{% translate "Créer un bon de commande depuis une offre PDF" %}
</h1>
</div>
<div>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% translate "Retour aux achats" %}
</a>
</div>
</div>
{% if step == 'upload' %}
<!-- ÉTAPE 1 : TÉLÉVERSEMENT DU FICHIER PDF -->
<div class="row justify-content-center mt-4">
<div class="col-lg-8 col-xl-7">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<div class="mb-3">
<span class="d-inline-flex align-items-center justify-content-center bg-primary bg-opacity-10 text-primary rounded-circle" style="width: 80px; height: 80px;">
<i class="bi bi-cloud-arrow-up fs-1"></i>
</span>
</div>
<h4 class="fw-bold">{% translate "Importer une offre ou un devis fournisseur" %}</h4>
<p class="text-muted">
{% 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." %}
</p>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="parse_pdf">
<!-- Sélection de l'entrepôt -->
<div class="mb-4">
<label for="id_warehouse" class="form-label fw-semibold">
<i class="bi bi-building me-1"></i> {% translate "Entrepôt de destination" %} <span class="text-danger">*</span>
</label>
<select name="warehouse" id="id_warehouse" class="form-select form-select-lg" required>
{% for w in warehouses %}
<option value="{{ w.id }}" {% if forloop.first %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
<div class="form-text">
{% translate "Les articles de la commande seront associés par défaut aux emplacements de cet entrepôt." %}
</div>
</div>
<!-- Zone de dépôt PDF -->
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="bi bi-file-pdf me-1"></i> {% translate "Fichier PDF de l'offre" %} <span class="text-danger">*</span>
</label>
<div class="border border-2 border-dashed rounded-3 p-4 text-center bg-light" id="drop-zone" style="cursor: pointer;">
<i class="bi bi-file-earmark-arrow-up display-5 text-secondary d-block mb-2"></i>
<span class="fw-medium text-dark d-block mb-1">{% translate "Cliquez ou glissez-déposez le devis PDF ici" %}</span>
<span class="text-muted small">{% translate "Formats acceptés : PDF numérique ou scanné (OCR automatique)" %}</span>
<input type="file" name="pdf_file" id="id_pdf_file" class="d-none" accept=".pdf,application/pdf" required>
<div id="selected-file-name" class="mt-3 fw-bold text-primary d-none">
<i class="bi bi-file-earmark-check me-1"></i> <span class="file-text"></span>
</div>
</div>
</div>
<!-- Encadré explicatif -->
<div class="alert alert-info border-0 d-flex align-items-start mb-4">
<i class="bi bi-info-circle-fill fs-5 me-2 mt-1 flex-shrink-0"></i>
<div class="small">
<strong>{% translate "Règle de gestion des articles :" %}</strong><br>
{% 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 <strong>statut spécial 'En attente de validation superviseur'</strong>." %}
</div>
</div>
<!-- Bouton de soumission -->
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg" id="submit-parse-btn">
<i class="bi bi-cpu me-2"></i> {% translate "Analyser le document" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% elif step == 'review' %}
<!-- ÉTAPE 2 : REVUE ET VALIDATION DU BON DE COMMANDE -->
<form method="post" id="confirm-order-form">
{% csrf_token %}
<input type="hidden" name="action" value="confirm_order">
<input type="hidden" name="warehouse_id" value="{{ selected_warehouse.id }}">
<input type="hidden" name="temp_pdf_path" value="{{ temp_pdf_path }}">
<input type="hidden" name="orig_pdf_name" value="{{ orig_pdf_name }}">
<input type="hidden" name="item_count" value="{{ parse_result.items|length }}">
<!-- Cartes d'en-tête (Fournisseur & Commande) -->
<div class="row g-3 mb-4">
<!-- CARTE FOURNISSEUR -->
<div class="col-lg-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<span class="fw-bold text-dark"><i class="bi bi-building text-primary me-2"></i>{% translate "Fournisseur" %}</span>
{% if parse_result.matched_supplier %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% translate "Fournisseur identifié" %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle">
<i class="bi bi-exclamation-circle me-1"></i>{% translate "Nouveau fournisseur détecté" %}
</span>
{% endif %}
</div>
<div class="card-body">
{% if parse_result.matched_supplier %}
<div class="mb-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_existing" value="existing" checked>
<label class="form-check-label fw-bold" for="sup_choice_existing">
{{ parse_result.matched_supplier.name }}
</label>
</div>
<div class="text-muted small ps-4">
{% if parse_result.matched_supplier.enterprise_number %}
<div><strong>{% translate "TVA / N° Entr." %} :</strong> {{ parse_result.matched_supplier.enterprise_number }}</div>
{% endif %}
{% if parse_result.matched_supplier.contact_email %}
<div><strong>{% translate "Email" %} :</strong> {{ parse_result.matched_supplier.contact_email }}</div>
{% endif %}
</div>
<input type="hidden" name="supplier_id" value="{{ parse_result.matched_supplier.id }}">
</div>
<div class="border-top pt-2">
<a class="small text-muted text-decoration-none" data-bs-toggle="collapse" href="#changeSupplierCollapse" role="button">
<i class="bi bi-pencil me-1"></i>{% translate "Changer de fournisseur" %}
</a>
<div class="collapse mt-2" id="changeSupplierCollapse">
<select class="form-select form-select-sm" onchange="document.querySelector('input[name=supplier_id]').value = this.value;">
{% for s in suppliers %}
<option value="{{ s.id }}" {% if s.id == parse_result.matched_supplier.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% else %}
<!-- Fournisseur non répertorié : création proposée -->
<div class="mb-3">
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_new" value="new" checked>
<label class="form-check-label fw-bold text-primary" for="sup_choice_new">
{% translate "Créer ce fournisseur dans le catalogue" %}
</label>
</div>
<div class="row g-2 ps-3">
<div class="col-md-7">
<label class="form-label small mb-1">{% translate "Nom du fournisseur" %}</label>
<input type="text" name="new_supplier_name" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_name }}">
</div>
<div class="col-md-5">
<label class="form-label small mb-1">{% translate "N° Entreprise / TVA" %}</label>
<input type="text" name="new_supplier_vat" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_vat }}">
</div>
<div class="col-md-6">
<label class="form-label small mb-1">{% translate "Email" %}</label>
<input type="email" name="new_supplier_email" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_email }}">
</div>
<div class="col-md-6">
<label class="form-label small mb-1">{% translate "Téléphone" %}</label>
<input type="text" name="new_supplier_phone" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_phone }}">
</div>
<div class="col-12">
<label class="form-label small mb-1">{% translate "Adresse" %}</label>
<input type="text" name="new_supplier_address" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_address }}">
</div>
</div>
</div>
<div class="border-top pt-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_pick" value="existing">
<label class="form-check-label small" for="sup_choice_pick">
{% translate "Associer à un fournisseur existant :" %}
</label>
</div>
<select name="supplier_id" class="form-select form-select-sm mt-1" onfocus="document.getElementById('sup_choice_pick').checked = true;">
<option value="">{% translate "— Sélectionner un fournisseur —" %}</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
</div>
</div>
<!-- CARTE COMMANDE & DESTINATION -->
<div class="col-lg-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<span class="fw-bold text-dark"><i class="bi bi-cart-check text-primary me-2"></i>{% translate "Informations du bon de commande" %}</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small text-muted mb-1">{% translate "Entrepôt de livraison" %}</label>
<div class="fw-bold">{{ selected_warehouse.name }}</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted mb-1">{% translate "Statut initial" %}</label>
<select name="order_status" class="form-select form-select-sm">
<option value="pending" selected>{% translate "En attente (Brouillon)" %}</option>
<option value="ordered">{% translate "Envoyée (Commandée)" %}</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small text-muted mb-1">{% translate "N° Devis / Offre d'origine" %}</label>
<div class="fw-bold font-monospace">{{ parse_result.metadata.quote_number|default:"—" }}</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted mb-1">{% translate "Date du devis" %}</label>
<div class="fw-bold">{{ parse_result.metadata.date|default:"—" }}</div>
</div>
<div class="col-12 border-top pt-2">
<div class="small text-muted d-flex align-items-center">
<i class="bi bi-paperclip me-1 text-primary"></i>
<span>{% translate "Pièce justificative attachée :" %} <strong>{{ orig_pdf_name }}</strong></span>
</div>
</div>
</div>
</div>
</div>
</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">
<div>
<span class="fw-bold text-dark fs-5">
<i class="bi bi-box-seam text-primary me-2"></i>
{% translate "Articles extraits du devis" %}
</span>
<span class="badge bg-primary ms-2">{{ parse_result.items|length }} {% translate "ligne(s)" %}</span>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleSelectAllItems(true)">
<i class="bi bi-check-all"></i> {% translate "Tout cocher" %}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleSelectAllItems(false)">
<i class="bi bi-x"></i> {% translate "Tout décocher" %}
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="items-review-table">
<thead class="table-light small">
<tr>
<th style="width: 40px;" class="text-center">
<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="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>
<th style="width: 120px;" class="text-end">{% translate "Total HT" %}</th>
<th style="min-width: 180px;">{% translate "Emplacement stock" %}</th>
</tr>
</thead>
<tbody>
{% for item in parse_result.items %}
{% with idx=forloop.counter0 %}
<tr class="{% if item.is_new %}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="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 }}">
<input type="hidden" name="item_{{ idx }}_unit" value="{{ item.unit }}">
<input type="hidden" name="item_{{ idx }}_price" value="{{ item.unit_price }}">
<input type="hidden" name="item_{{ idx }}_vat" value="{{ item.vat_rate }}">
</td>
<!-- Désignation extraite -->
<td>
{% if item.reference %}
<span class="badge bg-light text-dark border font-monospace me-1">{{ item.reference }}</span>
{% endif %}
<div class="fw-semibold text-break">{{ item.name }}</div>
{% if item.discount and item.discount != "0%" %}
<small class="text-success"><i class="bi bi-tag-fill me-1"></i>Remise fournisseur : {{ item.discount }}</small>
{% endif %}
</td>
<!-- Rapprochement Catalogue Produit -->
<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" %}
</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 %}
</td>
<!-- Quantité -->
<td class="text-end">
<span class="fw-bold">{{ item.quantity }}</span>
<span class="text-muted small d-block">{{ item.raw_unit }}</span>
</td>
<!-- Prix unitaire net HT -->
<td class="text-end">
<span class="fw-semibold">{{ item.unit_price|floatformat:2 }} €</span>
</td>
<!-- Taux TVA -->
<td class="text-end text-muted small">
{{ item.vat_rate|floatformat:0 }}%
</td>
<!-- Total HT -->
<td class="text-end fw-bold text-dark item-total-cell" data-total="{{ item.total_amount }}" data-vat="{{ item.vat_rate }}">
{{ item.total_amount|floatformat:2 }} €
</td>
<!-- Emplacement de stock -->
<td>
<select name="item_{{ idx }}_location" class="form-select form-select-sm">
{% for loc in locations %}
<option value="{{ loc.id }}" {% if loc.id == default_location.id %}selected{% endif %}>
{{ loc.name }} {% if loc.is_default %}(par défaut){% endif %}
</option>
{% empty %}
<option value="">{% translate "Aucun emplacement" %}</option>
{% endfor %}
</select>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Totaux récapitulatifs & Validation -->
<div class="card-footer bg-light py-3">
<div class="row align-items-center">
<div class="col-md-6 mb-3 mb-md-0">
<div class="d-flex align-items-center gap-3">
<div>
<span class="text-muted small d-block">{% translate "Total HTVA :" %}</span>
<span class="h5 mb-0 fw-bold" id="computed-total-ht">0.00 €</span>
</div>
<div class="border-start ps-3">
<span class="text-muted small d-block">{% translate "TVA calculée :" %}</span>
<span class="h6 mb-0 text-muted" id="computed-total-tva">0.00 €</span>
</div>
<div class="border-start ps-3">
<span class="text-muted small d-block">{% translate "Total TTC estimé :" %}</span>
<span class="h5 mb-0 fw-bold text-primary" id="computed-total-ttc">0.00 €</span>
</div>
</div>
</div>
<div class="col-md-6 text-md-end d-flex justify-content-md-end gap-2">
<a href="{% url 'stock:purchase_order_from_pdf' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-repeat"></i> {% translate "Recommencer" %}
</a>
<button type="submit" class="btn btn-success btn-lg px-4" id="submit-confirm-btn">
<i class="bi bi-cart-check me-2"></i> {% translate "Créer le bon de commande" %}
</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Zone drag & drop
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('id_pdf_file');
const selectedFileDiv = document.getElementById('selected-file-name');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-primary', 'bg-white');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-white');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-primary', 'bg-white');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
updateFileDisplay();
}
});
fileInput.addEventListener('change', updateFileDisplay);
function updateFileDisplay() {
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
selectedFileDiv.querySelector('.file-text').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
selectedFileDiv.classList.remove('d-none');
}
}
}
// Calcul initial des totaux
updateTotals();
});
function toggleSelectAllItems(checked) {
document.querySelectorAll('.item-include-cb').forEach(cb => {
cb.checked = 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 updateTotals() {
let totalHt = 0;
let totalTva = 0;
document.querySelectorAll('#items-review-table tbody tr').forEach(row => {
const cb = row.querySelector('.item-include-cb');
if (cb && cb.checked) {
const totalCell = row.querySelector('.item-total-cell');
if (totalCell) {
const amount = parseFloat(totalCell.getAttribute('data-total')) || 0;
const vatRate = parseFloat(totalCell.getAttribute('data-vat')) || 21;
totalHt += amount;
totalTva += amount * (vatRate / 100.0);
}
}
});
const htEl = document.getElementById('computed-total-ht');
const tvaEl = document.getElementById('computed-total-tva');
const ttcEl = document.getElementById('computed-total-ttc');
if (htEl) htEl.textContent = totalHt.toFixed(2) + ' €';
if (tvaEl) tvaEl.textContent = totalTva.toFixed(2) + ' €';
if (ttcEl) ttcEl.textContent = (totalHt + totalTva).toFixed(2) + ' €';
}
</script>
{% endblock %}

View file

@ -7,6 +7,9 @@
<h1 class="h3">{% translate "Gestion des achats" %}</h1>
{% if can_create_purchase_order %}
<div class="d-flex gap-2">
<a href="{% url 'stock:purchase_order_from_pdf' %}" class="btn btn-outline-success">
<i class="bi bi-file-earmark-pdf"></i> {% translate "Créer depuis une offre (PDF)" %}
</a>
<a href="{% url 'stock:request_for_quote_new' %}" class="btn btn-outline-primary">
<i class="bi bi-plus-circle"></i> {% translate "Nouvelle demande d'offre" %}
</a>

View file

@ -190,13 +190,13 @@
<div class="card mt-2 mb-2 border-0">
<div class="card-body py-2">
<div class="row g-2 align-items-center">
<div class="col-md-5">
<div class="col-md-4">
<input type="text"
id="products-search"
class="form-control form-control-sm"
placeholder="{% translate 'Rechercher par nom, code ou SKU...' %}">
</div>
<div class="col-md-4">
<div class="col-md-3">
<select id="products-category-filter" class="form-select form-select-sm">
<option value="">{% translate "Toutes les catégories" %}</option>
{% for category in categories %}
@ -205,10 +205,17 @@
</select>
</div>
<div class="col-md-3">
<select id="products-status-filter" class="form-select form-select-sm">
<option value="">{% translate "Tous les statuts" %}</option>
<option value="validated">{% translate "Validés" %}</option>
<option value="pending_validation">{% translate "En attente de validation" %}</option>
</select>
</div>
<div class="col-md-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="products-show-inactive">
<label class="form-check-label small" for="products-show-inactive">
{% translate "Afficher les inactifs" %}
{% translate "Inactifs" %}
</label>
</div>
</div>

View file

@ -22,6 +22,8 @@ urlpatterns = [
path("products/<int:product_id>/", views.product_detail, name="product_detail"),
path("products/<int:product_id>/edit/", views.product_edit, name="product_edit"),
path("products/<int:product_id>/delete/", views.product_delete, name="product_delete"),
path("products/<int:product_id>/validate/", views.product_validate, name="product_validate"),
path("products/<int:product_id>/reject/", views.product_reject, name="product_reject"),
path("movements/", views.stock_movements, name="stock_movements"),
path("movements/new/", views.new_stock_movement, name="new_stock_movement"),
path("movements/entry/", views.new_stock_entry, name="new_stock_entry"),
@ -33,6 +35,7 @@ urlpatterns = [
path("movements/<int:pk>/", views.stock_movement_detail, name="stock_movement_detail"),
path("purchases/", views.stock_purchases, name="stock_purchases"),
path("purchases/new/", views.new_purchase_order, name="purchase_order_new"),
path("purchases/from-pdf/", views.purchase_order_from_pdf, name="purchase_order_from_pdf"),
path("purchases/<int:pk>/", views.purchase_order_detail, name="purchase_order_detail"),
path("purchases/<int:pk>/edit/", views.edit_purchase_order, name="purchase_order_edit"),
path("purchases/<int:pk>/receive/", views.receive_purchase_order, name="purchase_order_receive"),

View file

@ -1,5 +1,7 @@
import json
from decimal import Decimal
from common.utils import get_short_name
from django.contrib import messages
@ -60,7 +62,9 @@ from .models import (
Stock,
SerializedItem,
SerializedItemEvent,
notify_supervisors_pending_product,
)
from .pdf_parser import PdfQuoteParser, normalize_unit
from .permissions import (
warehouse_view_required,
warehouse_manage_required,
@ -240,6 +244,11 @@ def products_api(request):
category_id = request.GET.get("category")
if category_id:
products = products.filter(category_id=category_id)
# Filtre par statut (ex: pending_validation)
status_filter = request.GET.get("status")
if status_filter:
products = products.filter(status=status_filter)
# Filtre par recherche
search = request.GET.get("search")
@ -270,6 +279,10 @@ def products_api(request):
"unit": p.unit,
"unit_display": p.get_unit_display(),
"is_active": p.is_active,
"status": p.status,
"status_display": p.get_status_display(),
"is_pending_validation": p.is_pending_validation,
"supplier_reference": p.supplier_reference or "",
}
for p in products
]
@ -1578,6 +1591,272 @@ def new_purchase_order(request):
)
@login_required
def purchase_order_from_pdf(request):
"""
Création d'un bon de commande à partir d'une offre ou devis au format PDF.
Étape 1 : Téléversement du document PDF et choix de l'entrepôt.
Étape 2 : Revue interactive des données extraites (fournisseur, articles, prix).
Pour les produits non trouvés, le magasinier confirme s'il s'agit d'un
nouveau produit (qui sera créé avec le statut particulier 'pending_validation'
pour approbation ultérieure par un superviseur) ou l'associe à un produit existant.
"""
import os
import tempfile
from common.models import UserConfig
try:
user_config = request.user.config
if not (request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any()):
messages.error(request, _("Vous n'avez pas les droits pour créer des bons de commande."))
return redirect('stock:stock_purchases')
except UserConfig.DoesNotExist:
messages.error(request, _("Vous n'avez pas les droits pour créer des bons de commande."))
return redirect('stock:stock_purchases')
accessible_warehouses = get_user_accessible_warehouses(request.user)
warehouses = Warehouse.objects.filter(id__in=accessible_warehouses).order_by('name')
if not warehouses.exists():
messages.error(request, _("Aucun entrepôt accessible pour créer une commande."))
return redirect('stock:stock_purchases')
step = 'upload'
parse_result = None
warehouse = None
temp_pdf_path = ''
orig_pdf_name = ''
if request.method == 'POST':
action = request.POST.get('action', '')
if action == 'parse_pdf':
pdf_file = request.FILES.get('pdf_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."))
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
try:
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'
elif action == 'confirm_order':
warehouse_id = request.POST.get('warehouse_id')
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
if not warehouse:
messages.error(request, _("Entrepôt invalide ou non autorisé."))
return redirect('stock:stock_purchases')
temp_pdf_path = request.POST.get('temp_pdf_path', '')
orig_pdf_name = request.POST.get('orig_pdf_name', '')
# Résolution ou création du Fournisseur
supplier_choice = request.POST.get('supplier_choice', 'existing')
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:
new_sup_name = request.POST.get('new_supplier_name', '').strip()
if not new_sup_name:
new_sup_name = _("Fournisseur sans nom")
supplier = Supplier.objects.create(
name=new_sup_name,
enterprise_number=request.POST.get('new_supplier_vat', '').strip() or None,
contact_email=request.POST.get('new_supplier_email', '').strip() or None,
phone_number=request.POST.get('new_supplier_phone', '').strip() or None,
address=request.POST.get('new_supplier_address', '').strip() or None,
is_active=True,
)
messages.info(request, _("Nouveau fournisseur créé : %(name)s") % {'name': supplier.name})
try:
item_count = int(request.POST.get('item_count', 0))
except (ValueError, TypeError):
item_count = 0
with transaction.atomic():
order_status = request.POST.get('order_status', 'pending')
order = PurchaseOrder.objects.create(
supplier=supplier,
warehouse=warehouse,
status=order_status,
)
new_products_created = []
created_items_count = 0
base_product_num = _next_product_number()
new_prod_offset = 0
for i in range(item_count):
# Vérifier si la ligne est sélectionnée
if not request.POST.get(f'item_{i}_include'):
continue
ref = request.POST.get(f'item_{i}_ref', '').strip()
name = request.POST.get(f'item_{i}_name', '').strip()
if not name:
name = ref or _("Article sans nom")
try:
qty = int(float(request.POST.get(f'item_{i}_qty', 1)))
except (ValueError, TypeError):
qty = 1
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:
unit_price = Decimal('0.00')
try:
vat_rate = Decimal(str(request.POST.get(f'item_{i}_vat', '21')).replace(',', '.'))
except Exception:
vat_rate = Decimal('21.00')
location_id = request.POST.get(f'item_{i}_location')
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
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,
product=product,
location=location,
quantity=qty,
unit_price=unit_price,
vat_rate=vat_rate,
)
created_items_count += 1
# Attacher le fichier PDF original comme document justificatif
if temp_pdf_path and os.path.exists(temp_pdf_path):
try:
from django.core.files.base import ContentFile
with open(temp_pdf_path, 'rb') as f:
doc_title = orig_pdf_name or f"Offre_{order.code}.pdf"
po_doc = PurchaseOrderDocument(
purchase_order=order,
name=doc_title,
)
po_doc.file.save(doc_title, ContentFile(f.read()), save=True)
os.remove(temp_pdf_path)
except Exception:
import logging
logging.getLogger(__name__).exception("Erreur lors de l'attachement du PDF au bon de commande")
messages.success(
request,
_("Le bon de commande %(code)s a été créé avec succès (%(items)d article(s)).")
% {'code': order.code, 'items': created_items_count}
)
if new_products_created:
messages.warning(
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': len(new_products_created),
'prods': ', '.join(p.name for p in new_products_created[:3]) + ('...' if len(new_products_created) > 3 else '')
}
)
return redirect('stock:purchase_order_detail', pk=order.pk)
suppliers = Supplier.objects.filter(is_active=True).order_by('name')
categories = ProductCategory.objects.order_by('name_fr')
products = Product.objects.filter(is_active=True).order_by('name')
locations = []
default_location = None
if warehouse:
locations = warehouse.locations.all().order_by('name')
default_location = warehouse.locations.filter(is_default=True).first() or locations.first()
return render(request, 'stock/purchase_order_from_pdf.html', {
'step': step,
'warehouses': warehouses,
'selected_warehouse': warehouse,
'locations': locations,
'default_location': default_location,
'suppliers': suppliers,
'categories': categories,
'products': products,
'parse_result': parse_result,
'temp_pdf_path': temp_pdf_path,
'orig_pdf_name': orig_pdf_name,
'cancel_url': reverse('stock:stock_purchases'),
})
def edit_purchase_order(request, pk):
"""
Modifier un bon de commande existant (articles, emplacements, quantités).
@ -3872,6 +4151,7 @@ def product_detail(request, product_id):
"alerts": alerts,
"serialized_items": serialized_items,
"is_stock_manager": is_stock_manager,
"can_validate": is_stock_manager and product.is_pending_validation,
}
return render(request, "stock/product_detail.html", context)
@ -5744,6 +6024,42 @@ def product_delete(request, product_id):
return redirect(reverse('stock:warehouses') + '?tab=products')
@login_required
@require_POST
def product_validate(request, product_id):
"""
Valide un produit créé par un magasinier (en attente de validation superviseur).
Réservé aux superviseurs et gestionnaires de stock.
"""
if not user_can_manage_products(request.user):
messages.error(request, _("Vous n'avez pas la permission de valider ce produit."))
return redirect('stock:product_detail', product_id=product_id)
product = get_object_or_404(Product, id=product_id)
comment = request.POST.get('comment', '').strip()
product.validate_product(request.user, comment=comment)
messages.success(request, _("Le produit \"%(name)s\" a été validé avec succès.") % {'name': product.name})
return redirect('stock:product_detail', product_id=product.pk)
@login_required
@require_POST
def product_reject(request, product_id):
"""
Rejette un produit créé par un magasinier (en attente de validation superviseur).
Réservé aux superviseurs et gestionnaires de stock.
"""
if not user_can_manage_products(request.user):
messages.error(request, _("Vous n'avez pas la permission de rejeter ce produit."))
return redirect('stock:product_detail', product_id=product_id)
product = get_object_or_404(Product, id=product_id)
comment = request.POST.get('comment', '').strip()
product.reject_product(request.user, comment=comment)
messages.warning(request, _("Le produit \"%(name)s\" a été rejeté et désactivé.") % {'name': product.name})
return redirect('stock:product_detail', product_id=product.pk)
# =============================================
# Conceptions de produits (Product Designs)
# =============================================