feat(stock): support photo/scan quotes via local OCR and propose supplier creation
- Enable image uploads (.png, .jpg, .jpeg, .webp, .tiff) alongside PDFs - Process photos and scans with local RapidOCR and adaptive bounding box clustering - Propose new supplier creation with pre-filled fields when quote supplier is not found - Retain manual catalog product association safeguard - Add automated unit and integration tests
This commit is contained in:
parent
358fb23d7e
commit
32cb154815
5 changed files with 526 additions and 125 deletions
|
|
@ -29,6 +29,12 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
np = None
|
np = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
except ImportError:
|
||||||
|
Image = None
|
||||||
|
ImageOps = None
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from .models import Supplier, Product, WarehouseLocation
|
from .models import Supplier, Product, WarehouseLocation
|
||||||
|
|
||||||
|
|
@ -159,13 +165,50 @@ def compute_name_similarity(name_a: str, name_b: str) -> Tuple[float, set]:
|
||||||
|
|
||||||
class PdfQuoteParser:
|
class PdfQuoteParser:
|
||||||
"""
|
"""
|
||||||
Parseur local de devis / offres PDF pour la création de bons de commande.
|
Parseur local de devis / offres au format PDF ou image (scan, photo).
|
||||||
|
- Utilise pypdfium2 pour l'extraction vectorielle directe sur PDF numérique (rapide et exact).
|
||||||
|
- Bascule automatiquement sur RapidOCR (moteur 100% local CPU) pour les PDF scannés et les photos/images.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif')
|
||||||
|
|
||||||
def __init__(self, file_or_path):
|
def __init__(self, file_or_path):
|
||||||
self.file_or_path = file_or_path
|
self.file_or_path = file_or_path
|
||||||
self._temp_path = None
|
self._temp_path = None
|
||||||
self.is_ocr = False
|
self.is_ocr = False
|
||||||
|
self.is_image = False
|
||||||
|
|
||||||
|
def _is_image(self) -> bool:
|
||||||
|
"""Détecte si le fichier fourni est une image ou une photo."""
|
||||||
|
if isinstance(self.file_or_path, str):
|
||||||
|
ext = os.path.splitext(self.file_or_path)[1].lower()
|
||||||
|
if ext in self.IMAGE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
elif hasattr(self.file_or_path, 'name'):
|
||||||
|
ext = os.path.splitext(self.file_or_path.name)[1].lower()
|
||||||
|
if ext in self.IMAGE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Détection par signature d'octets si disponible
|
||||||
|
if hasattr(self.file_or_path, 'read'):
|
||||||
|
header = self.file_or_path.read(16)
|
||||||
|
if hasattr(self.file_or_path, 'seek'):
|
||||||
|
self.file_or_path.seek(0)
|
||||||
|
if header.startswith(b'\x89PNG') or header.startswith(b'\xff\xd8\xff') or header.startswith(b'RIFF'):
|
||||||
|
return True
|
||||||
|
if header.startswith(b'%PDF'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(self.file_or_path, str) and os.path.exists(self.file_or_path):
|
||||||
|
try:
|
||||||
|
with open(self.file_or_path, 'rb') as f:
|
||||||
|
header = f.read(16)
|
||||||
|
if header.startswith(b'\x89PNG') or header.startswith(b'\xff\xd8\xff') or header.startswith(b'RIFF'):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def _get_document(self) -> Tuple[Any, str]:
|
def _get_document(self) -> Tuple[Any, str]:
|
||||||
"""Ouvre le document PDF via pypdfium2."""
|
"""Ouvre le document PDF via pypdfium2."""
|
||||||
|
|
@ -201,9 +244,14 @@ class PdfQuoteParser:
|
||||||
|
|
||||||
def parse(self) -> Dict[str, Any]:
|
def parse(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extrait les métadonnées et la liste des articles d'un devis.
|
Extrait les métadonnées et la liste des articles d'un devis (PDF ou Image).
|
||||||
Effectue le rapprochement automatique avec le stock existant (fournisseur et produits).
|
Effectue le rapprochement automatique avec le stock existant (fournisseur et produits).
|
||||||
"""
|
"""
|
||||||
|
if self._is_image():
|
||||||
|
self.is_ocr = True
|
||||||
|
self.is_image = True
|
||||||
|
return self._parse_image()
|
||||||
|
|
||||||
doc = None
|
doc = None
|
||||||
try:
|
try:
|
||||||
doc, path = self._get_document()
|
doc, path = self._get_document()
|
||||||
|
|
@ -214,7 +262,7 @@ class PdfQuoteParser:
|
||||||
# Vérifier si le document possède du texte vectoriel ou nécessite un OCR
|
# 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)
|
total_char_count = sum(len(page.get_textpage().get_text_range()) for page in doc)
|
||||||
if total_char_count < 50:
|
if total_char_count < 50:
|
||||||
logger.info(f"PDF sans couche texte suffisante ({total_char_count} chars). Utilisation du moteur OCR.")
|
logger.info(f"PDF sans couche texte suffisante ({total_char_count} chars). Utilisation du moteur OCR local.")
|
||||||
self.is_ocr = True
|
self.is_ocr = True
|
||||||
return self._parse_with_ocr(doc)
|
return self._parse_with_ocr(doc)
|
||||||
else:
|
else:
|
||||||
|
|
@ -227,6 +275,78 @@ class PdfQuoteParser:
|
||||||
pass
|
pass
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
def _parse_image(self) -> Dict[str, Any]:
|
||||||
|
"""Analyse directe d'une photo ou d'un scan d'offre via RapidOCR local."""
|
||||||
|
if Image is None:
|
||||||
|
raise RuntimeError("Le module PIL/Pillow n'est pas disponible pour l'analyse d'images.")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# Charger l'image avec gestion de l'orientation EXIF des smartphones
|
||||||
|
if isinstance(self.file_or_path, str):
|
||||||
|
pil_img = Image.open(self.file_or_path)
|
||||||
|
elif hasattr(self.file_or_path, 'temporary_file_path'):
|
||||||
|
pil_img = Image.open(self.file_or_path.temporary_file_path())
|
||||||
|
elif hasattr(self.file_or_path, 'read'):
|
||||||
|
pil_img = Image.open(self.file_or_path)
|
||||||
|
if hasattr(self.file_or_path, 'seek'):
|
||||||
|
self.file_or_path.seek(0)
|
||||||
|
else:
|
||||||
|
raise ValueError("Type d'image non supporté.")
|
||||||
|
|
||||||
|
if ImageOps is not None:
|
||||||
|
pil_img = ImageOps.exif_transpose(pil_img)
|
||||||
|
pil_img = pil_img.convert('RGB')
|
||||||
|
|
||||||
|
# Redimensionnement maîtrisé si image très volumineuse (ex: photo smartphone 48MP)
|
||||||
|
max_dim = max(pil_img.size)
|
||||||
|
if max_dim > 2500:
|
||||||
|
scale = 2500.0 / max_dim
|
||||||
|
new_size = (int(pil_img.width * scale), int(pil_img.height * scale))
|
||||||
|
pil_img = pil_img.resize(new_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
ocr_res, _ = ocr(np.array(pil_img))
|
||||||
|
rects = []
|
||||||
|
h = pil_img.height
|
||||||
|
|
||||||
|
if ocr_res:
|
||||||
|
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)
|
||||||
|
rects.append((left, h - bottom, right, h - top, txt.strip()))
|
||||||
|
|
||||||
|
rects.sort(key=lambda x: -x[3])
|
||||||
|
avg_h = float(np.mean([r[3] - r[1] for r in rects])) if rects else 15.0
|
||||||
|
y_tol = max(8.0, avg_h * 0.45)
|
||||||
|
|
||||||
|
page_lines = self._cluster_lines(rects, y_tol=y_tol)
|
||||||
|
full_text = "\n".join(" ".join(r[4] for r in l) for l in page_lines)
|
||||||
|
|
||||||
|
metadata = self._extract_metadata(full_text)
|
||||||
|
items = self._extract_items_from_clustered_lines([page_lines])
|
||||||
|
|
||||||
|
matched_supplier = self._match_supplier(metadata)
|
||||||
|
enriched_items = self._match_products(items)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_ocr': True,
|
||||||
|
'is_image': True,
|
||||||
|
'metadata': metadata,
|
||||||
|
'items': enriched_items,
|
||||||
|
'matched_supplier': matched_supplier,
|
||||||
|
'total_items': len(enriched_items),
|
||||||
|
}
|
||||||
|
|
||||||
def _parse_vector(self, doc: Any) -> Dict[str, Any]:
|
def _parse_vector(self, doc: Any) -> Dict[str, Any]:
|
||||||
"""Analyse un PDF avec couche texte vectorielle (recherche spatiale précise)."""
|
"""Analyse un PDF avec couche texte vectorielle (recherche spatiale précise)."""
|
||||||
full_text = ""
|
full_text = ""
|
||||||
|
|
@ -236,12 +356,12 @@ class PdfQuoteParser:
|
||||||
metadata = self._extract_metadata(full_text, doc)
|
metadata = self._extract_metadata(full_text, doc)
|
||||||
items = self._extract_items_vector(doc)
|
items = self._extract_items_vector(doc)
|
||||||
|
|
||||||
# Rapprochement avec le catalogue
|
|
||||||
matched_supplier = self._match_supplier(metadata)
|
matched_supplier = self._match_supplier(metadata)
|
||||||
enriched_items = self._match_products(items)
|
enriched_items = self._match_products(items)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'is_ocr': False,
|
'is_ocr': False,
|
||||||
|
'is_image': False,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'items': enriched_items,
|
'items': enriched_items,
|
||||||
'matched_supplier': matched_supplier,
|
'matched_supplier': matched_supplier,
|
||||||
|
|
@ -267,6 +387,7 @@ class PdfQuoteParser:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rects = []
|
rects = []
|
||||||
|
h = pil_img.height
|
||||||
for item in ocr_res:
|
for item in ocr_res:
|
||||||
box, txt, conf = item
|
box, txt, conf = item
|
||||||
if not txt.strip():
|
if not txt.strip():
|
||||||
|
|
@ -275,12 +396,13 @@ class PdfQuoteParser:
|
||||||
right = max(pt[0] for pt in box)
|
right = max(pt[0] for pt in box)
|
||||||
top = min(pt[1] for pt in box)
|
top = min(pt[1] for pt in box)
|
||||||
bottom = max(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.append((left, h - bottom, right, h - top, txt.strip()))
|
||||||
|
|
||||||
rects.sort(key=lambda x: -x[3])
|
rects.sort(key=lambda x: -x[3])
|
||||||
page_lines = self._cluster_lines(rects)
|
avg_h = float(np.mean([r[3] - r[1] for r in rects])) if rects else 15.0
|
||||||
|
y_tol = max(8.0, avg_h * 0.45)
|
||||||
|
|
||||||
|
page_lines = self._cluster_lines(rects, y_tol=y_tol)
|
||||||
ocr_pages_data.append(page_lines)
|
ocr_pages_data.append(page_lines)
|
||||||
for l in page_lines:
|
for l in page_lines:
|
||||||
full_text_lines.append(' '.join(item[4] for item in l))
|
full_text_lines.append(' '.join(item[4] for item in l))
|
||||||
|
|
@ -294,6 +416,7 @@ class PdfQuoteParser:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'is_ocr': True,
|
'is_ocr': True,
|
||||||
|
'is_image': False,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'items': enriched_items,
|
'items': enriched_items,
|
||||||
'matched_supplier': matched_supplier,
|
'matched_supplier': matched_supplier,
|
||||||
|
|
@ -308,9 +431,11 @@ class PdfQuoteParser:
|
||||||
|
|
||||||
for r in rects:
|
for r in rects:
|
||||||
y = (r[1] + r[3]) / 2.0
|
y = (r[1] + r[3]) / 2.0
|
||||||
if current_y is None or abs(current_y - y) <= y_tol:
|
h_box = r[3] - r[1]
|
||||||
|
tol = max(y_tol, h_box * 0.45)
|
||||||
|
if current_y is None or abs(current_y - y) <= tol:
|
||||||
current_line.append(r)
|
current_line.append(r)
|
||||||
current_y = y
|
current_y = (current_y + y) / 2.0 if current_y else y
|
||||||
else:
|
else:
|
||||||
current_line.sort(key=lambda x: x[0])
|
current_line.sort(key=lambda x: x[0])
|
||||||
lines.append(current_line)
|
lines.append(current_line)
|
||||||
|
|
@ -321,7 +446,7 @@ class PdfQuoteParser:
|
||||||
lines.append(current_line)
|
lines.append(current_line)
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _extract_metadata(self, full_text: str, doc: pdfium.PdfDocument) -> Dict[str, Any]:
|
def _extract_metadata(self, full_text: str, doc: Optional[Any] = None) -> Dict[str, Any]:
|
||||||
"""Extrait les métadonnées de l'en-tête (Fournisseur, Devis n°, Date, Totaux)."""
|
"""Extrait les métadonnées de l'en-tête (Fournisseur, Devis n°, Date, Totaux)."""
|
||||||
meta = {
|
meta = {
|
||||||
'supplier_name': '',
|
'supplier_name': '',
|
||||||
|
|
@ -338,7 +463,9 @@ class PdfQuoteParser:
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Numéro de TVA / Entreprise
|
# 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)
|
vat_m = re.search(r'(?:TVA|BTW|VAT|N°\s*d\'entreprise)[\s.:-]*(BE\s*0?\d{3}[\.\s]?\d{3}[\.\s]?\d{3})', full_text, re.I)
|
||||||
|
if not vat_m:
|
||||||
|
vat_m = re.search(r'\b(BE\s*0?\d{3}[\.\s]?\d{3}[\.\s]?\d{3})\b', full_text, re.I)
|
||||||
if vat_m:
|
if vat_m:
|
||||||
meta['supplier_vat'] = vat_m.group(1).strip()
|
meta['supplier_vat'] = vat_m.group(1).strip()
|
||||||
|
|
||||||
|
|
@ -348,12 +475,12 @@ class PdfQuoteParser:
|
||||||
meta['supplier_email'] = email_m.group(0).strip()
|
meta['supplier_email'] = email_m.group(0).strip()
|
||||||
|
|
||||||
# 3. Téléphone
|
# 3. Téléphone
|
||||||
phone_m = re.search(r'(?:Tel|Tél|Phone)[\s:]*([+\d\s\.\(\)\/-]{8,25})', full_text, re.I)
|
phone_m = re.search(r'(?:Tel|Tél|Phone|Gsm)[\s.:-]*([+\d\s\.\(\)\/-]{8,25})', full_text, re.I)
|
||||||
if phone_m:
|
if phone_m:
|
||||||
meta['supplier_phone'] = phone_m.group(1).strip().rstrip(' -')
|
meta['supplier_phone'] = phone_m.group(1).strip().rstrip(' -')
|
||||||
|
|
||||||
# 4. Numéro de devis / offre (ex: "OF 2026000723/", "DEV-12345", etc.)
|
# 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)
|
quote_m = re.search(r'\b(OF\s*[-#]?\s*\d+[\w\/-]*|DEV(?:IS)?\s*[-#]?\s*\d+[\w\/-]*|QUO(?:TE)?\s*[-#]?\s*\d+[\w\/-]*|OFFRE\s*[-#]?\s*\d+[\w\/-]*)\b', full_text, re.I)
|
||||||
if quote_m:
|
if quote_m:
|
||||||
meta['quote_number'] = quote_m.group(0).strip().rstrip('/')
|
meta['quote_number'] = quote_m.group(0).strip().rstrip('/')
|
||||||
else:
|
else:
|
||||||
|
|
@ -367,6 +494,26 @@ class PdfQuoteParser:
|
||||||
meta['date'] = date_m.group(1).replace('-', '/').replace('.', '/')
|
meta['date'] = date_m.group(1).replace('-', '/').replace('.', '/')
|
||||||
|
|
||||||
# 6. Nom du fournisseur
|
# 6. Nom du fournisseur
|
||||||
|
LEGAL = r'(?:s\.?a\.?|n\.?v\.?|s\.?r\.?l\.?|s\.?p\.?r\.?l\.?|b\.?v\.?b\.?a\.?|b\.?v\.?|s\.?a\.?s\.?|s\.?a\.?r\.?l\.?)'
|
||||||
|
for line in full_text.split('\n')[:30]:
|
||||||
|
line_s = line.strip()
|
||||||
|
# Chercher une ligne contenant une forme légale avec nom substantiel
|
||||||
|
m1 = re.search(rf'^\b{LEGAL}\b\s+([A-Za-z0-9\ \.-]{{3,60}})', line_s, re.I)
|
||||||
|
if m1:
|
||||||
|
cand = m1.group(1).strip()
|
||||||
|
cand_clean = re.sub(rf'\b{LEGAL}\b', '', cand, flags=re.I).strip(' .-')
|
||||||
|
if len(cand_clean) >= 4:
|
||||||
|
meta['supplier_name'] = line_s
|
||||||
|
break
|
||||||
|
m2 = re.search(rf'^([A-Za-z0-9\ \.-]{{3,60}})\s+\b{LEGAL}\b', line_s, re.I)
|
||||||
|
if m2:
|
||||||
|
cand = m2.group(1).strip()
|
||||||
|
cand_clean = re.sub(rf'\b{LEGAL}\b', '', cand, flags=re.I).strip(' .-')
|
||||||
|
if len(cand_clean) >= 4:
|
||||||
|
meta['supplier_name'] = line_s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not meta['supplier_name']:
|
||||||
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)
|
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:
|
if name_m:
|
||||||
meta['supplier_name'] = name_m.group(0).strip()
|
meta['supplier_name'] = name_m.group(0).strip()
|
||||||
|
|
@ -375,7 +522,7 @@ class PdfQuoteParser:
|
||||||
if domain_m:
|
if domain_m:
|
||||||
meta['supplier_name'] = domain_m.group(1)
|
meta['supplier_name'] = domain_m.group(1)
|
||||||
else:
|
else:
|
||||||
meta['supplier_name'] = "Fournisseur inconnu"
|
meta['supplier_name'] = ""
|
||||||
|
|
||||||
# Adresse
|
# 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)
|
addr_m = re.search(r'((?:Rue|Avenue|Boulevard|Chaussée|Straat|Lentestraat)[^\n\r]+[\d]{4}\s+[A-Za-zÀ-ÿ]+)', full_text, re.I)
|
||||||
|
|
@ -414,7 +561,6 @@ class PdfQuoteParser:
|
||||||
|
|
||||||
rects.sort(key=lambda x: -x[3])
|
rects.sort(key=lambda x: -x[3])
|
||||||
|
|
||||||
# Délimiter verticalement la zone de tableau sur la page
|
|
||||||
table_top = None
|
table_top = None
|
||||||
table_bottom = 50.0
|
table_bottom = 50.0
|
||||||
|
|
||||||
|
|
@ -443,11 +589,18 @@ class PdfQuoteParser:
|
||||||
items = []
|
items = []
|
||||||
current_item = None
|
current_item = None
|
||||||
|
|
||||||
# Modèle de ligne produit :
|
# Modèle 1 (vectoriel classique) :
|
||||||
# description | qté | unité | prix unitaire | [remise%] | total | tva%
|
# description | qté | unité | prix unitaire | [remise%] | total | tva%
|
||||||
row_pattern = re.compile(
|
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+%)'
|
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+%)'
|
||||||
)
|
)
|
||||||
|
# Modèle 2 (OCR / Scan souple où qté+unité ou total+tva peuvent être regroupés sans pipe) :
|
||||||
|
ocr_row_base = re.compile(
|
||||||
|
r'^(.*?)\s*\|\s*(\d+[.,]\d+)\s*(?:\|\s*|\s+)([A-Za-zÀ-ÿ]+)\s*\|\s*(\d+[.,]\d+)(.*?)$'
|
||||||
|
)
|
||||||
|
ocr_row_tail = re.compile(
|
||||||
|
r'(?:\|\s*(\d+[.,]?\d*%)\s*)?\|\s*(\d+[.,]\d+)(?:[\|\s]+(\d+%))?'
|
||||||
|
)
|
||||||
|
|
||||||
stop_keywords = [
|
stop_keywords = [
|
||||||
'Total hors', 'Total EUR', 'Total TTC', 'Total HT',
|
'Total hors', 'Total EUR', 'Total TTC', 'Total HT',
|
||||||
|
|
@ -473,7 +626,23 @@ class PdfQuoteParser:
|
||||||
discount_str = m.group(5) if m.group(5) else '0%'
|
discount_str = m.group(5) if m.group(5) else '0%'
|
||||||
total_val = float(m.group(6).replace(',', '.'))
|
total_val = float(m.group(6).replace(',', '.'))
|
||||||
vat_val = float(m.group(7).replace('%', ''))
|
vat_val = float(m.group(7).replace('%', ''))
|
||||||
|
else:
|
||||||
|
m_ocr = ocr_row_base.match(line_str)
|
||||||
|
if m_ocr:
|
||||||
|
desc_part = m_ocr.group(1).strip()
|
||||||
|
qty_val = float(m_ocr.group(2).replace(',', '.'))
|
||||||
|
raw_unit = m_ocr.group(3).strip()
|
||||||
|
gross_price = float(m_ocr.group(4).replace(',', '.'))
|
||||||
|
tail = m_ocr.group(5)
|
||||||
|
tail_m = ocr_row_tail.search(tail) if tail else None
|
||||||
|
discount_str = tail_m.group(1) if tail_m and tail_m.group(1) else '0%'
|
||||||
|
total_val = float(tail_m.group(2).replace(',', '.')) if tail_m and tail_m.group(2) else gross_price * qty_val
|
||||||
|
vat_val = float(tail_m.group(3).replace('%', '')) if tail_m and tail_m.group(3) else 21.0
|
||||||
|
m = m_ocr
|
||||||
|
else:
|
||||||
|
m = None
|
||||||
|
|
||||||
|
if m:
|
||||||
# Séparer la référence fournisseur de la description
|
# Séparer la référence fournisseur de la description
|
||||||
parts = desc_part.split(' | ')
|
parts = desc_part.split(' | ')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
|
|
@ -511,10 +680,9 @@ class PdfQuoteParser:
|
||||||
if continuation and not any(k in continuation for k in ['RECUPEL', 'ING - BE95', 'BNP Paribas', '1/2', '2/2']):
|
if continuation and not any(k in continuation for k in ['RECUPEL', 'ING - BE95', 'BNP Paribas', '1/2', '2/2']):
|
||||||
current_item['name'] += ' ' + continuation
|
current_item['name'] += ' ' + continuation
|
||||||
|
|
||||||
# Nettoyage final des libellés (suppression des indicateurs de pagination orphelins)
|
# Nettoyage final des libellés
|
||||||
for it in items:
|
for it in items:
|
||||||
it['name'] = re.sub(r'\s*\b[1-9]/[1-9]\b\s*$', '', it['name']).strip()
|
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()
|
it['name'] = re.sub(r'categoryId=\d+.*', '', it['name']).strip()
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
@ -638,3 +806,7 @@ class PdfQuoteParser:
|
||||||
item['is_new'] = not bool(matched_id)
|
item['is_new'] = not bool(matched_id)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# Alias pour usage universel (PDF, scans, photos)
|
||||||
|
QuoteDocumentParser = PdfQuoteParser
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block title %}{% translate "Créer un bon de commande depuis une offre PDF" %} — Loko{% endblock %}
|
{% block title %}{% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %} — Loko{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
|
|
@ -11,12 +11,13 @@
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb mb-1">
|
<ol class="breadcrumb mb-1">
|
||||||
<li class="breadcrumb-item"><a href="{% url 'stock:stock_purchases' %}">{% translate "Achats" %}</a></li>
|
<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>
|
<li class="breadcrumb-item active" aria-current="page">{% translate "Import d'offre" %}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 class="h3 d-flex align-items-center mb-0">
|
<h1 class="h3 d-flex align-items-center mb-0">
|
||||||
<i class="bi bi-file-earmark-pdf text-danger me-2"></i>
|
<i class="bi bi-file-earmark-pdf text-danger me-2"></i>
|
||||||
{% translate "Créer un bon de commande depuis une offre PDF" %}
|
<i class="bi bi-camera text-primary me-2"></i>
|
||||||
|
{% translate "Créer un bon de commande depuis une offre (PDF, Scan, Photo)" %}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -27,7 +28,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if step == 'upload' %}
|
{% if step == 'upload' %}
|
||||||
<!-- ÉTAPE 1 : TÉLÉVERSEMENT DU FICHIER PDF -->
|
<!-- ÉTAPE 1 : TÉLÉVERSEMENT DU FICHIER (PDF, SCAN OU PHOTO) -->
|
||||||
<div class="row justify-content-center mt-4">
|
<div class="row justify-content-center mt-4">
|
||||||
<div class="col-lg-8 col-xl-7">
|
<div class="col-lg-8 col-xl-7">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
|
@ -40,7 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
<h4 class="fw-bold">{% translate "Importer une offre ou un devis fournisseur" %}</h4>
|
<h4 class="fw-bold">{% translate "Importer une offre ou un devis fournisseur" %}</h4>
|
||||||
<p class="text-muted">
|
<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." %}
|
{% translate "Déposez un devis au format PDF, un scan ou une photo prise avec un smartphone. Le système analyse automatiquement le document avec reconnaissance OCR locale, extrait les articles, compare avec le stock existant et prépare le bon de commande." %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -63,16 +64,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone de dépôt PDF -->
|
<!-- Zone de dépôt Document (PDF ou Image) -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-semibold">
|
<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>
|
<i class="bi bi-file-earmark-text me-1"></i> {% translate "Fichier de l'offre (PDF, scan ou photo)" %} <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="border border-2 border-dashed rounded-3 p-4 text-center bg-light" id="drop-zone" style="cursor: pointer;">
|
<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>
|
<div class="mb-2">
|
||||||
<span class="fw-medium text-dark d-block mb-1">{% translate "Cliquez ou glissez-déposez le devis PDF ici" %}</span>
|
<i class="bi bi-file-earmark-pdf text-danger fs-2 me-2"></i>
|
||||||
<span class="text-muted small">{% translate "Formats acceptés : PDF numérique ou scanné (OCR automatique)" %}</span>
|
<i class="bi bi-camera text-primary fs-2"></i>
|
||||||
<input type="file" name="pdf_file" id="id_pdf_file" class="d-none" accept=".pdf,application/pdf" required>
|
</div>
|
||||||
|
<span class="fw-medium text-dark d-block mb-1">{% translate "Cliquez ou glissez-déposez le document ici" %}</span>
|
||||||
|
<span class="text-muted small d-block mb-2">{% translate "Formats acceptés : PDF (numérique ou scanné), photos & scans (JPG, PNG, WEBP, TIFF)" %}</span>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border">
|
||||||
|
<i class="bi bi-cpu me-1"></i>{% translate "Reconnaissance OCR 100% locale" %}
|
||||||
|
</span>
|
||||||
|
<input type="file" name="pdf_file" id="id_pdf_file" class="d-none" accept=".pdf,image/*,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.tif" required>
|
||||||
<div id="selected-file-name" class="mt-3 fw-bold text-primary d-none">
|
<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>
|
<i class="bi bi-file-earmark-check me-1"></i> <span class="file-text"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,8 +90,8 @@
|
||||||
<div class="alert alert-info border-0 d-flex align-items-start mb-4">
|
<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>
|
<i class="bi bi-info-circle-fill fs-5 me-2 mt-1 flex-shrink-0"></i>
|
||||||
<div class="small">
|
<div class="small">
|
||||||
<strong>{% translate "Règle de gestion des articles :" %}</strong><br>
|
<strong>{% translate "Règle de gestion des articles & du fournisseur :" %}</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>." %}
|
{% translate "Les articles du devis sont rapprochés avec les produits existants de votre catalogue Loko. Si le fournisseur n'est pas encore enregistré, vous pourrez confirmer sa création directement lors de la revue." %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -123,7 +130,7 @@
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle">
|
<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é" %}
|
<i class="bi bi-exclamation-circle me-1"></i>{% translate "Fournisseur non trouvé" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,57 +160,76 @@
|
||||||
<div class="collapse mt-2" id="changeSupplierCollapse">
|
<div class="collapse mt-2" id="changeSupplierCollapse">
|
||||||
<select class="form-select form-select-sm" onchange="document.querySelector('input[name=supplier_id]').value = this.value;">
|
<select class="form-select form-select-sm" onchange="document.querySelector('input[name=supplier_id]').value = this.value;">
|
||||||
{% for s in suppliers %}
|
{% for s in suppliers %}
|
||||||
<option value="{{ s.id }}" {% if s.id == parse_result.matched_supplier.id %}selected{% endif %}>{{ s.name }}</option>
|
<option value="{{ s.id }}" {% if s.id == parse_result.matched_supplier.id %}selected{% endif %}>{{ s.name }}{% if s.enterprise_number %} ({{ s.enterprise_number }}){% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Fournisseur non répertorié : création proposée -->
|
<!-- Fournisseur non répertorié : Proposition claire de création ou sélection -->
|
||||||
<div class="mb-3">
|
<div class="alert alert-warning border-0 d-flex align-items-center mb-3 py-2 px-3">
|
||||||
|
<i class="bi bi-building-exclamation fs-4 me-2 text-warning flex-shrink-0"></i>
|
||||||
|
<div class="small">
|
||||||
|
<strong>{% translate "Fournisseur non trouvé dans le répertoire Loko" %}</strong><br>
|
||||||
|
{% translate "Le fournisseur détecté n'a pas été retrouvé. Souhaitez-vous le créer dans Loko ou rattacher un fournisseur existant ?" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option 1 : Créer le fournisseur (pré-rempli) -->
|
||||||
|
<div class="p-3 border rounded-3 border-primary bg-white shadow-sm mb-3" id="container_create_supplier">
|
||||||
<div class="form-check mb-2">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_new" value="new" checked>
|
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_new" value="new" checked onchange="toggleSupplierChoice()">
|
||||||
<label class="form-check-label fw-bold text-primary" for="sup_choice_new">
|
<label class="form-check-label fw-bold text-primary" for="sup_choice_new">
|
||||||
{% translate "Créer ce fournisseur dans le catalogue" %}
|
<i class="bi bi-plus-circle-fill me-1"></i> {% translate "Créer ce nouveau fournisseur dans le répertoire (pré-rempli)" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-2 ps-3">
|
<div class="row g-2 ps-3 mt-1">
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<label class="form-label small mb-1">{% translate "Nom du fournisseur" %}</label>
|
<label class="form-label small fw-semibold mb-1">
|
||||||
<input type="text" name="new_supplier_name" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_name }}">
|
{% translate "Nom de l'entreprise" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="new_supplier_name" id="id_new_supplier_name" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_name }}" placeholder="{% translate 'ex: Mon Fournisseur SRL' %}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<label class="form-label small mb-1">{% translate "N° Entreprise / TVA" %}</label>
|
<label class="form-label small fw-semibold mb-1">{% translate "N° TVA / Entreprise" %}</label>
|
||||||
<input type="text" name="new_supplier_vat" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_vat }}">
|
<input type="text" name="new_supplier_vat" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_vat }}" placeholder="BE 0123.456.789">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label small mb-1">{% translate "Email" %}</label>
|
<label class="form-label small fw-semibold mb-1">{% translate "Email de contact" %}</label>
|
||||||
<input type="email" name="new_supplier_email" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_email }}">
|
<input type="email" name="new_supplier_email" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_email }}" placeholder="contact@fournisseur.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label small mb-1">{% translate "Téléphone" %}</label>
|
<label class="form-label small fw-semibold 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 }}">
|
<input type="text" name="new_supplier_phone" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_phone }}" placeholder="+32 ...">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label small mb-1">{% translate "Adresse" %}</label>
|
<label class="form-label small fw-semibold mb-1">{% translate "Adresse" %}</label>
|
||||||
<input type="text" name="new_supplier_address" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_address }}">
|
<input type="text" name="new_supplier_address" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_address }}" placeholder="{% translate 'Rue, code postal, ville' %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-muted small ps-3 mt-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
{% translate "Le fournisseur sera automatiquement enregistré dans Loko et rattaché à ce bon de commande." %}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-top pt-2">
|
</div>
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_pick" value="existing">
|
<!-- Option 2 : Rattacher à un fournisseur existant -->
|
||||||
<label class="form-check-label small" for="sup_choice_pick">
|
<div class="p-3 border rounded-3 bg-light-subtle" id="container_existing_supplier">
|
||||||
{% translate "Associer à un fournisseur existant :" %}
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_pick" value="existing" onchange="toggleSupplierChoice()">
|
||||||
|
<label class="form-check-label fw-bold text-secondary" for="sup_choice_pick">
|
||||||
|
<i class="bi bi-search me-1"></i> {% translate "Ou rattacher à un fournisseur déjà existant dans Loko :" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<select name="supplier_id" class="form-select form-select-sm mt-1" onfocus="document.getElementById('sup_choice_pick').checked = true;">
|
<div class="ps-3">
|
||||||
<option value="">{% translate "— Sélectionner un fournisseur —" %}</option>
|
<select name="supplier_id" id="id_supplier_id" class="form-select form-select-sm" onfocus="document.getElementById('sup_choice_pick').checked = true; toggleSupplierChoice();">
|
||||||
|
<option value="">{% translate "— Sélectionner un fournisseur dans la liste —" %}</option>
|
||||||
{% for s in suppliers %}
|
{% for s in suppliers %}
|
||||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
<option value="{{ s.id }}">{{ s.name }}{% if s.enterprise_number %} ({{ s.enterprise_number }}){% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,10 +263,25 @@
|
||||||
<div class="fw-bold">{{ parse_result.metadata.date|default:"—" }}</div>
|
<div class="fw-bold">{{ parse_result.metadata.date|default:"—" }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 border-top pt-2">
|
<div class="col-12 border-top pt-2">
|
||||||
<div class="small text-muted d-flex align-items-center">
|
<div class="small text-muted d-flex align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
<i class="bi bi-paperclip me-1 text-primary"></i>
|
<i class="bi bi-paperclip me-1 text-primary"></i>
|
||||||
<span>{% translate "Pièce justificative attachée :" %} <strong>{{ orig_pdf_name }}</strong></span>
|
<span>{% translate "Pièce justificative attachée :" %} <strong>{{ orig_pdf_name }}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if parse_result.is_image %}
|
||||||
|
<span class="badge bg-info-subtle text-info-emphasis border border-info-subtle">
|
||||||
|
<i class="bi bi-camera me-1"></i>{% translate "Photo / Scan (OCR local)" %}
|
||||||
|
</span>
|
||||||
|
{% elif parse_result.is_ocr %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>{% translate "PDF scanné (OCR local)" %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
|
||||||
|
<i class="bi bi-file-earmark-pdf me-1"></i>{% translate "PDF numérique" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -504,16 +545,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
function updateFileDisplay() {
|
function updateFileDisplay() {
|
||||||
if (fileInput.files.length > 0) {
|
if (fileInput.files.length > 0) {
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
|
const isImg = file.type.startsWith('image/') || /\.(png|jpe?g|webp|bmp|tiff?)$/i.test(file.name);
|
||||||
|
const icon = selectedFileDiv.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = isImg ? 'bi bi-camera me-1 text-primary' : 'bi bi-file-earmark-pdf me-1 text-danger';
|
||||||
|
}
|
||||||
selectedFileDiv.querySelector('.file-text').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
selectedFileDiv.querySelector('.file-text').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
||||||
selectedFileDiv.classList.remove('d-none');
|
selectedFileDiv.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialisation affichage fournisseur si non identifié
|
||||||
|
toggleSupplierChoice();
|
||||||
|
|
||||||
// Calcul initial des totaux
|
// Calcul initial des totaux
|
||||||
updateTotals();
|
updateTotals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleSupplierChoice() {
|
||||||
|
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
|
||||||
|
const cardNew = document.getElementById('container_create_supplier');
|
||||||
|
const cardExisting = document.getElementById('container_existing_supplier');
|
||||||
|
if (cardNew && cardExisting) {
|
||||||
|
if (isNew) {
|
||||||
|
cardNew.classList.remove('opacity-75', 'bg-light-subtle');
|
||||||
|
cardNew.classList.add('border-primary', 'shadow-sm', 'bg-white');
|
||||||
|
cardExisting.classList.remove('border-primary', 'shadow-sm', 'bg-white');
|
||||||
|
cardExisting.classList.add('opacity-75', 'bg-light-subtle');
|
||||||
|
} else {
|
||||||
|
cardExisting.classList.remove('opacity-75', 'bg-light-subtle');
|
||||||
|
cardExisting.classList.add('border-primary', 'shadow-sm', 'bg-white');
|
||||||
|
cardNew.classList.remove('border-primary', 'shadow-sm', 'bg-white');
|
||||||
|
cardNew.classList.add('opacity-75', 'bg-light-subtle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAllItems(checked) {
|
function toggleSelectAllItems(checked) {
|
||||||
document.querySelectorAll('.item-include-cb').forEach(cb => {
|
document.querySelectorAll('.item-include-cb').forEach(cb => {
|
||||||
cb.checked = checked;
|
cb.checked = checked;
|
||||||
|
|
@ -542,6 +610,26 @@ function handleProductSelection(idx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateOrderForm() {
|
function validateOrderForm() {
|
||||||
|
// 1. Validation du fournisseur
|
||||||
|
const supChoiceNew = document.getElementById('sup_choice_new');
|
||||||
|
const supChoicePick = document.getElementById('sup_choice_pick');
|
||||||
|
if (supChoiceNew && supChoiceNew.checked) {
|
||||||
|
const nameInput = document.getElementById('id_new_supplier_name') || document.querySelector('input[name="new_supplier_name"]');
|
||||||
|
if (!nameInput || !nameInput.value.trim()) {
|
||||||
|
alert("{% translate 'Veuillez saisir le nom du nouveau fournisseur à créer.' %}");
|
||||||
|
if (nameInput) nameInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (supChoicePick && supChoicePick.checked) {
|
||||||
|
const supSelect = document.getElementById('id_supplier_id') || document.querySelector('select[name="supplier_id"]');
|
||||||
|
if (!supSelect || !supSelect.value.trim()) {
|
||||||
|
alert("{% translate 'Veuillez sélectionner un fournisseur existant dans la liste déroulante.' %}");
|
||||||
|
if (supSelect) supSelect.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validation des articles
|
||||||
const includeCbs = document.querySelectorAll('.item-include-cb:checked');
|
const includeCbs = document.querySelectorAll('.item-include-cb:checked');
|
||||||
if (includeCbs.length === 0) {
|
if (includeCbs.length === 0) {
|
||||||
alert("{% translate 'Veuillez cocher au moins une ligne d\'article à inclure dans le bon de commande.' %}");
|
alert("{% translate 'Veuillez cocher au moins une ligne d\'article à inclure dans le bon de commande.' %}");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{% 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">
|
<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)" %}
|
<i class="bi bi-file-earmark-text"></i> {% translate "Créer depuis une offre (PDF, Scan, Photo)" %}
|
||||||
</a>
|
</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" %}
|
||||||
|
|
|
||||||
|
|
@ -2636,12 +2636,138 @@ class PreparationOrderNumberCodificationTests(TestCase):
|
||||||
self.assertEqual(po_old.order_number, "BP-I113392-01")
|
self.assertEqual(po_old.order_number, "BP-I113392-01")
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderFromDocumentTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from common.models import UserConfig
|
||||||
|
from decimal import Decimal
|
||||||
|
from .models import Product, Supplier, Warehouse, WarehouseLocation
|
||||||
|
|
||||||
|
self.warehouse = Warehouse.objects.create(
|
||||||
|
code="WH-DOC-TEST",
|
||||||
|
name="Entrepôt Document Test",
|
||||||
|
manager="Denis",
|
||||||
|
)
|
||||||
|
self.location = WarehouseLocation.objects.create(
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
name="Emplacement Test",
|
||||||
|
code="LOC-DOC-TEST",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
self.supplier = Supplier.objects.create(
|
||||||
|
name="Fournisseur Existant Test",
|
||||||
|
enterprise_number="BE 0111.222.333",
|
||||||
|
)
|
||||||
|
self.product = Product.objects.create(
|
||||||
|
code="PROD-DOC-1",
|
||||||
|
name="Mètre Ruban CLB Magnétique 5m x 25mm",
|
||||||
|
sku="SKU-DOC-1",
|
||||||
|
supplier_reference="ZE 45525",
|
||||||
|
price=Decimal("9.28"),
|
||||||
|
)
|
||||||
|
self.superuser = User.objects.create_superuser(
|
||||||
|
username="admin_doc", password="password", email="admin_doc@test.com"
|
||||||
|
)
|
||||||
|
self.superuser_config = UserConfig.objects.create(
|
||||||
|
user=self.superuser, is_intern=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parser_detects_image_extension(self):
|
||||||
|
from .pdf_parser import PdfQuoteParser
|
||||||
|
parser_png = PdfQuoteParser("test_scan.png")
|
||||||
|
self.assertTrue(parser_png._is_image())
|
||||||
|
parser_jpg = PdfQuoteParser("photo_quote.jpg")
|
||||||
|
self.assertTrue(parser_jpg._is_image())
|
||||||
|
parser_pdf = PdfQuoteParser("quote.pdf")
|
||||||
|
self.assertFalse(parser_pdf._is_image())
|
||||||
|
|
||||||
|
def test_parser_extract_metadata_supplier_name_clean(self):
|
||||||
|
from .pdf_parser import PdfQuoteParser
|
||||||
|
sample_ocr_text = (
|
||||||
|
"INTERNATIONAL TOOLS SERVICE\n"
|
||||||
|
"S.A. N.V.\n"
|
||||||
|
"ITS-tools.com\n"
|
||||||
|
"sa International Tools Service nv\n"
|
||||||
|
"Rue du Printemps 39-43, Lentestraat - Bruxelles 1050 Brussel\n"
|
||||||
|
"TVA-BTW-VAT : BE 0447.966.784\n"
|
||||||
|
"Tel : +32 2 640 58 60\n"
|
||||||
|
"OF2026000723/\n"
|
||||||
|
"31/03/2026\n"
|
||||||
|
)
|
||||||
|
parser = PdfQuoteParser("dummy.png")
|
||||||
|
meta = parser._extract_metadata(sample_ocr_text)
|
||||||
|
self.assertEqual(meta["supplier_vat"], "BE 0447.966.784")
|
||||||
|
self.assertEqual(meta["supplier_phone"], "+32 2 640 58 60")
|
||||||
|
self.assertEqual(meta["quote_number"], "OF2026000723")
|
||||||
|
self.assertEqual(meta["date"], "31/03/2026")
|
||||||
|
self.assertIn("International Tools Service", meta["supplier_name"])
|
||||||
|
|
||||||
|
def test_view_confirm_order_creates_new_supplier(self):
|
||||||
|
from django.urls import reverse
|
||||||
|
from .models import PurchaseOrder, Supplier
|
||||||
|
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
url = reverse("stock:purchase_order_from_pdf")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"action": "confirm_order",
|
||||||
|
"warehouse_id": self.warehouse.pk,
|
||||||
|
"supplier_choice": "new",
|
||||||
|
"new_supplier_name": "Nouveau Fournisseur SRL",
|
||||||
|
"new_supplier_vat": "BE 0999.888.777",
|
||||||
|
"new_supplier_email": "contact@nouveau.be",
|
||||||
|
"new_supplier_phone": "+32 470 00 00 00",
|
||||||
|
"new_supplier_address": "10 Rue Neuve, 1000 Bruxelles",
|
||||||
|
"item_count": "1",
|
||||||
|
"item_0_include": "on",
|
||||||
|
"item_0_product_id": self.product.pk,
|
||||||
|
"item_0_ref": "ZE 45525",
|
||||||
|
"item_0_name": "Mètre ruban",
|
||||||
|
"item_0_qty": "5",
|
||||||
|
"item_0_price": "9.28",
|
||||||
|
"item_0_vat": "21.00",
|
||||||
|
"item_0_location": self.location.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(url, data)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
created_sup = Supplier.objects.filter(name="Nouveau Fournisseur SRL").first()
|
||||||
|
self.assertIsNotNone(created_sup)
|
||||||
|
self.assertEqual(created_sup.enterprise_number, "BE 0999.888.777")
|
||||||
|
self.assertEqual(created_sup.contact_email, "contact@nouveau.be")
|
||||||
|
|
||||||
|
po = PurchaseOrder.objects.filter(supplier=created_sup).first()
|
||||||
|
self.assertIsNotNone(po)
|
||||||
|
self.assertEqual(po.items.count(), 1)
|
||||||
|
self.assertEqual(po.items.first().product, self.product)
|
||||||
|
self.assertEqual(po.items.first().quantity, 5)
|
||||||
|
|
||||||
|
def test_view_confirm_order_links_existing_supplier(self):
|
||||||
|
from django.urls import reverse
|
||||||
|
from .models import PurchaseOrder
|
||||||
|
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
url = reverse("stock:purchase_order_from_pdf")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"action": "confirm_order",
|
||||||
|
"warehouse_id": self.warehouse.pk,
|
||||||
|
"supplier_choice": "existing",
|
||||||
|
"supplier_id": self.supplier.pk,
|
||||||
|
"item_count": "1",
|
||||||
|
"item_0_include": "on",
|
||||||
|
"item_0_product_id": self.product.pk,
|
||||||
|
"item_0_qty": "2",
|
||||||
|
"item_0_price": "9.28",
|
||||||
|
"item_0_vat": "21.00",
|
||||||
|
"item_0_location": self.location.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(url, data)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
po = PurchaseOrder.objects.filter(supplier=self.supplier).first()
|
||||||
|
self.assertIsNotNone(po)
|
||||||
|
self.assertEqual(po.items.count(), 1)
|
||||||
|
self.assertEqual(po.items.first().quantity, 2)
|
||||||
|
|
|
||||||
|
|
@ -1629,38 +1629,46 @@ def purchase_order_from_pdf(request):
|
||||||
action = request.POST.get('action', '')
|
action = request.POST.get('action', '')
|
||||||
|
|
||||||
if action == 'parse_pdf':
|
if action == 'parse_pdf':
|
||||||
pdf_file = request.FILES.get('pdf_file')
|
doc_file = request.FILES.get('pdf_file') or request.FILES.get('document_file')
|
||||||
warehouse_id = request.POST.get('warehouse')
|
warehouse_id = request.POST.get('warehouse')
|
||||||
if not pdf_file:
|
ALLOWED_EXTENSIONS = ('.pdf', '.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif')
|
||||||
messages.error(request, _("Veuillez sélectionner un fichier PDF."))
|
if not doc_file:
|
||||||
elif not pdf_file.name.lower().endswith('.pdf'):
|
messages.error(request, _("Veuillez sélectionner un document (PDF, scan ou photo)."))
|
||||||
messages.error(request, _("Le fichier doit obligatoirement être au format PDF."))
|
else:
|
||||||
|
file_ext = os.path.splitext(doc_file.name.lower())[1]
|
||||||
|
if file_ext not in ALLOWED_EXTENSIONS:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
_("Format de fichier non supporté. Veuillez téléverser un PDF ou une image (JPG, PNG, WEBP, TIFF).")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
|
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
|
||||||
if not warehouse:
|
if not warehouse:
|
||||||
warehouse = warehouses.first()
|
warehouse = warehouses.first()
|
||||||
|
|
||||||
# Sauvegarder dans un fichier temporaire pour pouvoir l'attacher après validation
|
# Sauvegarder dans un fichier temporaire pour pouvoir l'attacher après validation
|
||||||
fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_', suffix='.pdf')
|
fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_', suffix=file_ext)
|
||||||
with os.fdopen(fd, 'wb') as f:
|
with os.fdopen(fd, 'wb') as f:
|
||||||
for chunk in pdf_file.chunks():
|
for chunk in doc_file.chunks():
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
temp_pdf_path = tmp_path
|
temp_pdf_path = tmp_path
|
||||||
orig_pdf_name = pdf_file.name
|
orig_pdf_name = doc_file.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .pdf_parser import PdfQuoteParser
|
from .pdf_parser import PdfQuoteParser
|
||||||
parser = PdfQuoteParser(temp_pdf_path)
|
parser = PdfQuoteParser(temp_pdf_path)
|
||||||
parse_result = parser.parse()
|
parse_result = parser.parse()
|
||||||
step = 'review'
|
step = 'review'
|
||||||
if parse_result.get('is_ocr'):
|
if parse_result.get('is_image'):
|
||||||
messages.info(request, _("Le document a été analysé via le moteur OCR local (document scanné ou image)."))
|
messages.info(request, _("L'image/photo a été analysée avec succès via le moteur OCR local."))
|
||||||
|
elif parse_result.get('is_ocr'):
|
||||||
|
messages.info(request, _("Le document PDF scanné a été analysé avec succès via le moteur OCR local."))
|
||||||
else:
|
else:
|
||||||
messages.success(request, _("Le devis a été analysé avec succès. Veuillez vérifier et valider les articles ci-dessous."))
|
messages.success(request, _("Le devis a été analysé avec succès. Veuillez vérifier et valider les articles ci-dessous."))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).exception("Erreur lors de l'analyse du devis PDF")
|
logging.getLogger(__name__).exception("Erreur lors de l'analyse du document d'achat")
|
||||||
messages.error(request, _("Erreur lors de l'analyse du PDF : %(err)s") % {'err': str(e)})
|
messages.error(request, _("Erreur lors de l'analyse du document : %(err)s") % {'err': str(e)})
|
||||||
if temp_pdf_path and os.path.exists(temp_pdf_path):
|
if temp_pdf_path and os.path.exists(temp_pdf_path):
|
||||||
try:
|
try:
|
||||||
os.remove(temp_pdf_path)
|
os.remove(temp_pdf_path)
|
||||||
|
|
@ -1684,12 +1692,19 @@ def purchase_order_from_pdf(request):
|
||||||
supplier = None
|
supplier = None
|
||||||
if supplier_choice == 'existing':
|
if supplier_choice == 'existing':
|
||||||
supplier_id = request.POST.get('supplier_id')
|
supplier_id = request.POST.get('supplier_id')
|
||||||
|
if supplier_id:
|
||||||
supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first()
|
supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first()
|
||||||
|
|
||||||
if not supplier:
|
if not supplier:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
_("Veuillez sélectionner un fournisseur existant valide dans la liste ou choisir de le créer.")
|
||||||
|
)
|
||||||
|
return redirect('stock:purchase_order_from_pdf')
|
||||||
|
else:
|
||||||
new_sup_name = request.POST.get('new_supplier_name', '').strip()
|
new_sup_name = request.POST.get('new_supplier_name', '').strip()
|
||||||
if not new_sup_name:
|
if not new_sup_name:
|
||||||
new_sup_name = _("Fournisseur sans nom")
|
messages.error(request, _("Veuillez renseigner le nom du nouveau fournisseur à créer."))
|
||||||
|
return redirect('stock:purchase_order_from_pdf')
|
||||||
supplier = Supplier.objects.create(
|
supplier = Supplier.objects.create(
|
||||||
name=new_sup_name,
|
name=new_sup_name,
|
||||||
enterprise_number=request.POST.get('new_supplier_vat', '').strip() or None,
|
enterprise_number=request.POST.get('new_supplier_vat', '').strip() or None,
|
||||||
|
|
@ -1698,7 +1713,7 @@ def purchase_order_from_pdf(request):
|
||||||
address=request.POST.get('new_supplier_address', '').strip() or None,
|
address=request.POST.get('new_supplier_address', '').strip() or None,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
messages.info(request, _("Nouveau fournisseur créé : %(name)s") % {'name': supplier.name})
|
messages.success(request, _("Nouveau fournisseur « %(name)s » créé avec succès.") % {'name': supplier.name})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item_count = int(request.POST.get('item_count', 0))
|
item_count = int(request.POST.get('item_count', 0))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue