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:
parent
53d579009a
commit
4bb53fb71c
5 changed files with 1067 additions and 0 deletions
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue