From 4bb53fb71c47357410f03418b4a87c6666ca99aa Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 1 Oct 2026 15:43:52 +0200 Subject: [PATCH] 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 --- loko/mobile/tests.py | 117 ++++ loko/mobile/urls.py | 1 + loko/mobile/views.py | 301 +++++++++ .../purchase_order_from_pdf_mobile.html | 635 ++++++++++++++++++ .../stock/mobile/stock_purchases_mobile.html | 13 + 5 files changed, 1067 insertions(+) create mode 100644 loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html diff --git a/loko/mobile/tests.py b/loko/mobile/tests.py index 8541e1e..6b10ce9 100644 --- a/loko/mobile/tests.py +++ b/loko/mobile/tests.py @@ -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)) + + diff --git a/loko/mobile/urls.py b/loko/mobile/urls.py index 9df663c..9871f39 100644 --- a/loko/mobile/urls.py +++ b/loko/mobile/urls.py @@ -62,6 +62,7 @@ urlpatterns = [ path("stock/inventories/new/", views.stock_new_inventory_mobile, name='stock_new_inventory_mobile'), path("stock/inventories//", 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//", views.stock_purchase_order_detail_mobile, name='stock_purchase_order_detail_mobile'), # QR Code scanner path("qr-scanner/", views.qr_scanner, name='qr_scanner'), diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 9190776..92cfda2 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -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): diff --git a/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html b/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html new file mode 100644 index 0000000..88ed3c8 --- /dev/null +++ b/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html @@ -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' %} + + + + {% else %} + + + + {% endif %} +{% endblock upleft %} + +{% block upright %} + {% if step == 'review' %} + + + + {% endif %} +{% endblock upright %} + +{% block extra_head %} + +{% endblock extra_head %} + +{% block content %} + +{% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+{% endif %} + +{% if step == 'upload' %} + +
+ {% csrf_token %} + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ {% translate "Prendre en photo / Importer" %} +
+
+ {% translate "Prenez une photo de chaque page ou importez vos photos/PDF (sélection multiple autorisée)." %} +
+ + {% translate "OCR 100% local sur serveur" %} + + + +
+ + +
+
+ + +
+
+
+
+
+ + +
+ +
+ {% translate "Rapprochement automatique :" %}
+ {% 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." %} +
+
+ + +
+ +
+
+ +{% elif step == 'review' %} + +
+ {% csrf_token %} + + + + + + + + +
+
+ {% translate "Fournisseur" %} + {% if parse_result.matched_supplier %} + + {% translate "Identifié" %} + + {% else %} + + {% translate "Non trouvé" %} + + {% endif %} +
+
+ {% if parse_result.matched_supplier %} +
+ + +
+
+ {% if parse_result.matched_supplier.enterprise_number %} +
{% translate "TVA" %} : {{ parse_result.matched_supplier.enterprise_number }}
+ {% endif %} +
+ {% else %} +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+
+ {% endif %} +
+
+ + +
+
+
+
+ {% translate "Entrepôt :" %} + {{ selected_warehouse.name }} +
+
+ {% translate "N° Devis :" %} + {{ parse_result.metadata.quote_number|default:"—" }} +
+
+ {% translate "Statut initial :" %} + +
+
+ {% translate "Date de l'offre :" %} + {{ parse_result.metadata.date|default:"—" }} +
+
+ + +
+ {% if parse_result.is_multi_image %} + + {% blocktranslate with count=parse_result.image_count %}{{ count }} photos combinées{% endblocktranslate %} + + {% elif parse_result.is_image %} + + {% translate "Photo (OCR local)" %} + + {% else %} + + {% translate "PDF" %} + + {% endif %} + {% if saved_temp_files and saved_temp_files|length > 1 %} + {% for f in saved_temp_files %} + {{ f.name }} + {% endfor %} + {% endif %} +
+
+
+ + +
+
+ + {% translate "Articles extraits" %} ({{ parse_result.items|length }}) +
+
+ + +
+
+ + +
+ {% for item in parse_result.items %} + {% with idx=forloop.counter0 %} +
+ +
+
+ + +
+
+ + {{ item.total_amount|stringformat:".2f" }} € + +
+
+ + +
+ +
+ {{ item.name }} +
+ + + + + + +
+ {% if item.matched_product %} +
+ {% translate "Associé à :" %} {{ item.matched_product.name }} +
+ {% else %} +
+ {% translate "Produit non associé — Sélectionner ci-dessous :" %} +
+ {% endif %} + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {% endwith %} + {% endfor %} +
+ + +
+
+
+ {% translate "Total HT :" %} 0.00 € + {% translate "TVA :" %} 0.00 € +
+
+ {% translate "Total TTC :" %} + 0.00 € +
+
+
+ +
+
+
+{% endif %} + + +{% endblock content %} diff --git a/loko/stock/templates/stock/mobile/stock_purchases_mobile.html b/loko/stock/templates/stock/mobile/stock_purchases_mobile.html index b5d6237..5e18a9e 100644 --- a/loko/stock/templates/stock/mobile/stock_purchases_mobile.html +++ b/loko/stock/templates/stock/mobile/stock_purchases_mobile.html @@ -10,6 +10,12 @@ {% endblock upleft %} +{% block upright %} + + {% translate "Importer" %} + +{% endblock upright %} + {% block extra_head %}