from functools import wraps from django.shortcuts import render, redirect from django.contrib import messages from django.contrib.auth.decorators import login_not_required, login_required from django.http import JsonResponse, HttpResponse from django.conf import settings from django.contrib.staticfiles import finders as staticfiles_finders from django.views.decorators.cache import never_cache from django.utils.translation import get_language, gettext as _ from pathlib import Path from common.models import UserConfig, Thematic, UserThematics from common.utils import check_thematic_access from assets.models import AssetCategory, NatureAssetModel from assets.models.nature import BULB_TYPE_CHOICES def stock_access_required(view_func): """ Decorator to ensure the user has one of the allowed stock roles: admin, stock_manager, manager, controller, top_manager. """ @wraps(view_func) def _wrapped_view(request, *args, **kwargs): if not request.user.is_authenticated: return redirect('%s?next=%s' % (settings.LOGIN_URL, request.path)) try: user_config = request.user.config roles = set(user_config.roles.values_list('name', flat=True)) allowed_roles = {'admin', 'stock_manager', 'manager', 'controller', 'top_manager'} if request.user.is_superuser or roles.intersection(allowed_roles): return view_func(request, *args, **kwargs) except (UserConfig.DoesNotExist, AttributeError): pass return render(request, "403.html", status=403) return _wrapped_view def index(request): """Render the mobile landing page.""" user_config = None try: user_config = UserConfig.objects.get(user=request.user) # Vérifie si l'utilisateur n'a qu'un seul rôle et que c'est 'technician' roles = list(user_config.roles.values_list('name', flat=True)) if len(roles) == 1 and roles[0] == 'technician': return redirect('/mobile/interventions/') # Vérifie si l'utilisateur n'a qu'un seul rôle et que c'est 'observer' if len(roles) == 1 and roles[0] == 'observer': return redirect('/mobile/observations/') except UserConfig.DoesNotExist: # If no user config exists, return unauthorized (403) return render(request, "403.html", status=403) # Check if user has permission to edit green surfaces (user config thematics) can_edit_green_surfaces = False try: nature_thematic = Thematic.objects.get(code='nature') can_edit_green_surfaces = user_config.is_intern and UserThematics.objects.filter( user_config=user_config, thematic=nature_thematic, can_edit_assets=True ).exists() except Thematic.DoesNotExist: pass # Check if user has access to at least one warehouse (view permission) from stock.permissions import get_user_accessible_warehouses has_stock_role = request.user.is_superuser or any(r in roles for r in ['admin', 'stock_manager', 'manager', 'controller', 'top_manager']) can_access_stock = has_stock_role and get_user_accessible_warehouses(request.user).exists() # Check if user can add repair interventions for structures thematic (technicians) can_add_structures_repair = False default_thematic = user_config.default_thematic if default_thematic and default_thematic.code == 'structures': is_technician = 'technician' in roles if is_technician: can_add_structures_repair = UserThematics.objects.filter( user_config=user_config, thematic=default_thematic, can_edit_interventions=True ).exists() # Check if user (internal admin/manager/controller) can add interventions across all thematics can_add_intervention_mobile = user_config.is_intern and any(r in roles for r in ['admin', 'manager', 'controller']) # Check if user is an inspector and get inspection configurations is_inspector = 'inspector' in roles inspection_configs = [] if is_inspector: from interventions.models import InspectionConfiguration from interventions.permissions import can_user_create_inspection_for_contract configs = list( InspectionConfiguration.objects.select_related('thematic') .prefetch_related('contracts_providers__contract', 'operation_templates') ) is_admin = 'admin' in roles for config in configs: associated_contracts = [cp.contract for cp in config.contracts_providers.all()] if not associated_contracts: inspection_configs.append(config) elif is_admin: inspection_configs.append(config) else: for contract in associated_contracts: if can_user_create_inspection_for_contract(request.user, contract): inspection_configs.append(config) break can_inspect = user_config.get_inspectable_thematics().exists() return render(request, "mobile/mobile_index.html", { 'can_edit_green_surfaces': can_edit_green_surfaces, 'can_access_stock': can_access_stock, 'can_add_structures_repair': can_add_structures_repair, 'can_add_intervention_mobile': can_add_intervention_mobile, 'is_inspector': is_inspector, 'inspection_configs': inspection_configs, 'can_inspect': can_inspect, }) @login_not_required def install(request): """Render installation instructions for the mobile app.""" return render(request, "mobile/mobile_install.html") @never_cache def mobile_controls_service_worker(request): """Sert le service worker des contrôles depuis /mobile/controls/service-worker.js. Ce chemin donne au SW un scope naturel sur /mobile/controls/ qui couvre toutes les pages mobiles de détail et de liste des contrôles. """ rel = 'controls/service-worker.js' # 1. Finders (développement et production avec STATICFILES_DIRS) abs_path = staticfiles_finders.find(rel) if abs_path: try: content = Path(abs_path).read_text(encoding='utf-8') return HttpResponse(content, content_type='application/javascript; charset=utf-8') except (FileNotFoundError, IOError): pass # 2. STATIC_ROOT (après collectstatic en production) try: content = (Path(settings.STATIC_ROOT) / rel).read_text(encoding='utf-8') return HttpResponse(content, content_type='application/javascript; charset=utf-8') except (FileNotFoundError, IOError, TypeError): pass # 3. Chemin source app (fallback ultime) try: content = (settings.BASE_DIR / 'controls' / 'static' / rel).read_text(encoding='utf-8') except (FileNotFoundError, IOError): content = 'console.error("Controls service worker introuvable");' return HttpResponse(content, content_type='application/javascript; charset=utf-8') @check_thematic_access('nature') def green_surfaces_edit_mobile(request): """Mobile interface for editing green surfaces (nature assets).""" # Get categories and models for nature thematic categories = list(AssetCategory.objects.filter(thematic__code='nature')) categories.sort(key=lambda m: m.get_name().lower()) models = list(NatureAssetModel.objects.filter(category__code='NAT_GREEN_AREAS')) models.sort(key=lambda m: (m.get_name() or "").lower()) context = { 'categories': categories, 'models': models, 'bulb_type_choices': sorted(BULB_TYPE_CHOICES, key=lambda x: x[1].lower()), } return render(request, "mobile/green_surfaces_edit_v2.html", context) @check_thematic_access('nature') def green_surfaces_edit_custom_draw(request): """Mobile interface for editing green surfaces with custom draw module (test page).""" # Get categories and models for nature thematic categories = list(AssetCategory.objects.filter(thematic__code='nature')) categories.sort(key=lambda m: m.get_name().lower()) models = list(NatureAssetModel.objects.filter(category__code='NAT_GREEN_AREAS')) models.sort(key=lambda m: (m.get_name() or "").lower()) context = { 'categories': categories, 'models': models, } return render(request, "mobile/green_surfaces_edit_custom_draw.html", context) @login_required @stock_access_required def stock_index_mobile(request): """Mobile landing page for the stock module.""" from stock.models import PreparationOrder, ProductionOrder from stock.permissions import get_user_accessible_warehouses preparation_orders_count = PreparationOrder.objects.filter( status__in=['to_process', 'pending'] ).count() accessible_warehouses = get_user_accessible_warehouses(request.user) production_orders_count = ProductionOrder.objects.filter( warehouse__id__in=accessible_warehouses, status__in=['to_process', 'in_production'], ).count() return render(request, "stock/mobile/stock_index_mobile.html", { 'preparation_orders_count': preparation_orders_count, 'production_orders_count': production_orders_count, }) @login_required @stock_access_required def stock_products_mobile(request): """Mobile list of stock products.""" from stock.models import Product, ProductCategory from django.db.models import Q q = request.GET.get('q', '').strip() category_id = request.GET.get('category', '').strip() products = Product.objects.select_related('category').order_by('name') if q: products = products.filter(Q(name__icontains=q) | Q(code__icontains=q)) if category_id: products = products.filter(category_id=category_id) categories = ProductCategory.objects.order_by('name_fr') return render(request, "stock/mobile/stock_products_mobile.html", { 'products': products[:200], 'q': q, 'category_id': category_id, 'categories': categories, }) @login_required @stock_access_required def qr_scanner(request): """Page du scanner QR Code mobile.""" return render(request, 'mobile/qr_scanner.html') @login_required @stock_access_required def qr_resolve(request): """ Résout le contenu d'un QR code scanné et retourne l'URL de redirection en JSON. Format attendu: STREETUP:: Types supportés : product, order """ code = request.GET.get('code', '').strip() if not code.startswith('STREETUP:'): return JsonResponse({'error': _('QR Code non reconnu.')}, status=400) parts = code.split(':', 2) if len(parts) != 3: return JsonResponse({'error': _('Format de QR Code invalide.')}, status=400) _, qr_type, identifier = parts if qr_type == 'product': from stock.models import Product try: product = Product.objects.get(code=identifier) return JsonResponse({ 'url': f'/mobile/stock/products/{product.pk}/', 'label': f"▣ {product.name} ({product.code})", }) except Product.DoesNotExist: return JsonResponse({'error': _(f'Produit « {identifier} » introuvable.')}, status=404) if qr_type == 'order': from stock.models import PurchaseOrder try: order = PurchaseOrder.objects.get(uuid=identifier) return JsonResponse({ 'url': f'/stock/purchase-orders/{order.pk}/', 'label': f'📦 {order.code or "#" + str(order.pk)}', }) except (PurchaseOrder.DoesNotExist, Exception): return JsonResponse({'error': _(f'Commande introuvable.')}, status=404) if qr_type == 'serializeditem': from stock.models import SerializedItem try: si = SerializedItem.objects.select_related('product').get(serial_number=identifier) return JsonResponse({ 'url': f'/mobile/stock/serialized/{si.pk}/', 'label': f'🔖 {si.product.name} — {si.serial_number}', }) except SerializedItem.DoesNotExist: return JsonResponse({'error': _(f'Article tracé « {identifier} » introuvable.')}, status=404) if qr_type == 'stocklocation': from stock.models import WarehouseLocation try: loc = WarehouseLocation.objects.select_related('warehouse').get(code=identifier) return JsonResponse({ 'url': f'/stock/warehouse-locations/{loc.pk}/', 'label': f'📍 {loc.warehouse.name} — {loc.name}', }) except WarehouseLocation.DoesNotExist: return JsonResponse({'error': _(f'Emplacement « {identifier} » introuvable.')}, status=404) if qr_type == 'preparation': from stock.models import PreparationOrder try: order = PreparationOrder.objects.get(order_number=identifier) return JsonResponse({ 'url': f'/mobile/stock/preparation-orders/{order.pk}/', 'label': f'📋 Bon de préparation {order.order_number}', }) except PreparationOrder.DoesNotExist: return JsonResponse({'error': _(f'Bon de préparation « {identifier} » introuvable.')}, status=404) return JsonResponse({'error': _(f'Type de QR Code inconnu : {qr_type}.')}, status=400) @login_required @stock_access_required def stock_product_detail_mobile(request, product_id): """Mobile detail page for a stock product.""" from stock.models import Product, Stock, PurchaseOrderItem, StockMovement from common.models import UserConfig from django.shortcuts import get_object_or_404 try: user_config = request.user.config is_stock_manager = user_config.has_role('stock_manager') or user_config.has_role('admin') except (UserConfig.DoesNotExist, AttributeError): is_stock_manager = False product = get_object_or_404(Product.objects.select_related('category'), id=product_id) stocks = list(Stock.objects.filter(product=product).select_related( 'location__warehouse' ).order_by('location__warehouse__name', 'location__name')) from stock.permissions import user_can_administer_warehouse for stock in stocks: stock.can_administer = user_can_administer_warehouse(request.user, stock.location.warehouse) pending_orders = PurchaseOrderItem.objects.filter( product=product, purchase_order__status__in=['pending', 'ordered'] ).select_related('purchase_order__supplier', 'purchase_order__warehouse', 'location') recent_movements = StockMovement.objects.filter( product=product ).select_related( 'source_location__warehouse', 'destination_location__warehouse', 'batch', 'batch__preparation_order', 'batch__purchase_order', ).order_by('-date')[:20] order_history = PurchaseOrderItem.objects.filter( product=product, purchase_order__status__in=['received', 'cancelled'] ).select_related( 'purchase_order__supplier', 'purchase_order__warehouse' ).order_by('-purchase_order__date_ordered')[:30] serialized_items = None if product.is_serialized: from stock.models import SerializedItem serialized_items = SerializedItem.objects.filter(product=product).select_related( 'current_holder', 'current_location__warehouse' ).order_by('serial_number') return render(request, 'stock/mobile/product_detail_mobile.html', { 'product': product, 'stocks': stocks, 'pending_orders': pending_orders, 'recent_movements': recent_movements, 'order_history': order_history, 'is_stock_manager': is_stock_manager, 'serialized_items': serialized_items, }) @login_required @stock_access_required def stock_serialized_item_detail_mobile(request, pk): """Mobile detail page for a serialized (tracked) item.""" from stock.models import SerializedItem, WarehouseLocation from stock.permissions import user_can_manage_products from contracts.models import CompanyMember from django.shortcuts import get_object_or_404 item = get_object_or_404( SerializedItem.objects.select_related( 'product__category', 'current_location__warehouse', 'current_holder', 'created_by' ).prefetch_related('current_holder__teams'), pk=pk, ) events = item.events.select_related( 'person', 'from_location__warehouse', 'to_location__warehouse', 'created_by' ) locations = WarehouseLocation.objects.select_related('warehouse').order_by('warehouse__name', 'name') members = CompanyMember.objects.prefetch_related('teams').order_by('name') return render(request, 'stock/mobile/serialized_item_detail_mobile.html', { 'item': item, 'events': events, 'locations': locations, 'members': members, 'can_manage': user_can_manage_products(request.user), }) @login_required @stock_access_required def stock_preparation_orders_mobile(request): from stock.models import PreparationOrder selected_status = request.GET.get('status', '').strip() selected_priority = request.GET.get('priority', '').strip() preparation_orders = PreparationOrder.objects.all() if selected_status: preparation_orders = preparation_orders.filter(status=selected_status) if selected_priority: preparation_orders = preparation_orders.filter(priority=selected_priority) preparation_orders = preparation_orders.prefetch_related( 'preparationorderitem_set__product' ).order_by('-date')[:200] return render(request, "stock/mobile/stock_preparation_orders_mobile.html", { 'preparation_orders': preparation_orders, 'selected_status': selected_status, 'selected_priority': selected_priority, 'preparation_order_status_choices': [ ('to_process', _("A traiter")), ('pending', _("En attente")), ('processed', _("Traité")), ('cancelled', _("Annulé")), ], 'preparation_order_priority_choices': [ ('1', _("Urgent")), ('2', _("Elevé")), ('3', _("Normal")), ('4', _("Bas")), ], }) @login_required @stock_access_required def stock_production_orders_mobile(request): """Mobile list of production orders (bons de confection).""" from stock.models import ProductionOrder from stock.permissions import get_user_accessible_warehouses accessible_warehouses = get_user_accessible_warehouses(request.user) production_orders = ProductionOrder.objects.filter( warehouse__id__in=accessible_warehouses ).select_related('warehouse').order_by('-created_at')[:200] return render(request, "stock/mobile/stock_production_orders_mobile.html", { 'production_orders': production_orders, }) @login_required @stock_access_required def stock_production_order_detail_mobile(request, pk): """Mobile detail page for a production order (bon de confection).""" from django.shortcuts import get_object_or_404 from stock.models import ProductionOrder from stock.permissions import get_user_accessible_warehouses from common.models import UserConfig accessible_warehouses = get_user_accessible_warehouses(request.user) order = get_object_or_404( ProductionOrder.objects.select_related( 'warehouse', 'preparation_order', 'output_product', 'output_location', ).prefetch_related('documents').filter(warehouse__id__in=accessible_warehouses), pk=pk, ) try: user_config = request.user.config can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') except UserConfig.DoesNotExist: can_manage = False components_status = order.get_components_status() can_start = order.can_start() # Extraire la description libre (depuis le FK ou depuis les notes pré-remplies) other_item_description = None if order.preparation_order_other_item_id: other_item_description = order.preparation_order_other_item.description else: notes = order.notes or '' if 'Issu de' in notes and ': ' in notes: other_item_description = notes.split(': ', 1)[1] return render(request, "stock/mobile/stock_production_order_detail_mobile.html", { 'order': order, 'components_status': components_status, 'can_start': can_start, 'can_manage': can_manage, 'other_item_description': other_item_description, }) @login_required @stock_access_required def stock_movements_mobile(request): """Mobile list of stock movements (entrées / sorties).""" from stock.models import StockMovementBatch, StockMovement from stock.permissions import get_user_accessible_warehouses from django.db.models import Q, Prefetch accessible_warehouses = get_user_accessible_warehouses(request.user) batches = StockMovementBatch.objects.filter( Q(movements__source_location__warehouse__id__in=accessible_warehouses) | Q(movements__destination_location__warehouse__id__in=accessible_warehouses) ).distinct().select_related('person', 'company', 'created_by').prefetch_related( Prefetch( 'movements', queryset=StockMovement.objects.select_related( 'product', 'source_location', 'destination_location' ) ) ).order_by('-date')[:100] return render(request, "stock/mobile/stock_movements_mobile.html", { 'batches': batches, }) @login_required @stock_access_required def new_stock_entry_mobile(request): """Crée une nouvelle entrée de stock depuis l'interface mobile.""" from stock.views import _new_stock_batch_view return _new_stock_batch_view( request, 'in', template_name="stock/mobile/new_stock_batch_mobile.html", cancel_url_name="mobile:stock_movements_mobile", success_redirect_fn=lambda req, batch: redirect( 'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk ), ) @login_required @stock_access_required def new_stock_exit_mobile(request): """Crée une nouvelle sortie de stock depuis l'interface mobile.""" from stock.views import _new_stock_batch_view return _new_stock_batch_view( request, 'out', template_name="stock/mobile/new_stock_batch_mobile.html", cancel_url_name="mobile:stock_movements_mobile", success_redirect_fn=lambda req, batch: redirect( 'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk ), ) @login_required @stock_access_required def new_stock_transfer_mobile(request): """Crée un nouveau transfert de stock depuis l'interface mobile.""" from stock.views import _new_stock_batch_view return _new_stock_batch_view( request, 'transfer', template_name="stock/mobile/new_stock_batch_mobile.html", cancel_url_name="mobile:stock_movements_mobile", success_redirect_fn=lambda req, batch: redirect( 'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk ), ) @login_required @stock_access_required def production_order_create_mobile(request): """Crée un nouveau bon de confection depuis l'interface mobile.""" from stock.models import ProductionOrder, PreparationOrderItem, PreparationOrderOtherItem from stock.forms import ProductionOrderForm, ProductionOrderComponentFormSet from common.models import UserConfig try: user_config = request.user.config is_stock_manager = user_config.has_role('stock_manager') or user_config.has_role('admin') except UserConfig.DoesNotExist: is_stock_manager = False if not is_stock_manager: messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('mobile:stock_production_orders_mobile') initial = {} other_item_context = None prep_item_context = None prep_id = request.GET.get('preparation_order') other_item_id = request.GET.get('other_item') prep_item_id = request.GET.get('prep_item') if prep_id: initial['preparation_order'] = prep_id try: default_warehouse = request.user.config.get_default_warehouse() if default_warehouse: initial['warehouse'] = default_warehouse except Exception: pass if prep_item_id: try: prep_item = PreparationOrderItem.objects.select_related( 'preparation_order', 'product__category' ).get(pk=prep_item_id) prep_item_context = prep_item if not prep_id: initial['preparation_order'] = prep_item.preparation_order_id initial['quantity'] = prep_item.quantity initial['notes'] = _( "Issu de l'article {product} du bon de préparation #{order_number}" ).format( product=prep_item.product.name, order_number=prep_item.preparation_order.order_number, ) except PreparationOrderItem.DoesNotExist: pass if other_item_id: try: other_item = PreparationOrderOtherItem.objects.select_related( 'preparation_order', 'sign_panel_type', 'category' ).get(pk=other_item_id) other_item_context = other_item if not prep_id: initial['preparation_order'] = other_item.preparation_order_id initial['quantity'] = other_item.quantity initial['output_product_name'] = other_item.description[:300] initial['notes'] = _( "Issu de l'item #{id} du bon de préparation #{order_number}: {desc}" ).format( id=other_item.id, order_number=other_item.preparation_order.order_number, desc=other_item.description, ) except PreparationOrderOtherItem.DoesNotExist: pass if request.method == 'POST': form = ProductionOrderForm(request.POST, request.FILES) formset = ProductionOrderComponentFormSet(request.POST, prefix='components') if form.is_valid() and formset.is_valid(): order = form.save(commit=False) if prep_item_context: order.preparation_order_item = prep_item_context if other_item_context: order.preparation_order_other_item = other_item_context order.save() form.save_m2m() formset.instance = order formset.save() from stock.models import ProductionOrderDocument for f in request.FILES.getlist('new_documents'): ProductionOrderDocument.objects.create( production_order=order, file=f, name=f.name, ) messages.success(request, _("Bon de confection créé avec succès.")) return redirect('mobile:stock_production_order_detail_mobile', pk=order.pk) else: form = ProductionOrderForm(initial=initial) formset = ProductionOrderComponentFormSet(prefix='components') return render(request, 'stock/mobile/production_order_form_mobile.html', { 'form': form, 'formset': formset, 'action': 'create', 'other_item_context': other_item_context, 'prep_item_context': prep_item_context, }) @login_required @stock_access_required def stock_movement_batch_detail_mobile(request, batch_id): """Mobile detail page for a stock movement batch.""" from stock.models import StockMovementBatch, StockBatchSignature from stock.permissions import get_user_accessible_warehouses from django.shortcuts import get_object_or_404 from django.db.models import Q import base64 from django.core.files.base import ContentFile batch = get_object_or_404( StockMovementBatch.objects.select_related( 'person', 'company', 'contract', 'contract__company', 'created_by', 'preparation_order', 'purchase_order', 'purchase_order__supplier', ).prefetch_related( 'person__teams', 'movements__product', 'movements__source_location__warehouse', 'movements__destination_location__warehouse', 'signatures__signed_by', 'documents', ), pk=batch_id, ) # Vérifier si l'utilisateur peut gérer au moins un entrepôt impliqué dans ce lot manageable_warehouses = get_user_accessible_warehouses(request.user, can_manage=True) batch_warehouse_ids = set( batch.movements.filter(source_location__warehouse__isnull=False) .values_list('source_location__warehouse_id', flat=True) ) | set( batch.movements.filter(destination_location__warehouse__isnull=False) .values_list('destination_location__warehouse_id', flat=True) ) can_add_signature = bool( manageable_warehouses.filter(id__in=batch_warehouse_ids).exists() ) if request.method == 'POST': if not can_add_signature: messages.error(request, _("Vous n'avez pas la permission d'ajouter une signature à ce lot.")) return redirect('mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk) signature_data = request.POST.get('signature_data', '').strip() signature_name = request.POST.get('signature_name', '').strip() or None image_file = None if signature_data and signature_data.startswith('data:image/'): header, encoded = signature_data.split(',', 1) image_bytes = base64.b64decode(encoded) image_file = ContentFile(image_bytes, name=f'signature_batch_{batch.pk}_{batch.signatures.count() + 1}.png') elif request.FILES.get('signature'): image_file = request.FILES['signature'] if image_file: sig = StockBatchSignature(batch=batch, signed_by=request.user, signature_name=signature_name) sig.image.save(image_file.name if hasattr(image_file, 'name') else f'sig_{batch.pk}.png', image_file, save=True) messages.success(request, _('Signature enregistrée avec succès.')) return redirect('mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk) return render(request, 'stock/mobile/stock_movement_batch_detail_mobile.html', { 'batch': batch, 'can_add_signature': can_add_signature, }) @login_required @stock_access_required def stock_inventories_mobile(request): """Mobile list of inventories.""" from stock.models import Inventory from stock.permissions import get_user_accessible_warehouses accessible_warehouses = get_user_accessible_warehouses(request.user) inventories = Inventory.objects.filter( warehouse_location__warehouse__id__in=accessible_warehouses ).select_related('warehouse_location__warehouse').order_by('-created_at')[:200] return render(request, "stock/mobile/stock_inventories_mobile.html", { 'inventories': inventories, }) @login_required @stock_access_required def stock_inventory_detail_mobile(request, pk): """Mobile detail page for an inventory (saisie des quantités comptées).""" from django.shortcuts import get_object_or_404 from stock.models import Inventory from stock.permissions import get_user_accessible_warehouses, user_can_validate_inventory accessible_warehouses = get_user_accessible_warehouses(request.user) inventory = get_object_or_404( Inventory.objects.filter( warehouse_location__warehouse__id__in=accessible_warehouses ).select_related("warehouse_location__warehouse"), pk=pk, ) items = inventory.items.select_related("product", "validated_by").order_by("product__name") warehouse = inventory.warehouse_location.warehouse can_validate = user_can_validate_inventory(request.user, warehouse) from stock.permissions import user_can_manage_warehouse can_change_status = user_can_manage_warehouse(request.user, warehouse) return render(request, "stock/mobile/stock_inventory_detail_mobile.html", { "inventory": inventory, "items": items, "can_validate": can_validate, "can_change_status": can_change_status, }) @login_required @stock_access_required def stock_new_inventory_mobile(request): """Mobile form to create a new inventory.""" from stock.models import Inventory, WarehouseLocation, Warehouse from stock.permissions import get_user_accessible_warehouses accessible_warehouses = get_user_accessible_warehouses(request.user) locations = ( WarehouseLocation.objects.filter(warehouse__id__in=accessible_warehouses) .select_related('warehouse') .order_by('warehouse__name', 'name') ) # Group locations by warehouse for the template warehouses_with_locations = {} for loc in locations: wh = loc.warehouse warehouses_with_locations.setdefault(wh, []).append(loc) grouped_locations = [ {'warehouse': wh, 'locations': locs} for wh, locs in warehouses_with_locations.items() ] error = None performed_by = '' location_id = '' if request.method == 'POST': location_id = request.POST.get('warehouse_location', '').strip() performed_by = request.POST.get('performed_by', '').strip() if not location_id: error = _("Veuillez sélectionner un emplacement.") else: location = WarehouseLocation.objects.filter( pk=location_id, warehouse__id__in=accessible_warehouses, ).first() if not location: error = _("Emplacement invalide ou non accessible.") else: inventory = Inventory.objects.create( warehouse_location=location, performed_by=performed_by, status=Inventory.STATUS_IN_PROGRESS, ) # Pré-remplir les articles avec les produits existants à cet emplacement from stock.models import Stock, InventoryItem existing_stocks = ( Stock.objects.filter(location=location, quantity__gt=0) .select_related('product') .order_by('product__name') ) for s in existing_stocks: InventoryItem.objects.create( inventory=inventory, product=s.product, expected_quantity=s.quantity, counted_quantity=s.quantity, ) messages.success(request, _("Inventaire créé avec succès.")) return redirect('mobile:stock_inventory_detail_mobile', pk=inventory.pk) return render(request, "stock/mobile/stock_new_inventory_mobile.html", { 'grouped_locations': grouped_locations, 'error': error, 'performed_by': performed_by, 'location_id': location_id, }) @login_required @stock_access_required def stock_purchases_mobile(request): """Mobile list of purchase orders (commandes).""" from stock.models import PurchaseOrder from stock.permissions import get_user_accessible_warehouses accessible_warehouses = get_user_accessible_warehouses(request.user) purchase_orders = PurchaseOrder.objects.filter( warehouse__id__in=accessible_warehouses ).select_related('supplier', 'warehouse').order_by('-date_ordered')[:200] return render(request, "stock/mobile/stock_purchases_mobile.html", { 'purchase_orders': purchase_orders, }) @login_required @stock_access_required def stock_purchase_order_detail_mobile(request, pk): """Mobile detail page for a purchase order (bon de commande).""" from django.shortcuts import get_object_or_404 from stock.models import PurchaseOrder, PurchaseOrderItem from stock.permissions import get_user_accessible_warehouses from common.models import UserConfig from django.db.models import Sum as _Sum accessible_warehouses = get_user_accessible_warehouses(request.user) purchase_order = get_object_or_404( PurchaseOrder.objects.filter( warehouse__id__in=accessible_warehouses ).select_related('supplier', 'warehouse') .prefetch_related('items__product', 'items__location__warehouse'), pk=pk, ) qr_code = purchase_order.generate_qr_code() items_with_stock = [] for item in purchase_order.items.all(): items_with_stock.append({ 'item': item, }) try: user_config = request.user.config can_manage_purchase = user_config.can_manage_purchase_orders_any() except UserConfig.DoesNotExist: can_manage_purchase = False return render(request, "stock/mobile/stock_purchase_order_detail_mobile.html", { 'purchase_order': purchase_order, 'qr_code': qr_code, 'items_with_stock': items_with_stock, 'can_manage_purchase': can_manage_purchase, }) @login_required @stock_access_required def stock_preparation_order_detail_mobile(request, pk): """Mobile detail page for a preparation order (bon de préparation).""" from django.shortcuts import get_object_or_404 from stock.models import PreparationOrder, Stock from stock.permissions import get_user_accessible_warehouses from common.models import UserConfig preparation_order = get_object_or_404( PreparationOrder.objects.select_related( 'intervention', 'intervention__intervention_manager', ).prefetch_related( 'preparationorderitem_set__product__category', 'preparationorderitem_set__production_orders', 'other_items__category', 'other_items__sign_panel_type', 'other_items__production_orders', 'documents', ), pk=pk, ) # Ajout de documents via POST if request.method == 'POST' and request.FILES.getlist('documents'): from stock.models import PreparationOrderDocument for f in request.FILES.getlist('documents'): PreparationOrderDocument.objects.create( preparation_order=preparation_order, file=f, name=f.name, ) messages.success(request, _('Document(s) ajouté(s) avec succès.')) return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk) from stock.models import PreparationOrderItem, WarehouseLocation executed_summary = preparation_order.get_executed_output_summary() executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location() executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()} has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values()) is_reopened = preparation_order.status == 'to_process' and has_executed_outputs # S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon for p_id, loc_dict in executed_by_loc.items(): executed_qty = sum(loc_dict.values()) if executed_qty > 0: PreparationOrderItem.objects.get_or_create( preparation_order=preparation_order, product_id=p_id, defaults={'quantity': 0} ) items_with_stock = [] for item in preparation_order.preparationorderitem_set.filter(quantity__gt=0): product = item.product available_stock = product.total_stock reserved_stock = product.reserved_quantity prod_order = item.production_orders.first() prod_summary = executed_summary.get(product.id, {'out': 0, 'in': 0, 'net': 0}) executed_qty = prod_summary['net'] gross_out = prod_summary['out'] gross_in = prod_summary['in'] delta = item.quantity - executed_qty items_with_stock.append({ 'item': item, 'available_stock': available_stock, 'reserved_stock': reserved_stock, 'is_sufficient': available_stock >= item.quantity, 'production_order': prod_order, 'executed_qty': executed_qty, 'gross_out': gross_out, 'gross_in': gross_in, 'delta': delta, 'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse'), }) deleted_items_to_return = [] for item in preparation_order.preparationorderitem_set.filter(quantity=0): executed_qty = executed_totals.get(item.product_id, 0) if executed_qty > 0: product = item.product loc_dict = executed_by_loc.get(product.id, {}) loc_names = [] for l_id, q in loc_dict.items(): loc_obj = WarehouseLocation.objects.filter(pk=l_id).select_related('warehouse').first() if loc_obj: loc_names.append(f"{loc_obj.name} ({loc_obj.warehouse.name}): {q}") deleted_items_to_return.append({ 'item': item, 'product': product, 'executed_qty': executed_qty, 'location_info': ", ".join(loc_names), }) try: user_config = request.user.config can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() can_cancel = user_config.can_manage_preparation_orders_any() can_manage_purchase = user_config.can_manage_purchase_orders_any() except UserConfig.DoesNotExist: can_manage = False can_cancel = False can_manage_purchase = False other_items_with_info = [] for oi in preparation_order.other_items.all(): other_items_with_info.append({ 'item': oi, 'production_order': oi.production_orders.first(), }) exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date') return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date') return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", { 'preparation_order': preparation_order, 'items_with_stock': items_with_stock, 'other_items': other_items_with_info, 'can_manage': can_manage, 'can_cancel': can_cancel, 'can_manage_purchase': can_manage_purchase, 'exit_batches': exit_batches, 'return_batches': return_batches, 'has_executed_outputs': has_executed_outputs, 'is_reopened': is_reopened, 'deleted_items_to_return': deleted_items_to_return, }) @check_thematic_access('nature') def green_surfaces_edit_standalone(request): """Mobile interface for editing green surfaces with CustomDrawToolbar in standalone mode (test page). This page demonstrates the CustomDrawToolbar without callbacks. The toolbar manages all drawing operations internally. """ context = {} return render(request, "mobile/green_surfaces_edit_standalone.html", context)