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)."),
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
@ -294,6 +368,54 @@ class Product(models.Model):
|
||||||
).exists()
|
).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):
|
class Stock(models.Model):
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stocks")
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stocks")
|
||||||
location = models.ForeignKey(
|
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 threshold = product.threshold || 0;
|
||||||
const stockColor = isInactive ? 'text-muted' : (totalStock < threshold ? 'text-danger fw-bold' : 'text-success fw-bold');
|
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 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 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 = `
|
row.innerHTML = `
|
||||||
<td><a href="${productUrl}" class="text-decoration-none ${isInactive ? 'text-muted text-decoration-line-through' : ''}">${product.code || '-'}</a></td>
|
<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.sku || '-'}</td>
|
||||||
<td>${product.category || '-'}</td>
|
<td>${product.category || '-'}</td>
|
||||||
<td class="text-end ${stockColor}">${totalStock}</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
|
// Charger les produits au clic sur l'onglet
|
||||||
$('#products-table-tab').on('click', function () {
|
$('#products-table-tab').on('click', function () {
|
||||||
if (productsData.length === 0) {
|
if (productsData.length === 0) {
|
||||||
const category = $('#products-category-filter').val();
|
loadProducts(getProductsFilterParams());
|
||||||
const search = $('#products-search').val();
|
|
||||||
const include_inactive = $('#products-show-inactive').is(':checked');
|
|
||||||
loadProducts({ category, search, include_inactive });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrage automatique au changement de catégorie
|
// Filtrage automatique au changement de catégorie
|
||||||
$('#products-category-filter').on('change', function() {
|
$('#products-category-filter').on('change', function() {
|
||||||
const category = $(this).val();
|
loadProducts(getProductsFilterParams());
|
||||||
const search = $('#products-search').val();
|
});
|
||||||
const include_inactive = $('#products-show-inactive').is(':checked');
|
|
||||||
loadProducts({ category, search, include_inactive });
|
// Filtrage automatique par statut
|
||||||
|
$('#products-status-filter').on('change', function() {
|
||||||
|
loadProducts(getProductsFilterParams());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filtrage automatique au changement de l'état "Afficher les inactifs"
|
// Filtrage automatique au changement de l'état "Afficher les inactifs"
|
||||||
$('#products-show-inactive').on('change', function() {
|
$('#products-show-inactive').on('change', function() {
|
||||||
const include_inactive = $(this).is(':checked');
|
loadProducts(getProductsFilterParams());
|
||||||
const category = $('#products-category-filter').val();
|
|
||||||
const search = $('#products-search').val();
|
|
||||||
loadProducts({ category, search, include_inactive });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recherche automatique après 2 caractères
|
// Recherche automatique après 2 caractères
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
$('#products-search').on('input', function() {
|
$('#products-search').on('input', function() {
|
||||||
const search = $(this).val();
|
const search = $(this).val();
|
||||||
const category = $('#products-category-filter').val();
|
|
||||||
const include_inactive = $('#products-show-inactive').is(':checked');
|
|
||||||
|
|
||||||
// Annuler le timeout précédent
|
// Annuler le timeout précédent
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
|
|
@ -489,7 +499,7 @@ $('#products-search').on('input', function() {
|
||||||
// Charger si 2+ caractères ou si vide (pour réinitialiser)
|
// Charger si 2+ caractères ou si vide (pour réinitialiser)
|
||||||
if (search.length >= 2 || search.length === 0) {
|
if (search.length >= 2 || search.length === 0) {
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
loadProducts({ category, search, include_inactive });
|
loadProducts(getProductsFilterParams());
|
||||||
}, 300); // Délai de 300ms pour éviter trop de requêtes
|
}, 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">{{ product.code }}</span>
|
||||||
<span class="mx-2">-</span>
|
<span class="mx-2">-</span>
|
||||||
<span class="mx-2">{{ product.name }}</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>
|
<span class="badge bg-danger ms-2"><i class="bi bi-x-circle me-1"></i>{% translate "Inactif" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -35,6 +39,83 @@
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if is_stock_manager %}
|
||||||
<!-- Modal suppression -->
|
<!-- Modal suppression -->
|
||||||
<div class="modal fade" id="deleteProductModal" tabindex="-1" aria-hidden="true">
|
<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>
|
<h1 class="h3">{% translate "Gestion des achats" %}</h1>
|
||||||
{% if can_create_purchase_order %}
|
{% if can_create_purchase_order %}
|
||||||
<div class="d-flex gap-2">
|
<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">
|
<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" %}
|
<i class="bi bi-plus-circle"></i> {% translate "Nouvelle demande d'offre" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -190,13 +190,13 @@
|
||||||
<div class="card mt-2 mb-2 border-0">
|
<div class="card mt-2 mb-2 border-0">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
<div class="col-md-5">
|
<div class="col-md-4">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="products-search"
|
id="products-search"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
placeholder="{% translate 'Rechercher par nom, code ou SKU...' %}">
|
placeholder="{% translate 'Rechercher par nom, code ou SKU...' %}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<select id="products-category-filter" class="form-select form-select-sm">
|
<select id="products-category-filter" class="form-select form-select-sm">
|
||||||
<option value="">{% translate "Toutes les catégories" %}</option>
|
<option value="">{% translate "Toutes les catégories" %}</option>
|
||||||
{% for category in categories %}
|
{% for category in categories %}
|
||||||
|
|
@ -205,10 +205,17 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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">
|
<div class="form-check mb-0">
|
||||||
<input class="form-check-input" type="checkbox" id="products-show-inactive">
|
<input class="form-check-input" type="checkbox" id="products-show-inactive">
|
||||||
<label class="form-check-label small" for="products-show-inactive">
|
<label class="form-check-label small" for="products-show-inactive">
|
||||||
{% translate "Afficher les inactifs" %}
|
{% translate "Inactifs" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ urlpatterns = [
|
||||||
path("products/<int:product_id>/", views.product_detail, name="product_detail"),
|
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>/edit/", views.product_edit, name="product_edit"),
|
||||||
path("products/<int:product_id>/delete/", views.product_delete, name="product_delete"),
|
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/", views.stock_movements, name="stock_movements"),
|
||||||
path("movements/new/", views.new_stock_movement, name="new_stock_movement"),
|
path("movements/new/", views.new_stock_movement, name="new_stock_movement"),
|
||||||
path("movements/entry/", views.new_stock_entry, name="new_stock_entry"),
|
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("movements/<int:pk>/", views.stock_movement_detail, name="stock_movement_detail"),
|
||||||
path("purchases/", views.stock_purchases, name="stock_purchases"),
|
path("purchases/", views.stock_purchases, name="stock_purchases"),
|
||||||
path("purchases/new/", views.new_purchase_order, name="purchase_order_new"),
|
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>/", 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>/edit/", views.edit_purchase_order, name="purchase_order_edit"),
|
||||||
path("purchases/<int:pk>/receive/", views.receive_purchase_order, name="purchase_order_receive"),
|
path("purchases/<int:pk>/receive/", views.receive_purchase_order, name="purchase_order_receive"),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from common.utils import get_short_name
|
from common.utils import get_short_name
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
@ -60,7 +62,9 @@ from .models import (
|
||||||
Stock,
|
Stock,
|
||||||
SerializedItem,
|
SerializedItem,
|
||||||
SerializedItemEvent,
|
SerializedItemEvent,
|
||||||
|
notify_supervisors_pending_product,
|
||||||
)
|
)
|
||||||
|
from .pdf_parser import PdfQuoteParser, normalize_unit
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
warehouse_view_required,
|
warehouse_view_required,
|
||||||
warehouse_manage_required,
|
warehouse_manage_required,
|
||||||
|
|
@ -240,6 +244,11 @@ def products_api(request):
|
||||||
category_id = request.GET.get("category")
|
category_id = request.GET.get("category")
|
||||||
if category_id:
|
if category_id:
|
||||||
products = products.filter(category_id=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
|
# Filtre par recherche
|
||||||
search = request.GET.get("search")
|
search = request.GET.get("search")
|
||||||
|
|
@ -270,6 +279,10 @@ def products_api(request):
|
||||||
"unit": p.unit,
|
"unit": p.unit,
|
||||||
"unit_display": p.get_unit_display(),
|
"unit_display": p.get_unit_display(),
|
||||||
"is_active": p.is_active,
|
"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
|
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):
|
def edit_purchase_order(request, pk):
|
||||||
"""
|
"""
|
||||||
Modifier un bon de commande existant (articles, emplacements, quantités).
|
Modifier un bon de commande existant (articles, emplacements, quantités).
|
||||||
|
|
@ -3872,6 +4151,7 @@ def product_detail(request, product_id):
|
||||||
"alerts": alerts,
|
"alerts": alerts,
|
||||||
"serialized_items": serialized_items,
|
"serialized_items": serialized_items,
|
||||||
"is_stock_manager": is_stock_manager,
|
"is_stock_manager": is_stock_manager,
|
||||||
|
"can_validate": is_stock_manager and product.is_pending_validation,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "stock/product_detail.html", context)
|
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')
|
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)
|
# Conceptions de produits (Product Designs)
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue