feat(mobile): add quote photo/pdf OCR import interface in mobile stock

- Add mobile-optimized purchase order from quote view and template
- Support taking multiple page photos directly from smartphone camera
- Display responsive article cards with catalog product matching
- Provide sticky summary with live totals and confirmation CTA
- Link from mobile purchases list and add unit tests
This commit is contained in:
kdeterme 2026-10-01 15:43:52 +02:00
parent 53d579009a
commit 4bb53fb71c
5 changed files with 1067 additions and 0 deletions

View file

@ -221,3 +221,120 @@ class MobileInterventionDetailStockTests(TestCase):
self.assertIn("× 3", payload["html"])
class MobilePurchaseOrderFromPdfTests(TestCase):
def setUp(self):
from stock.models import Warehouse, WarehouseLocation, Supplier, Product
from decimal import Decimal
self.user = User.objects.create_superuser(username="admin_mob_stock", password="password", email="admin_mob@test.com")
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
admin_role, _ = Role.objects.get_or_create(name="admin")
self.user_config.roles.add(admin_role)
self.client.login(username="admin_mob_stock", password="password")
self.warehouse = Warehouse.objects.create(code="WH-MOB", name="Entrepôt Mobile")
self.location = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Emplacement Mob", code="LOC-MOB", is_default=True)
self.supplier = Supplier.objects.create(name="Fournisseur Mobile Test", enterprise_number="BE 0123.456.789")
self.product = Product.objects.create(
code="PROD-MOB-1",
name="Mètre Ruban Test Mobile",
sku="SKU-MOB-1",
price=Decimal("15.50"),
)
def test_mobile_purchases_list_has_import_button(self):
response = self.client.get(reverse("mobile:stock_purchases_mobile"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse("mobile:purchase_order_from_pdf_mobile"))
def test_mobile_purchase_order_from_pdf_upload_page_get(self):
url = reverse("mobile:purchase_order_from_pdf_mobile")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["step"], "upload")
self.assertContains(response, "Entrepôt Mobile")
def test_mobile_purchase_order_from_pdf_parse_post(self):
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
url = reverse("mobile:purchase_order_from_pdf_mobile")
file1 = SimpleUploadedFile("photo1.jpg", b"\xff\xd8\xff\xe0fakejpeg", content_type="image/jpeg")
mock_res = {
'is_ocr': True,
'is_image': True,
'is_multi_image': False,
'image_count': 1,
'total_items': 1,
'metadata': {'supplier_name': 'Fournisseur Mobile Test', 'quote_number': 'MOB-OF-1'},
'matched_supplier': self.supplier,
'items': [{
'reference': 'REF-1',
'name': 'Mètre Ruban Test Mobile',
'quantity': 2,
'unit': 'piece',
'raw_unit': 'Piece',
'gross_unit_price': 15.50,
'discount': '0%',
'unit_price': 15.50,
'total_amount': 31.00,
'vat_rate': 21.0,
'matched_product': self.product,
}],
}
with patch("stock.pdf_parser.PdfQuoteParser.parse", return_value=mock_res):
response = self.client.post(url, {
"action": "parse_pdf",
"warehouse": self.warehouse.pk,
"pdf_file": [file1],
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["step"], "review")
self.assertContains(response, "MOB-OF-1")
def test_mobile_purchase_order_from_pdf_confirm_post(self):
import json
import os
import tempfile
from stock.models import PurchaseOrder, PurchaseOrderDocument
url = reverse("mobile:purchase_order_from_pdf_mobile")
fd, tmp_p = tempfile.mkstemp(prefix="mob_photo_", suffix=".jpg")
with os.fdopen(fd, "wb") as f:
f.write(b"photo mobile content")
data = {
"action": "confirm_order",
"warehouse_id": self.warehouse.pk,
"supplier_choice": "existing",
"supplier_id": self.supplier.pk,
"temp_files_json": json.dumps([{"path": tmp_p, "name": "Photo_mobile_1.jpg"}]),
"item_count": "1",
"item_0_include": "on",
"item_0_product_id": self.product.pk,
"item_0_ref": "REF-1",
"item_0_name": "Mètre Ruban",
"item_0_qty": "4",
"item_0_price": "15.50",
"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(warehouse=self.warehouse).last()
self.assertIsNotNone(po)
self.assertRedirects(response, reverse("mobile:stock_purchase_order_detail_mobile", args=[po.pk]))
self.assertEqual(po.items.count(), 1)
self.assertEqual(po.items.first().quantity, 4)
self.assertEqual(po.documents.count(), 1)
self.assertEqual(po.documents.first().name, "Photo_mobile_1.jpg")
self.assertFalse(os.path.exists(tmp_p))

View file

@ -62,6 +62,7 @@ urlpatterns = [
path("stock/inventories/new/", views.stock_new_inventory_mobile, name='stock_new_inventory_mobile'),
path("stock/inventories/<int:pk>/", views.stock_inventory_detail_mobile, name='stock_inventory_detail_mobile'),
path("stock/purchases/", views.stock_purchases_mobile, name='stock_purchases_mobile'),
path("stock/purchases/from-pdf/", views.purchase_order_from_pdf_mobile, name='purchase_order_from_pdf_mobile'),
path("stock/purchases/<int:pk>/", views.stock_purchase_order_detail_mobile, name='stock_purchase_order_detail_mobile'),
# QR Code scanner
path("qr-scanner/", views.qr_scanner, name='qr_scanner'),

View file

@ -1053,6 +1053,307 @@ def stock_purchase_order_detail_mobile(request, pk):
})
@login_required
@stock_access_required
def purchase_order_from_pdf_mobile(request):
"""
Création mobile d'un bon de commande à partir d'une offre (photos smartphone ou PDF).
Version tactile optimisée pour smartphone.
"""
import os
import re
import json
import tempfile
from decimal import Decimal
from django.db import transaction
from django.urls import reverse
from django.core.files.base import ContentFile
from stock.models import (
Warehouse, WarehouseLocation, Supplier, Product,
ProductCategory, PurchaseOrder, PurchaseOrderItem, PurchaseOrderDocument
)
from stock.permissions import get_user_accessible_warehouses
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('mobile:stock_purchases_mobile')
except (UserConfig.DoesNotExist, AttributeError):
messages.error(request, _("Vous n'avez pas les droits pour créer des bons de commande."))
return redirect('mobile:stock_purchases_mobile')
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('mobile:stock_purchases_mobile')
step = 'upload'
parse_result = None
warehouse = None
temp_pdf_path = ''
orig_pdf_name = ''
temp_files_json = '[]'
saved_temp_files = []
if request.method == 'POST':
action = request.POST.get('action', '')
if action == 'parse_pdf':
doc_files = request.FILES.getlist('pdf_file') or request.FILES.getlist('document_file')
warehouse_id = request.POST.get('warehouse')
ALLOWED_EXTENSIONS = ('.pdf', '.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif')
if not doc_files:
messages.error(request, _("Veuillez sélectionner ou prendre en photo votre document."))
else:
invalid_files = [f.name for f in doc_files if os.path.splitext(f.name.lower())[1] not in ALLOWED_EXTENSIONS]
if invalid_files:
messages.error(
request,
_("Format non supporté pour : %(files)s. Utilisez des photos (JPG, PNG) ou PDF.")
% {'files': ', '.join(invalid_files[:3])}
)
else:
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
if not warehouse:
warehouse = warehouses.first()
# Trier naturellement les fichiers s'il y en a plusieurs (ex: page_1, page_2...)
if len(doc_files) > 1:
def _nat_key(f):
name = getattr(f, 'name', '') or str(f)
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)]
doc_files = sorted(doc_files, key=_nat_key)
saved_temp_files = []
for f in doc_files:
file_ext = os.path.splitext(f.name.lower())[1]
fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_mob_', suffix=file_ext)
with os.fdopen(fd, 'wb') as out_f:
for chunk in f.chunks():
out_f.write(chunk)
saved_temp_files.append({'path': tmp_path, 'name': f.name})
temp_paths = [item['path'] for item in saved_temp_files]
orig_names = [item['name'] for item in saved_temp_files]
temp_pdf_path = temp_paths[0] if temp_paths else ''
orig_pdf_name = orig_names[0] if orig_names else ''
temp_files_json = json.dumps(saved_temp_files)
try:
from stock.pdf_parser import PdfQuoteParser
parser = PdfQuoteParser(temp_paths)
parse_result = parser.parse()
step = 'review'
if parse_result.get('is_multi_image'):
messages.info(
request,
_("%(count)d photos de pages ont été combinées et analysées avec succès via l'OCR local.")
% {'count': parse_result.get('image_count', len(temp_paths))}
)
elif parse_result.get('is_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:
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 mobile de l'offre")
messages.error(request, _("Erreur lors de l'analyse : %(err)s") % {'err': str(e)})
for p in temp_paths:
if p and os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
temp_paths = []
saved_temp_files = []
temp_pdf_path = ''
orig_pdf_name = ''
temp_files_json = '[]'
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('mobile:stock_purchases_mobile')
temp_pdf_path = request.POST.get('temp_pdf_path', '')
orig_pdf_name = request.POST.get('orig_pdf_name', '')
temp_files_json = request.POST.get('temp_files_json', '')
files_to_attach = []
if temp_files_json:
try:
files_to_attach = json.loads(temp_files_json)
except Exception:
files_to_attach = []
if not files_to_attach and temp_pdf_path:
files_to_attach = [{'path': temp_pdf_path, 'name': orig_pdf_name}]
supplier_choice = request.POST.get('supplier_choice', 'existing')
supplier = None
if supplier_choice == 'existing':
supplier_id = request.POST.get('supplier_id')
if supplier_id:
supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first()
if not supplier:
messages.error(request, _("Veuillez sélectionner un fournisseur existant valide dans la liste ou choisir de le créer."))
return redirect('mobile:purchase_order_from_pdf_mobile')
else:
new_sup_name = request.POST.get('new_supplier_name', '').strip()
if not new_sup_name:
messages.error(request, _("Veuillez renseigner le nom du nouveau fournisseur à créer."))
return redirect('mobile:purchase_order_from_pdf_mobile')
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.success(request, _("Nouveau fournisseur « %(name)s » créé avec succè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,
)
created_items_count = 0
unassociated_items = []
for i in range(item_count):
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")
prod_id = request.POST.get(f'item_{i}_product_id')
product = Product.objects.filter(pk=prod_id, is_active=True).first() if prod_id else None
if not product:
unassociated_items.append(name)
continue
try:
qty = int(float(request.POST.get(f'item_{i}_qty', 1)))
except (ValueError, TypeError):
qty = 1
if qty <= 0:
qty = 1
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()
PurchaseOrderItem.objects.create(
purchase_order=order,
product=product,
location=location,
quantity=qty,
unit_price=unit_price,
vat_rate=vat_rate,
)
created_items_count += 1
if created_items_count == 0:
transaction.set_rollback(True)
messages.error(
request,
_("Aucun article n'a pu être ajouté car aucun produit existant n'a été associé. Veuillez sélectionner des produits existants.")
)
return redirect('mobile:purchase_order_from_pdf_mobile')
for idx, file_info in enumerate(files_to_attach, start=1):
f_path = file_info.get('path', '')
f_name = file_info.get('name', '')
if f_path and os.path.exists(f_path):
try:
with open(f_path, 'rb') as f:
doc_title = f_name or f"Offre_{order.code}_p{idx}.pdf"
po_doc = PurchaseOrderDocument(
purchase_order=order,
name=doc_title,
)
po_doc.file.save(doc_title, ContentFile(f.read()), save=True)
os.remove(f_path)
except Exception:
import logging
logging.getLogger(__name__).exception("Erreur lors de l'attachement du document mobile 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 unassociated_items:
messages.info(
request,
_("%(count)d ligne(s) non associée(s) ont été ignorée(s) : %(names)s.")
% {
'count': len(unassociated_items),
'names': ', '.join(unassociated_items[:2]) + ('...' if len(unassociated_items) > 2 else '')
}
)
return redirect('mobile:stock_purchase_order_detail_mobile', 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/mobile/purchase_order_from_pdf_mobile.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,
'temp_files_json': temp_files_json,
'saved_temp_files': saved_temp_files,
'cancel_url': reverse('mobile:stock_purchases_mobile'),
})
@login_required
@stock_access_required
def stock_preparation_order_detail_mobile(request, pk):

View file

@ -0,0 +1,635 @@
{% extends 'mobile/mobile_base.html' %}
{% load static %}
{% load i18n %}
{% block title %}
{% if step == 'upload' %}
{% translate "Importer une offre" %}
{% else %}
{% translate "Validation de l'offre" %}
{% endif %}
{% endblock title %}
{% block upleft %}
{% if step == 'upload' %}
<a href="{% url 'mobile:stock_purchases_mobile' %}" class="nav-link" style="color: white;">
<i class="bi bi-arrow-left fs-3"></i>
</a>
{% else %}
<a href="{% url 'mobile:purchase_order_from_pdf_mobile' %}" class="nav-link" style="color: white;" onclick="return confirm('{% translate "Voulez-vous recommencer l\'analyse ?" %}');">
<i class="bi bi-arrow-left fs-3"></i>
</a>
{% endif %}
{% endblock upleft %}
{% block upright %}
{% if step == 'review' %}
<a href="{% url 'mobile:purchase_order_from_pdf_mobile' %}" class="btn btn-sm btn-outline-light text-white" title="{% translate 'Recommencer' %}">
<i class="bi bi-arrow-repeat"></i>
</a>
{% endif %}
{% endblock upright %}
{% block extra_head %}
<style>
.mobile-upload-box {
border: 2px dashed #0d6efd;
border-radius: 1rem;
background: #f8faff;
padding: 1.5rem 1rem;
text-align: center;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.mobile-upload-box:active {
background: #e8f0fe;
}
.mobile-item-card {
border-radius: 0.75rem;
border: 1px solid #dee2e6;
background: #fff;
margin-bottom: 0.85rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
overflow: hidden;
}
.mobile-item-card.unassociated {
border-color: #ffc107;
background: #fffdf5;
}
.mobile-item-card.excluded {
opacity: 0.55;
background: #f8f9fa;
}
.sticky-bottom-summary {
position: sticky;
bottom: 3.5rem;
z-index: 1020;
background: #ffffff;
border-top: 1px solid #dee2e6;
box-shadow: 0 -4px 12px rgba(0,0,0,0.08);
border-radius: 1rem 1rem 0 0;
padding: 0.75rem 1rem;
margin: 0 -0.75rem -0.75rem -0.75rem;
}
</style>
{% endblock extra_head %}
{% block content %}
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% elif message.tags %}{{ message.tags }}{% else %}info{% endif %} alert-dismissible fade show small" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% if step == 'upload' %}
<!-- ÉTAPE 1 : TÉLÉVERSEMENT MOBILE (PHOTOS OU PDF) -->
<form method="post" enctype="multipart/form-data" id="mobile-upload-form">
{% csrf_token %}
<input type="hidden" name="action" value="parse_pdf">
<!-- Choix de l'entrepôt -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body p-3">
<label for="id_warehouse" class="form-label fw-semibold small text-muted mb-1">
<i class="bi bi-building me-1 text-primary"></i> {% translate "Entrepôt de livraison" %} <span class="text-danger">*</span>
</label>
<select name="warehouse" id="id_warehouse" class="form-select" required>
{% for w in warehouses %}
<option value="{{ w.id }}" {% if forloop.first %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Zone de capture / sélection de photo(s) -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body p-3">
<label class="form-label fw-semibold small text-muted mb-2">
<i class="bi bi-camera me-1 text-primary"></i> {% translate "Photos ou document de l'offre" %} <span class="text-danger">*</span>
</label>
<div class="mobile-upload-box" id="drop-zone">
<div class="mb-2">
<span class="d-inline-flex align-items-center justify-content-center bg-primary bg-opacity-10 text-primary rounded-circle" style="width: 56px; height: 56px;">
<i class="bi bi-camera fs-2"></i>
</span>
</div>
<div class="fw-bold text-dark fs-6 mb-1">
{% translate "Prendre en photo / Importer" %}
</div>
<div class="text-muted small mb-2">
{% translate "Prenez une photo de chaque page ou importez vos photos/PDF (sélection multiple autorisée)." %}
</div>
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-cpu me-1"></i>{% translate "OCR 100% local sur serveur" %}
</span>
<input type="file" name="pdf_file" id="id_pdf_file" class="d-none" accept=".pdf,image/*,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.tif" multiple required>
</div>
<!-- Liste des fichiers sélectionnés -->
<div id="selected-files-container" class="mt-3 d-none">
<div class="small fw-semibold text-primary mb-1 d-flex align-items-center">
<i class="bi bi-check-circle-fill me-1"></i>
<span id="selected-files-summary"></span>
</div>
<div id="selected-files-pills" class="d-flex flex-wrap gap-1"></div>
</div>
</div>
</div>
<!-- Info règle de gestion -->
<div class="alert alert-info border-0 shadow-sm py-2 px-3 small d-flex align-items-start mb-3">
<i class="bi bi-info-circle-fill text-primary fs-5 me-2 flex-shrink-0 mt-1"></i>
<div>
<strong>{% translate "Rapprochement automatique :" %}</strong><br>
{% translate "Les lignes détectées sont automatiquement rapprochées de vos produits en stock. Si le fournisseur est nouveau, vous pourrez confirmer sa création." %}
</div>
</div>
<!-- Bouton d'analyse -->
<div class="d-grid mb-4">
<button type="submit" class="btn btn-primary btn-lg py-3 fw-bold shadow-sm" id="btn-submit-parse">
<i class="bi bi-cpu me-2"></i> {% translate "Analyser l'offre" %}
</button>
</div>
</form>
{% elif step == 'review' %}
<!-- ÉTAPE 2 : REVUE ET VALIDATION DU BON DE COMMANDE -->
<form method="post" id="mobile-confirm-order-form" onsubmit="return validateMobileOrderForm()">
{% 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="temp_files_json" value="{{ temp_files_json }}">
<input type="hidden" name="item_count" value="{{ parse_result.items|length }}">
<!-- Carte Document & Fournisseur -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="fw-bold small text-dark"><i class="bi bi-building text-primary me-1"></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 "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 "Non trouvé" %}
</span>
{% endif %}
</div>
<div class="card-body p-3">
{% if parse_result.matched_supplier %}
<div class="form-check mb-2">
<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="small text-muted ps-4">
{% if parse_result.matched_supplier.enterprise_number %}
<div><strong>{% translate "TVA" %} :</strong> {{ parse_result.matched_supplier.enterprise_number }}</div>
{% endif %}
</div>
{% else %}
<div class="border rounded-3 p-2 bg-light 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 onchange="toggleMobileSupplierChoice()">
<label class="form-check-label fw-bold text-success" for="sup_choice_new">
<i class="bi bi-plus-circle me-1"></i> {% translate "Créer ce nouveau fournisseur" %}
</label>
</div>
<div id="box-create-supplier" class="ps-3">
<div class="mb-2">
<label class="form-label small fw-semibold mb-0">{% translate "Nom du fournisseur" %} *</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 'Nom de l\'entreprise' %}" required>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small text-muted mb-0">{% translate "TVA / Entreprise" %}</label>
<input type="text" name="new_supplier_vat" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_vat }}">
</div>
<div class="col-6">
<label class="form-label small text-muted mb-0">{% 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>
<div class="mb-2">
<label class="form-label small text-muted mb-0">{% translate "Email" %}</label>
<input type="email" name="new_supplier_email" class="form-control form-control-sm" value="{{ parse_result.metadata.supplier_email }}">
</div>
</div>
</div>
<div class="border rounded-3 p-2 bg-white">
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="supplier_choice" id="sup_choice_pick" value="existing" onchange="toggleMobileSupplierChoice()">
<label class="form-check-label fw-bold text-secondary small" for="sup_choice_pick">
{% translate "Ou rattacher à un fournisseur existant :" %}
</label>
</div>
<div class="ps-3">
<select name="supplier_id" id="id_supplier_id" class="form-select form-select-sm" onfocus="document.getElementById('sup_choice_pick').checked = true; toggleMobileSupplierChoice();">
<option value="">{% translate "— Sélectionner un fournisseur —" %}</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }}{% if s.enterprise_number %} ({{ s.enterprise_number }}){% endif %}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Carte Détails Commande -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body p-3">
<div class="row g-2 small">
<div class="col-6">
<span class="text-muted d-block">{% translate "Entrepôt :" %}</span>
<strong>{{ selected_warehouse.name }}</strong>
</div>
<div class="col-6">
<span class="text-muted d-block">{% translate "N° Devis :" %}</span>
<strong class="font-monospace">{{ parse_result.metadata.quote_number|default:"—" }}</strong>
</div>
<div class="col-6">
<span class="text-muted d-block">{% translate "Statut initial :" %}</span>
<select name="order_status" class="form-select form-select-sm py-0 mt-1">
<option value="pending" selected>{% translate "En attente" %}</option>
<option value="ordered">{% translate "Envoyée" %}</option>
</select>
</div>
<div class="col-6">
<span class="text-muted d-block">{% translate "Date de l'offre :" %}</span>
<strong>{{ parse_result.metadata.date|default:"—" }}</strong>
</div>
</div>
<!-- Badges des photos/pages analysées -->
<div class="mt-2 pt-2 border-top d-flex align-items-center flex-wrap gap-1">
{% if parse_result.is_multi_image %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
<i class="bi bi-images me-1"></i>{% blocktranslate with count=parse_result.image_count %}{{ count }} photos combinées{% endblocktranslate %}
</span>
{% elif 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 (OCR local)" %}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-file-earmark-pdf me-1"></i>{% translate "PDF" %}
</span>
{% endif %}
{% if saved_temp_files and saved_temp_files|length > 1 %}
{% for f in saved_temp_files %}
<span class="badge bg-light text-dark border small">{{ f.name }}</span>
{% endfor %}
{% endif %}
</div>
</div>
</div>
<!-- En-tête de section articles & boutons cocher tout -->
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
<div class="fw-bold small text-dark">
<i class="bi bi-box-seam text-primary me-1"></i>
{% translate "Articles extraits" %} ({{ parse_result.items|length }})
</div>
<div class="d-flex gap-1">
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;" onclick="toggleSelectAllMobile(true)">
{% translate "Tout cocher" %}
</button>
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;" onclick="toggleSelectAllMobile(false)">
{% translate "Tout décocher" %}
</button>
</div>
</div>
<!-- LISTE DES ARTICLES EXTRAITS (CARTE PAR ARTICLE) -->
<div id="mobile-items-container">
{% for item in parse_result.items %}
{% with idx=forloop.counter0 %}
<div class="mobile-item-card {% if not item.matched_product %}unassociated{% endif %}" id="mobile-item-card-{{ idx }}">
<!-- En-tête de carte article -->
<div class="p-2 border-bottom bg-light-subtle d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<input class="form-check-input item-include-cb m-0" type="checkbox" name="item_{{ idx }}_include" id="item_{{ idx }}_include" data-idx="{{ idx }}" checked onchange="toggleItemCardState({{ idx }})">
<label class="form-check-label fw-bold small m-0" for="item_{{ idx }}_include">
#{{ forloop.counter }}
{% if item.reference %}
<span class="badge bg-light text-dark border ms-1 font-monospace">{{ item.reference }}</span>
{% endif %}
</label>
</div>
<div>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle fw-semibold item-total-badge" id="badge-total-{{ idx }}" data-total="{{ item.total_amount|default:0 }}" data-vat="{{ item.vat_rate|default:21 }}">
{{ item.total_amount|stringformat:".2f" }} €
</span>
</div>
</div>
<!-- Corps de carte article -->
<div class="p-2">
<!-- Désignation extraite -->
<div class="small fw-semibold text-dark mb-1">
{{ item.name }}
</div>
<!-- Hidden fields nécessaires pour la soumission -->
<input type="hidden" name="item_{{ idx }}_ref" value="{{ item.reference }}">
<input type="hidden" name="item_{{ idx }}_name" value="{{ item.name }}">
<!-- Rapprochement produit du catalogue -->
<div class="mb-2">
{% if item.matched_product %}
<div class="badge bg-success-subtle text-success border border-success-subtle mb-1 w-100 text-start text-truncate" id="matched-label-{{ idx }}">
<i class="bi bi-check-circle me-1"></i>{% translate "Associé à :" %} {{ item.matched_product.name }}
</div>
{% else %}
<div class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle mb-1 w-100 text-start text-truncate" id="matched-label-{{ idx }}">
<i class="bi bi-exclamation-triangle me-1"></i>{% translate "Produit non associé — Sélectionner ci-dessous :" %}
</div>
{% endif %}
<select name="item_{{ idx }}_product_id" id="item_{{ idx }}_product_id" class="form-select form-select-sm" data-idx="{{ idx }}" onchange="handleMobileProductChange({{ idx }})">
<option value="">{% translate "— Choisir un produit du catalogue —" %}</option>
{% for p in products %}
<option value="{{ p.id }}" {% if item.matched_product and item.matched_product.id == p.id %}selected{% endif %}>
{{ p.name }} ({{ p.code }})
</option>
{% endfor %}
</select>
</div>
<!-- Quantité, Prix unitaire, Emplacement -->
<div class="row g-2">
<div class="col-4">
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "Qté" %}</label>
<input type="number" step="any" min="1" name="item_{{ idx }}_qty" id="item_{{ idx }}_qty" class="form-control form-control-sm item-qty-input" data-idx="{{ idx }}" value="{{ item.quantity|default:1 }}" onchange="recalcMobileItemTotal({{ idx }})">
</div>
<div class="col-4">
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "P.U. HT (€)" %}</label>
<input type="text" name="item_{{ idx }}_price" id="item_{{ idx }}_price" class="form-control form-control-sm item-price-input" data-idx="{{ idx }}" value="{{ item.unit_price|stringformat:'.2f' }}" onchange="recalcMobileItemTotal({{ idx }})">
</div>
<div class="col-4">
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "TVA %" %}</label>
<input type="text" name="item_{{ idx }}_vat" id="item_{{ idx }}_vat" class="form-control form-control-sm item-vat-input" data-idx="{{ idx }}" value="{{ item.vat_rate|default:21 }}" onchange="recalcMobileItemTotal({{ idx }})">
</div>
<div class="col-12 mt-1">
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "Emplacement de stockage" %}</label>
<select name="item_{{ idx }}_location" class="form-select form-select-sm">
{% for loc in locations %}
<option value="{{ loc.id }}" {% if default_location and default_location.id == loc.id %}selected{% endif %}>{{ loc.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
<!-- RÉSUMÉ FIXE DU TOTAL ET BOUTON DE VALIDATION -->
<div class="sticky-bottom-summary">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="small">
<span class="text-muted">{% translate "Total HT :" %}</span> <strong id="summary-total-ht">0.00 €</strong>
<span class="text-muted ms-2">{% translate "TVA :" %}</span> <span id="summary-total-tva">0.00 €</span>
</div>
<div class="text-end">
<span class="small text-muted">{% translate "Total TTC :" %}</span>
<span class="fw-bold fs-5 text-primary ms-1" id="summary-total-ttc">0.00 €</span>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg py-3 fw-bold shadow" id="btn-submit-confirm">
<i class="bi bi-cart-check me-2"></i> {% translate "Créer le bon de commande" %}
</button>
</div>
</div>
</form>
{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// ── GESTION DE L'UPLOAD EN ÉTAPE 1 ──
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('id_pdf_file');
const filesContainer = document.getElementById('selected-files-container');
const filesSummary = document.getElementById('selected-files-summary');
const filesPills = document.getElementById('selected-files-pills');
const uploadForm = document.getElementById('mobile-upload-form');
const btnSubmitParse = document.getElementById('btn-submit-parse');
if (dropZone && fileInput) {
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', function() {
if (fileInput.files.length > 0) {
const count = fileInput.files.length;
let totalBytes = 0;
filesPills.innerHTML = '';
for (let i = 0; i < count; i++) {
const f = fileInput.files[i];
totalBytes += f.size;
const pill = document.createElement('span');
pill.className = 'badge bg-light text-dark border';
pill.innerHTML = '<i class="bi bi-file-earmark-image text-primary me-1"></i>' + f.name;
filesPills.appendChild(pill);
}
const totalSizeStr = (totalBytes / 1024 > 1024)
? (totalBytes / (1024 * 1024)).toFixed(2) + ' MB'
: (totalBytes / 1024).toFixed(1) + ' KB';
filesSummary.textContent = count + ' {% translate "photo(s) / document(s) sélectionné(s)" %} (' + totalSizeStr + ')';
filesContainer.classList.remove('d-none');
}
});
if (uploadForm && btnSubmitParse) {
uploadForm.addEventListener('submit', function() {
btnSubmitParse.disabled = true;
btnSubmitParse.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>{% translate "Analyse OCR locale en cours..." %}';
});
}
}
// ── GESTION DE LA REVUE EN ÉTAPE 2 ──
updateMobileTotals();
});
function toggleMobileSupplierChoice() {
const isNew = document.getElementById('sup_choice_new') && document.getElementById('sup_choice_new').checked;
const boxCreate = document.getElementById('box-create-supplier');
const inputName = document.getElementById('id_new_supplier_name');
if (boxCreate) {
if (isNew) {
boxCreate.classList.remove('d-none');
if (inputName) inputName.required = true;
} else {
boxCreate.classList.add('d-none');
if (inputName) inputName.required = false;
}
}
}
function toggleSelectAllMobile(checked) {
document.querySelectorAll('.item-include-cb').forEach(cb => {
cb.checked = checked;
const idx = cb.dataset.idx;
toggleItemCardState(idx);
});
updateMobileTotals();
}
function toggleItemCardState(idx) {
const cb = document.getElementById('item_' + idx + '_include');
const card = document.getElementById('mobile-item-card-' + idx);
if (card && cb) {
if (cb.checked) {
card.classList.remove('excluded');
} else {
card.classList.add('excluded');
}
}
updateMobileTotals();
}
function handleMobileProductChange(idx) {
const select = document.getElementById('item_' + idx + '_product_id');
const cb = document.getElementById('item_' + idx + '_include');
const card = document.getElementById('mobile-item-card-' + idx);
const label = document.getElementById('matched-label-' + idx);
if (select && select.value && select.value.trim() !== '') {
if (cb) cb.checked = true;
if (card) {
card.classList.remove('unassociated', 'excluded');
}
if (label) {
const selectedText = select.options[select.selectedIndex].text;
label.className = 'badge bg-success-subtle text-success border border-success-subtle mb-1 w-100 text-start text-truncate';
label.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Associé à :" %} ' + selectedText;
}
} else {
if (card) card.classList.add('unassociated');
if (label) {
label.className = 'badge bg-warning-subtle text-warning-emphasis border border-warning-subtle mb-1 w-100 text-start text-truncate';
label.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>{% translate "Produit non associé" %}';
}
}
updateMobileTotals();
}
function recalcMobileItemTotal(idx) {
const qtyInput = document.getElementById('item_' + idx + '_qty');
const priceInput = document.getElementById('item_' + idx + '_price');
const vatInput = document.getElementById('item_' + idx + '_vat');
const badge = document.getElementById('badge-total-' + idx);
const qty = parseFloat(qtyInput ? qtyInput.value : 1) || 1;
const price = parseFloat(priceInput ? priceInput.value.replace(',', '.') : 0) || 0;
const vat = parseFloat(vatInput ? vatInput.value.replace(',', '.') : 21) || 21;
const total = qty * price;
if (badge) {
badge.setAttribute('data-total', total.toFixed(2));
badge.setAttribute('data-vat', vat);
badge.textContent = total.toFixed(2) + ' €';
}
updateMobileTotals();
}
function updateMobileTotals() {
let totalHt = 0;
let totalTva = 0;
document.querySelectorAll('.item-include-cb:checked').forEach(cb => {
const idx = cb.dataset.idx;
const badge = document.getElementById('badge-total-' + idx);
if (badge) {
const total = parseFloat(badge.getAttribute('data-total')) || 0;
const vat = parseFloat(badge.getAttribute('data-vat')) || 21;
totalHt += total;
totalTva += total * (vat / 100.0);
}
});
const htEl = document.getElementById('summary-total-ht');
const tvaEl = document.getElementById('summary-total-tva');
const ttcEl = document.getElementById('summary-total-ttc');
if (htEl) htEl.textContent = totalHt.toFixed(2) + ' €';
if (tvaEl) tvaEl.textContent = totalTva.toFixed(2) + ' €';
if (ttcEl) ttcEl.textContent = (totalHt + totalTva).toFixed(2) + ' €';
}
function validateMobileOrderForm() {
// 1. 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');
if (!nameInput || !nameInput.value.trim()) {
alert("{% translate 'Veuillez saisir le nom du nouveau fournisseur.' %}");
if (nameInput) nameInput.focus();
return false;
}
} else if (supChoicePick && supChoicePick.checked) {
const supSelect = document.getElementById('id_supplier_id');
if (!supSelect || !supSelect.value.trim()) {
alert("{% translate 'Veuillez sélectionner un fournisseur existant dans la liste.' %}");
if (supSelect) supSelect.focus();
return false;
}
}
// 2. Articles cochés
const checkedBoxes = document.querySelectorAll('.item-include-cb:checked');
if (checkedBoxes.length === 0) {
alert("{% translate 'Veuillez cocher au moins une ligne d\'article à inclure dans la commande.' %}");
return false;
}
// 3. Articles non associés
let unassociatedCount = 0;
let firstInvalid = null;
checkedBoxes.forEach(cb => {
const idx = cb.dataset.idx;
const select = document.getElementById('item_' + idx + '_product_id');
if (!select || !select.value || select.value.trim() === '') {
unassociatedCount++;
if (select) select.classList.add('is-invalid');
if (!firstInvalid) firstInvalid = select;
}
});
if (unassociatedCount > 0) {
alert("{% translate 'Certaines lignes cochées ne sont associées à aucun produit du catalogue. Veuillez sélectionner un produit pour chaque ligne cochée, ou la décocher.' %}");
if (firstInvalid) firstInvalid.focus();
return false;
}
const btnSubmit = document.getElementById('btn-submit-confirm');
if (btnSubmit) {
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>{% translate "Création du bon de commande..." %}';
}
return true;
}
</script>
{% endblock content %}

View file

@ -10,6 +10,12 @@
</a>
{% endblock upleft %}
{% block upright %}
<a href="{% url 'mobile:purchase_order_from_pdf_mobile' %}" class="btn btn-sm btn-primary text-white d-flex align-items-center" style="font-size: 0.85rem;">
<i class="bi bi-camera me-1"></i> {% translate "Importer" %}
</a>
{% endblock upright %}
{% block extra_head %}
<style>
.list-card {
@ -35,6 +41,13 @@
{% endblock extra_head %}
{% block content %}
<div class="mb-3">
<a href="{% url 'mobile:purchase_order_from_pdf_mobile' %}" class="btn btn-outline-primary w-100 py-2 d-flex align-items-center justify-content-center fw-semibold shadow-sm" style="border-radius: 0.75rem;">
<i class="bi bi-camera-fill fs-5 me-2"></i>
{% translate "Importer une offre (Photo / PDF)" %}
</a>
</div>
{% if purchase_orders %}
{% for order in purchase_orders %}
<a href="{% url 'mobile:stock_purchase_order_detail_mobile' order.pk %}" class="list-card">