feat: add product validation workflow, supervisor notifications, and PDF purchase order parser
This commit is contained in:
parent
65624c343a
commit
de6828221b
10 changed files with 1683 additions and 21 deletions
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
533
loko/stock/pdf_parser.py
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
541
loko/stock/templates/stock/purchase_order_from_pdf.html
Normal file
541
loko/stock/templates/stock/purchase_order_from_pdf.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =============================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue