import json from common.utils import get_short_name from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db import models, transaction from django.forms import inlineformset_factory from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_POST from django.contrib.gis.db.models.functions import Centroid, Transform from .forms import ( InventoryForm, InventoryItemEditForm, InventoryItemForm, InventoryItemFormSet, PreparationOrderForm, PreparationOrderItemForm, ProductForm, ProductAtLocationForm, ProductionOrderForm, ProductionOrderComponentFormSet, PurchaseOrderForm, PurchaseOrderItemFormSet, RequestForQuoteForm, RequestForQuoteItemForm, RequestForQuoteItemFormSet, SupplierForm, WarehouseLocationForm, ) from .models import ( Inventory, InventoryItem, ProductionOrder, ProductionOrderComponent, PurchaseOrder, PurchaseOrderItem, RequestForQuote, RequestForQuoteItem, StockMovement, StockMovementBatch, StockMovementDocument, StockAlert, Supplier, Warehouse, WarehouseLocation, PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem, PreparationOrderDocument, ProductionOrderDocument, PurchaseOrderDocument, Product, ProductCategory, Stock, SerializedItem, SerializedItemEvent, ) from .permissions import ( warehouse_view_required, warehouse_manage_required, location_view_required, product_view_required, filter_warehouses_by_permission, filter_locations_by_permission, filter_products_by_permission, get_user_accessible_warehouses, user_can_validate_inventory, user_can_administer_warehouse, user_can_administer_any_warehouse, user_can_manage_products, user_can_perform_inventory_on, user_can_validate_inventory_on, get_user_config, ) def warehouses_index(request): # Filtrer les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) warehouse_locations = WarehouseLocation.objects.filter( warehouse__id__in=accessible_warehouses ).select_related("warehouse") categories = ProductCategory.objects.all().order_by("name_fr") can_manage_products = user_can_manage_products(request.user) can_administer_any = user_can_administer_any_warehouse(request.user) # Articles tracés (onglet « Articles tracés ») si_query = request.GET.get("si_q", "").strip() si_product_id = request.GET.get("si_product", "").strip() si_status = request.GET.get("si_status", "").strip() serialized_items_qs = SerializedItem.objects.select_related( "product", "current_location__warehouse", "current_holder" ).filter(is_active=True) if si_query: serialized_items_qs = serialized_items_qs.filter( models.Q(serial_number__icontains=si_query) | models.Q(product__name__icontains=si_query) | models.Q(product__code__icontains=si_query) ) if si_product_id: serialized_items_qs = serialized_items_qs.filter(product_id=si_product_id) if si_status: serialized_items_qs = serialized_items_qs.filter(status=si_status) serialized_products = Product.objects.filter(is_serialized=True, is_active=True).order_by("name") context = { "warehouseLocations": warehouse_locations, "categories": categories, "can_manage_products": can_manage_products, "can_administer_any": can_administer_any, # Articles tracés "serialized_items": serialized_items_qs, "serialized_products": serialized_products, "si_query": si_query, "si_product_id": si_product_id, "si_status": si_status, "si_status_choices": SerializedItem.STATUS_CHOICES, } return render(request, "stock/warehouses_index.html", context) @warehouse_view_required def warehouse_detail(request, warehouse_id): warehouse = get_object_or_404(Warehouse, id=warehouse_id) if warehouse.geom: point_4326 = warehouse.geom.transform(4326, clone=True) warehouse.lon = point_4326.x warehouse.lat = point_4326.y warehouse_locations = warehouse.locations.all() # Récupérer tous les produits avec leur stock dans cet entrepôt from django.db.models import Sum, Q from collections import defaultdict # Obtenir les stocks de tous les produits dans cet entrepôt stocks = Stock.objects.filter( location__warehouse=warehouse ).select_related( 'product__category' ).values( 'product__id', 'product__name', 'product__code', 'product__threshold', 'product__unit', 'product__category__name_fr', 'product__category__id' ).annotate( total_stock=Sum('quantity') ).order_by('product__category__name_fr', 'product__name') # Regrouper par catégorie products_by_category = defaultdict(list) for stock in stocks: category_name = stock['product__category__name_fr'] or _("Sans catégorie") products_by_category[category_name].append({ 'id': stock['product__id'], 'name': stock['product__name'], 'code': stock['product__code'], 'unit': stock['product__unit'], 'stock': stock['total_stock'], 'threshold': stock['product__threshold'], 'is_below_threshold': stock['total_stock'] < stock['product__threshold'] }) # Trier les catégories products_by_category = dict(sorted(products_by_category.items())) return render( request, "stock/warehouse_detail.html", { "warehouse": warehouse, "products_by_category": products_by_category, "warehouseLocations": warehouse_locations, "can_administer": user_can_administer_warehouse(request.user, warehouse), }, ) def warehouses_geojson(request): # Filtrer les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) warehouses = Warehouse.objects.filter( id__in=accessible_warehouses ).annotate(geom_4326=Transform(Centroid("geom"), 4326)) def obj_to_feature(obj): geometry = json.loads(obj.geom_4326.geojson) if obj.geom_4326 else None return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": { "code": obj.code, "name": obj.name, "manager": obj.manager, }, } features = [] max_features = 5000 for count, warehouse in enumerate(warehouses): if count >= max_features: break features.append(obj_to_feature(warehouse)) geojson_data = { "type": "FeatureCollection", "features": features, } return JsonResponse(geojson_data, safe=False) def products_api(request): """ API endpoint pour récupérer les produits avec filtres et recherche """ # Filtrer les produits accessibles include_inactive = request.GET.get("include_inactive") == "true" if include_inactive: products = Product.objects.select_related("category").all() else: products = Product.objects.select_related("category").filter(is_active=True) products = filter_products_by_permission(products, request.user) # Filtre par catégorie category_id = request.GET.get("category") if category_id: products = products.filter(category_id=category_id) # Filtre par recherche search = request.GET.get("search") if search: products = products.filter( name__icontains=search ) | products.filter( sku__icontains=search ) | products.filter( code__icontains=search ) # Limite pour éviter les réponses trop volumineuses products = products[:1000] data = [ { "id": p.id, "code": p.code, "name": p.name, "sku": p.sku, "category": p.category.name_fr if p.category else "-", "category_id": p.category.id if p.category else None, "price": float(p.price) if p.price else None, "threshold": p.threshold, "total_stock": p.total_stock, "description": p.description or "", "unit": p.unit, "unit_display": p.get_unit_display(), "is_active": p.is_active, } for p in products ] return JsonResponse(data, safe=False) def warehouse_locations_api(request, warehouse_id): """ API endpoint pour récupérer les emplacements d'un entrepôt spécifique """ warehouse = get_object_or_404(Warehouse, id=warehouse_id) locations = WarehouseLocation.objects.filter(warehouse=warehouse).order_by('name') data = [ { "id": loc.id, "code": loc.code, "name": loc.name, "description": loc.description or "", "is_default": loc.is_default, } for loc in locations ] return JsonResponse(data, safe=False) def products_autocomplete_api(request): """ API endpoint pour l'autocomplete des produits avec leurs emplacements. Filtre les produits selon les permissions de l'utilisateur. Paramètres: - q: terme de recherche (nom, code, SKU) - ids: liste d'IDs séparés par des virgules pour récupérer des produits spécifiques """ # Filtrer les produits accessibles selon les entrepôts autorisés products = Product.objects.select_related("category").prefetch_related("authorized_locations").all() products = filter_products_by_permission(products, request.user) # Recherche par IDs spécifiques ids_param = request.GET.get("ids", "").strip() if ids_param: try: ids = [int(id.strip()) for id in ids_param.split(",") if id.strip()] products = products.filter(id__in=ids) except ValueError: pass else: # Autocomplete only active products products = products.filter(is_active=True) # Recherche par nom, code ou SKU search = request.GET.get("q", "").strip() if search: products = products.filter( models.Q(name__icontains=search) | models.Q(code__icontains=search) | models.Q(sku__icontains=search) ) # Limiter les résultats products = products[:50] # Récupérer les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) # Construire la réponse avec les emplacements data = [] for p in products: # Récupérer les stocks dans les entrepôts accessibles stocks = Stock.objects.filter( product=p, location__warehouse__in=accessible_warehouses ).select_related('location__warehouse').values( 'location__id', 'location__name', 'location__warehouse__name', 'quantity' ) # Formater les emplacements locations = [] for stock in stocks: if stock['quantity'] > 0: # Uniquement les emplacements avec stock locations.append({ 'id': stock['location__id'], 'name': stock['location__name'], 'warehouse': stock['location__warehouse__name'], 'quantity': stock['quantity'] }) data.append({ 'id': p.id, 'text': p.name, # Format pour Select2 'code': p.code, 'sku': p.sku, 'category': p.category.name_fr if p.category else None, 'total_stock': p.total_stock, 'reserved_quantity': p.reserved_quantity, 'available_stock': p.get_available_stock(), 'threshold': p.threshold, 'locations': locations, 'unit': p.unit, 'unit_display': p.get_unit_display(), 'is_serialized': p.is_serialized, 'authorized_location_ids': list(p.authorized_locations.values_list('id', flat=True)) if p.authorized_locations.exists() else None, }) return JsonResponse({'results': data}, safe=False) @login_required def preparation_orders_autocomplete_api(request): """ API endpoint pour l'autocomplete des bons de préparation par numéro. Utilisé lors de la création d'une entrée de stock (retour). """ q = request.GET.get('q', '').strip() qs = PreparationOrder.objects.all() if q: qs = qs.filter(order_number__icontains=q) qs = qs.order_by('-date')[:20] results = [ {'id': p.pk, 'text': p.order_number, 'status': p.get_status_display()} for p in qs ] return JsonResponse({'results': results}) @login_required def preparation_order_items_api(request, pk): """ API endpoint retournant les articles d'un bon de préparation. Utilisé pour pré-remplir les lignes produit lors d'un retour de stock. """ po = get_object_or_404(PreparationOrder, pk=pk) items = [] for item in po.preparationorderitem_set.select_related('product').all(): items.append({ 'product_id': item.product.pk, 'product_name': item.product.name, 'product_code': item.product.code or '', 'quantity': item.quantity, 'unit': item.product.unit or '', }) return JsonResponse({'items': items, 'order_number': po.order_number}) @login_required def product_locations_api(request, product_id): """ Retourne tous les emplacements accessibles avec la quantité en stock pour un produit donné. Utilisé pour filtrer dynamiquement les selects source/destination lors d'un mouvement de stock. """ product = get_object_or_404(Product, pk=product_id) accessible_warehouses = get_user_accessible_warehouses(request.user) locations = WarehouseLocation.objects.filter( warehouse__in=accessible_warehouses ).select_related('warehouse') if product.authorized_locations.exists(): locations = locations.filter(id__in=product.authorized_locations.all()) locations = locations.order_by('warehouse__name', 'name') # Index des stocks actuels pour ce produit stock_map = { s['location_id']: s['quantity'] for s in Stock.objects.filter( product=product, location__warehouse__in=accessible_warehouses ).values('location_id', 'quantity') } data = [ { 'id': loc.id, 'name': loc.name, 'warehouse_name': loc.warehouse.name, 'quantity': stock_map.get(loc.id, 0), } for loc in locations ] return JsonResponse({'locations': data}) @login_required def serialized_items_for_product_api(request, product_id): """ Retourne les articles tracés (SerializedItem) disponibles pour un produit, filtrés selon le type de mouvement. """ product = get_object_or_404(Product, pk=product_id, is_serialized=True) movement_type = request.GET.get('movement_type', 'out') if movement_type == 'in': # Retours / récupérations : articles attribués ou perdus items = product.serialized_items.filter( is_active=True, status__in=[SerializedItem.STATUS_ASSIGNED, SerializedItem.STATUS_LOST] ) else: # Sorties / transferts : articles disponibles ou attribués items = product.serialized_items.filter( is_active=True, status__in=[SerializedItem.STATUS_AVAILABLE, SerializedItem.STATUS_ASSIGNED] ) data = [ { 'id': item.pk, 'serial_number': item.serial_number, 'status': item.status, 'status_display': item.get_status_display(), 'holder': str(item.current_holder) if item.current_holder else None, 'location': ( f"{item.current_location.warehouse.name} — {item.current_location.name}" if item.current_location else None ), } for item in items.select_related( 'current_holder', 'current_location__warehouse' ).order_by('serial_number') ] return JsonResponse({'items': data}) # def designs_autocomplete_api(request): # designs = ProductDesign.objects.filter(status=ProductDesign.STATUS_ACTIVE) # ... # return JsonResponse({'results': []}, safe=False) def designs_autocomplete_api(request): return JsonResponse({'results': []}, safe=False) def sign_panel_types_api(request): """ API endpoint pour l'autocomplete des types de panneaux de signalisation. Paramètres: - q: terme de recherche (code ou nom) """ from sign.models import SignPanelType from django.utils.translation import get_language qs = SignPanelType.objects.all().order_by('code') search = request.GET.get('q', '').strip() if search: qs = qs.filter( models.Q(code__icontains=search) | models.Q(name_fr__icontains=search) | models.Q(name_nl__icontains=search) ) lang = get_language() or 'fr' results = [ { 'id': pt.id, 'code': pt.code, 'name': getattr(pt, f'name_{lang}', None) or pt.name_fr, 'text': f"{pt.code} — {getattr(pt, f'name_{lang}', None) or pt.name_fr}", } for pt in qs[:100] ] return JsonResponse({'results': results}) def stock_movements(request): 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.order_by("-date")[:1000] production_orders = ProductionOrder.objects.all().select_related( 'warehouse', 'preparation_order' ).order_by("-created_at")[:1000] # Filtrer les mouvements de stock et les inventaires selon les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) movements = StockMovement.objects.filter( models.Q(source_location__warehouse__id__in=accessible_warehouses) | models.Q(destination_location__warehouse__id__in=accessible_warehouses) ).select_related("batch", "batch__person", "batch__company", "batch__contract").order_by("-date")[:1000] batches_with_movements = StockMovementBatch.objects.filter( models.Q(movements__source_location__warehouse__id__in=accessible_warehouses) | models.Q(movements__destination_location__warehouse__id__in=accessible_warehouses) ).distinct().select_related( "person", "company", "contract", "created_by" ).prefetch_related( models.Prefetch( "movements", queryset=StockMovement.objects.select_related( "product", "source_location", "destination_location" ) ) ).order_by("-date")[:1000] inventories = Inventory.objects.filter( warehouse_location__warehouse__id__in=accessible_warehouses ).select_related("warehouse_location__warehouse").prefetch_related("items").order_by("-created_at")[:500] products = Product.objects.select_related("category").order_by("name") locations = WarehouseLocation.objects.select_related("warehouse").order_by("warehouse__name", "name") categories = ProductCategory.objects.order_by("name_fr") from contracts.models import CompanyMember, Company, Contract from common.models import UserConfig try: user_config = request.user.config is_admin = user_config.has_role('admin') or user_config.has_role('top_manager') can_create_preparation_order = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) can_create_production_order = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses(can_manage=True).exists() ) can_create_movement = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses(can_manage=True).exists() ) can_create_inventory = ( request.user.is_superuser or user_config.has_role('admin') or user_config.userwarehouses.filter( models.Q(can_manage=True) | models.Q(can_validate_inventory=True) ).exists() ) except UserConfig.DoesNotExist: is_admin = False can_create_preparation_order = False can_create_production_order = False can_create_movement = False can_create_inventory = False user_companies = Company.objects.filter(teams__members__user=request.user).distinct() if is_admin: company_members = CompanyMember.objects.all().prefetch_related("teams__company").order_by("name") elif user_companies.exists(): company_members = CompanyMember.objects.filter( teams__company__in=user_companies ).prefetch_related("teams__company").order_by("name") else: company_members = CompanyMember.objects.none() companies = Company.objects.order_by("name") contracts = Contract.objects.filter(is_active=True).order_by("contract_number") return render( request, "stock/stock_movements.html", { "preparation_orders": preparation_orders, "production_orders": production_orders, "movements": movements, "batches_with_movements": batches_with_movements, "inventories": inventories, "products": products, "locations": locations, "categories": categories, "company_members": company_members, "companies": companies, "contracts": contracts, "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")), ], "can_create_preparation_order": can_create_preparation_order, "can_create_production_order": can_create_production_order, "can_create_movement": can_create_movement, "can_create_inventory": can_create_inventory, }, ) def new_stock_movement(request): """ Crée un nouveau mouvement de stock manuellement. """ from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses(can_manage=True).exists() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de créer des mouvements de stock.")) return redirect('stock:stock_movements') if request.method == 'POST': product_id = request.POST.get('product_id') movement_type = request.POST.get('movement_type') # 'in', 'out', 'transfer' source_location_id = request.POST.get('source_location_id') or None destination_location_id = request.POST.get('destination_location_id') or None quantity_raw = request.POST.get('quantity', '0') reason = request.POST.get('reason', '').strip() try: quantity = int(quantity_raw) if quantity <= 0: raise ValueError except ValueError: messages.error(request, _("La quantité doit être un entier positif.")) return redirect('stock:stock_movements') product = get_object_or_404(Product, pk=product_id) source_location = get_object_or_404(WarehouseLocation, pk=source_location_id) if source_location_id else None destination_location = get_object_or_404(WarehouseLocation, pk=destination_location_id) if destination_location_id else None from stock.permissions import user_can_manage_warehouse if source_location and not user_can_manage_warehouse(request.user, source_location.warehouse): messages.error(request, _("Vous n'avez pas la permission de gérer l'entrepôt source.")) return redirect('stock:stock_movements') if destination_location and not user_can_manage_warehouse(request.user, destination_location.warehouse): messages.error(request, _("Vous n'avez pas la permission de gérer l'entrepôt de destination.")) return redirect('stock:stock_movements') if movement_type == 'in' and not destination_location: messages.error(request, _("Une entrée nécessite un emplacement de destination.")) return redirect('stock:stock_movements') if movement_type == 'out' and not source_location: messages.error(request, _("Une sortie nécessite un emplacement source.")) return redirect('stock:stock_movements') if movement_type == 'transfer' and (not source_location or not destination_location): messages.error(request, _("Un transfert nécessite un emplacement source et un emplacement destination.")) return redirect('stock:stock_movements') # Appliquer le mouvement sur le stock if movement_type == 'out' or movement_type == 'transfer': stock_out, created = Stock.objects.get_or_create( product=product, location=source_location, defaults={'quantity': 0} ) stock_out.quantity = max(0, stock_out.quantity - quantity) stock_out.save() if movement_type == 'in' or movement_type == 'transfer': stock_in, created = Stock.objects.get_or_create( product=product, location=destination_location, defaults={'quantity': 0} ) stock_in.quantity += quantity stock_in.save() StockMovement.objects.create( product=product, source_location=source_location if movement_type in ('out', 'transfer') else None, destination_location=destination_location if movement_type in ('in', 'transfer') else None, quantity=quantity, reason=reason or None, ) messages.success(request, _("Mouvement de stock créé avec succès.")) return redirect('stock:stock_movements') return redirect('stock:stock_movements') def stock_movement_detail(request, pk): """ Affiche le détail d'un mouvement de stock. """ movement = get_object_or_404( StockMovement.objects.select_related( "product", "product__category", "source_location", "source_location__warehouse", "destination_location", "destination_location__warehouse", "batch", "batch__person", "batch__company", "batch__contract", ), pk=pk, ) return render(request, "stock/stock_movement_detail.html", {"movement": movement}) def _apply_serialized_item_events(items, movement_type, src_location, dst_location, person, notes, user): """Crée les événements et met à jour les articles tracés suite à un mouvement de stock.""" from django.utils import timezone if movement_type == 'in': event_type = SerializedItemEvent.TYPE_RETURNED elif movement_type == 'out': event_type = SerializedItemEvent.TYPE_ASSIGNED else: event_type = SerializedItemEvent.TYPE_TRANSFERRED for item in items: if movement_type == 'in': item.status = SerializedItem.STATUS_AVAILABLE item.current_holder = None item.current_location = dst_location elif movement_type == 'out': item.status = SerializedItem.STATUS_ASSIGNED if person else SerializedItem.STATUS_AVAILABLE item.current_holder = person item.current_location = None else: # transfer item.current_location = dst_location item.save(update_fields=['status', 'current_holder', 'current_location']) SerializedItemEvent.objects.create( item=item, event_type=event_type, date=timezone.now(), person=person, from_location=src_location if movement_type in ('out', 'transfer') else None, to_location=dst_location if movement_type in ('in', 'transfer') else None, notes=notes or '', created_by=user, ) # Mapping event_type → (movement_type, subtype) ; None = pas de mouvement _SERIALIZED_EVENT_MOVEMENT_MAP = { SerializedItemEvent.TYPE_ENTRY: ('in', 'purchase'), SerializedItemEvent.TYPE_RETURNED: ('in', 'return'), SerializedItemEvent.TYPE_ASSIGNED: ('out', 'epi'), SerializedItemEvent.TYPE_TRANSFERRED: ('transfer', 'internal'), SerializedItemEvent.TYPE_LOST: ('out', 'loss'), SerializedItemEvent.TYPE_RETIRED: ('out', None), SerializedItemEvent.TYPE_NOTE: None, } def _create_stock_movement_for_serialized_event( item, event_type, from_location, to_location, person, notes, user ): """ Crée un StockMovementBatch + StockMovement lié à l'événement sur un article tracé. Ne fait rien si le type d'événement n'implique pas de mouvement de stock ou si les emplacements nécessaires sont absents. """ mapping = _SERIALIZED_EVENT_MOVEMENT_MAP.get(event_type) if mapping is None: return # pas de mouvement pour ce type (ex. note) movement_type, subtype = mapping # Vérifications de cohérence des emplacements if movement_type == 'in' and not to_location: return if movement_type == 'out' and not from_location: return if movement_type == 'transfer' and not (from_location and to_location): return with transaction.atomic(): batch = StockMovementBatch.objects.create( movement_type=movement_type, movement_subtype=subtype, reason=notes or None, person=person, created_by=user, ) movement = StockMovement.objects.create( product=item.product, source_location=from_location if movement_type in ('out', 'transfer') else None, destination_location=to_location if movement_type in ('in', 'transfer') else None, quantity=1, reason=notes or None, batch=batch, ) movement.serialized_items.add(item) # Mise à jour des quantités en stock if movement_type in ('out', 'transfer') and from_location: stock_out, _ = Stock.objects.get_or_create( product=item.product, location=from_location, defaults={'quantity': 0} ) stock_out.quantity = max(0, stock_out.quantity - 1) stock_out.save() if movement_type in ('in', 'transfer') and to_location: stock_in, _ = Stock.objects.get_or_create( product=item.product, location=to_location, defaults={'quantity': 0} ) stock_in.quantity += 1 stock_in.save() def _new_stock_batch_view(request, movement_type, template_name="stock/new_stock_batch.html", cancel_url_name="stock:stock_movements", success_redirect_fn=None): """ Vue générique pour créer un lot de mouvements de stock (entrée, sortie ou transfert). Gère plusieurs lignes produit + documents attachés. """ from common.models import UserConfig from contracts.models import CompanyMember, Company, Contract try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses(can_manage=True).exists() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de créer des mouvements de stock.")) return redirect(cancel_url_name) products = Product.objects.select_related("category").order_by("name") locations = list(WarehouseLocation.objects.select_related("warehouse").order_by("warehouse__name", "name")) from stock.permissions import user_can_administer_warehouse for loc in locations: loc.can_administer = user_can_administer_warehouse(request.user, loc.warehouse) categories = ProductCategory.objects.order_by("name_fr") try: is_admin = request.user.config.has_role('admin') or request.user.config.has_role('top_manager') except UserConfig.DoesNotExist: is_admin = False user_companies = Company.objects.filter(teams__members__user=request.user).distinct() if is_admin: company_members = CompanyMember.objects.all().prefetch_related("teams__company").order_by("name") elif user_companies.exists(): company_members = CompanyMember.objects.filter( teams__company__in=user_companies ).prefetch_related("teams__company").order_by("name") else: company_members = CompanyMember.objects.none() companies = Company.objects.order_by("name") contracts = Contract.objects.filter(is_active=True).order_by("contract_number") if request.method == 'POST': reason = request.POST.get('reason', '').strip() or None movement_subtype = request.POST.get('movement_subtype', '').strip() or None requester = request.POST.get('requester', '').strip() or None person_id = request.POST.get('person_id') or None company_name = request.POST.get('company_name', '').strip() or None contract_id = request.POST.get('contract_id') or None preparation_order_id = request.POST.get('preparation_order_id') or None if movement_type == 'in' else None estimated_return_date_str = request.POST.get('estimated_return_date', '').strip() estimated_return_date = None if estimated_return_date_str: try: from django.utils.dateparse import parse_date estimated_return_date = parse_date(estimated_return_date_str) except ValueError: pass is_loan = (movement_subtype == 'loan') from contracts.models import CompanyMember, Company, Contract person = get_object_or_404(CompanyMember, pk=person_id) if person_id else None company = None if company_name: company = Company.objects.filter(name__iexact=company_name).first() if not company: company = Company.objects.create(name=company_name) contract = get_object_or_404(Contract, pk=contract_id) if contract_id else None product_ids = request.POST.getlist('product_id') quantities = request.POST.getlist('quantity') global_src_id = request.POST.get('global_source_location_id') or None global_dst_id = request.POST.get('global_destination_location_id') or None if movement_type == 'transfer': source_ids = [global_src_id] * len(product_ids) dest_ids = [global_dst_id] * len(product_ids) elif movement_type == 'out': source_ids = [global_src_id] * len(product_ids) dest_ids = [None] * len(product_ids) elif movement_type == 'in': source_ids = [None] * len(product_ids) dest_ids = [global_dst_id] * len(product_ids) else: source_ids = request.POST.getlist('source_location_id') dest_ids = request.POST.getlist('destination_location_id') serialized_ids_list = request.POST.getlist('serialized_item_ids') serialized_new_serials_list = request.POST.getlist('serialized_new_serials') has_subtype_permission = True if movement_subtype == 'correction': selected_loc_id = global_dst_id if movement_type == 'in' else global_src_id if selected_loc_id: try: selected_loc = WarehouseLocation.objects.get(id=selected_loc_id) from stock.permissions import user_can_administer_warehouse if not user_can_administer_warehouse(request.user, selected_loc.warehouse): has_subtype_permission = False except WarehouseLocation.DoesNotExist: has_subtype_permission = False else: has_subtype_permission = False from stock.permissions import user_can_manage_warehouse has_manage_permission = True if global_src_id: try: src_loc = WarehouseLocation.objects.get(id=global_src_id) if not user_can_manage_warehouse(request.user, src_loc.warehouse): has_manage_permission = False except WarehouseLocation.DoesNotExist: has_manage_permission = False if global_dst_id: try: dst_loc = WarehouseLocation.objects.get(id=global_dst_id) if not user_can_manage_warehouse(request.user, dst_loc.warehouse): has_manage_permission = False except WarehouseLocation.DoesNotExist: has_manage_permission = False if not movement_subtype and movement_type != 'transfer': messages.error(request, _("Le sous-type est obligatoire.")) elif movement_subtype == 'correction' and not has_subtype_permission: messages.error(request, _("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.")) elif not has_manage_permission: messages.error(request, _("Vous n'avez pas la permission de gérer l'entrepôt impliqué dans ce mouvement.")) elif movement_type == 'in' and not global_dst_id: messages.error(request, _("L'emplacement destination est obligatoire pour une entrée.")) elif movement_type == 'out' and not global_src_id: messages.error(request, _("L'emplacement source est obligatoire pour une sortie.")) elif movement_type == 'transfer' and (not global_src_id or not global_dst_id): messages.error(request, _("Les emplacements source et destination sont obligatoires pour un transfert.")) elif not product_ids: messages.error(request, _("Veuillez ajouter au moins une ligne produit.")) else: errors = [] lines = [] for i, pid in enumerate(product_ids): if not pid: continue try: qty = int(quantities[i]) if i < len(quantities) else 0 if qty <= 0: raise ValueError except (ValueError, IndexError): errors.append(_("Ligne %(n)s : quantité invalide.") % {'n': i + 1}) continue src_id = source_ids[i] if i < len(source_ids) else None dst_id = dest_ids[i] if i < len(dest_ids) else None src = get_object_or_404(WarehouseLocation, pk=src_id) if src_id else None dst = get_object_or_404(WarehouseLocation, pk=dst_id) if dst_id else None if movement_type == 'in' and not dst: errors.append(_("Ligne %(n)s : emplacement de destination requis pour une entrée.") % {'n': i + 1}) continue if movement_type == 'out' and not src: errors.append(_("Ligne %(n)s : emplacement source requis pour une sortie.") % {'n': i + 1}) continue if movement_type == 'transfer' and (not src or not dst): errors.append(_("Ligne %(n)s : emplacements source et destination requis pour un transfert.") % {'n': i + 1}) continue try: product_obj = Product.objects.prefetch_related('authorized_locations').get(pk=pid) if product_obj.authorized_locations.exists(): auth_locs = product_obj.authorized_locations.all() if src and src not in auth_locs: errors.append(_("Ligne %(n)s : l'emplacement source '%(loc)s' n'est pas autorisé pour le produit '%(prod)s'.") % {'n': i + 1, 'loc': src.name, 'prod': product_obj.name}) continue if dst and dst not in auth_locs: errors.append(_("Ligne %(n)s : l'emplacement de destination '%(loc)s' n'est pas autorisé pour le produit '%(prod)s'.") % {'n': i + 1, 'loc': dst.name, 'prod': product_obj.name}) continue except Product.DoesNotExist: errors.append(_("Ligne %(n)s : produit invalide.") % {'n': i + 1}) continue lines.append({'product_id': pid, 'qty': qty, 'src': src, 'dst': dst}) if errors: for err in errors: messages.error(request, err) elif lines: with transaction.atomic(): linked_prep_order = None if preparation_order_id: linked_prep_order = PreparationOrder.objects.filter(pk=preparation_order_id).first() batch = StockMovementBatch.objects.create( movement_type=movement_type, movement_subtype=movement_subtype, reason=reason, requester=requester, person=person, company=company, contract=contract, created_by=request.user, preparation_order=linked_prep_order, is_loan=is_loan, estimated_return_date=estimated_return_date, ) for line in lines: product = get_object_or_404(Product, pk=line['product_id']) qty = line['qty'] src = line['src'] dst = line['dst'] if movement_type in ('out', 'transfer') and src: stock_out, created = Stock.objects.get_or_create( product=product, location=src, defaults={'quantity': 0} ) stock_out.quantity = max(0, stock_out.quantity - qty) stock_out.save() if movement_type in ('in', 'transfer') and dst: stock_in, created = Stock.objects.get_or_create( product=product, location=dst, defaults={'quantity': 0} ) stock_in.quantity += qty stock_in.save() sm = StockMovement.objects.create( product=product, source_location=src if movement_type in ('out', 'transfer') else None, destination_location=dst if movement_type in ('in', 'transfer') else None, quantity=qty, reason=reason, batch=batch, ) # Articles tracés (numéros de série) if product.is_serialized: ids_str = serialized_ids_list[i] if i < len(serialized_ids_list) else '' s_ids = [int(x) for x in ids_str.split(',') if x.strip().isdigit()] s_items = list(SerializedItem.objects.filter(pk__in=s_ids, product=product)) if s_ids else [] if s_items: _apply_serialized_item_events( s_items, movement_type, src, dst, person, reason, request.user, ) # Nouveaux articles tracés (entrée uniquement) new_serials_str = serialized_new_serials_list[i] if i < len(serialized_new_serials_list) else '' new_items = [] if movement_type == 'in' and new_serials_str.strip(): for sn in [s.strip() for s in new_serials_str.split(',') if s.strip()]: already = SerializedItem.objects.filter(serial_number=sn).first() if already: if already.product_id == product.pk: new_items.append(already) continue new_si = SerializedItem( product=product, serial_number=sn, status=SerializedItem.STATUS_AVAILABLE, current_location=dst, created_by=request.user, ) new_si.save() SerializedItemEvent.objects.create( item=new_si, event_type=SerializedItemEvent.TYPE_ENTRY, to_location=dst, notes=reason or '', created_by=request.user, ) new_items.append(new_si) all_items = s_items + new_items if all_items: sm.serialized_items.set(all_items) # Documents attachés for f in request.FILES.getlist('documents'): StockMovementDocument.objects.create( batch=batch, file=f, name=f.name, ) messages.success(request, _("Lot de mouvements de stock créé avec succès.")) if success_redirect_fn: return success_redirect_fn(request, batch) return redirect('stock:stock_movement_batch_detail', pk=batch.pk) type_labels = { 'in': _("Nouvelle entrée de stock"), 'out': _("Nouvelle sortie de stock"), 'transfer': _("Nouveau transfert de stock"), } return render(request, template_name, { "movement_type": movement_type, "movement_type_label": type_labels.get(movement_type, ""), "products": products, "locations": locations, "categories": categories, "company_members": company_members, "companies": companies, "contracts": contracts, "cancel_url_name": cancel_url_name, }) def new_stock_entry(request): """Crée une nouvelle entrée de stock (lot multi-produits).""" return _new_stock_batch_view(request, 'in') def new_stock_exit(request): """Crée une nouvelle sortie de stock (lot multi-produits).""" return _new_stock_batch_view(request, 'out') def new_stock_transfer(request): """Crée un nouveau transfert de stock (lot multi-produits).""" return _new_stock_batch_view(request, 'transfer') def stock_movement_batch_detail(request, pk): """Affiche le détail d'un lot de mouvements de stock.""" from stock.models import StockBatchSignature from stock.permissions import get_user_accessible_warehouses import base64 from django.core.files.base import ContentFile batch = get_object_or_404( StockMovementBatch.objects.select_related( "person", "company", "contract", "created_by", "preparation_order", "purchase_order", "purchase_order__supplier", ).prefetch_related( "person__teams", "movements__product__category", "movements__source_location__warehouse", "movements__destination_location__warehouse", "signatures__signed_by", "documents", ), pk=pk, ) # 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('stock:stock_movement_batch_detail', pk=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.")) else: messages.error(request, _("Aucune signature fournie.")) return redirect('stock:stock_movement_batch_detail', pk=pk) return render(request, "stock/stock_movement_batch_detail.html", { "batch": batch, "can_add_signature": can_add_signature, }) def stock_purchases(request): # Filtrer les commandes d'achat selon les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) purchase_orders = ( PurchaseOrder.objects.filter( warehouse__id__in=accessible_warehouses ).select_related("supplier", "warehouse") .prefetch_related("items__product", "items__location") .all() ) # Récupérer les demandes d'offre request_for_quotes = ( RequestForQuote.objects.filter( warehouse__id__in=accessible_warehouses ).select_related("warehouse", "created_by") .prefetch_related("items__product", "suppliers") .all() ) # Récupérer tous les fournisseurs suppliers = Supplier.objects.all().order_by("name") # Vérifier si l'utilisateur est stock_manager from common.models import UserConfig try: user_config = request.user.config is_stock_manager = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses().exists() ) can_create_purchase_order = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any() ) can_create_supplier = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any() ) except UserConfig.DoesNotExist: is_stock_manager = False can_create_purchase_order = False can_create_supplier = False for order in purchase_orders: order.can_receive = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(order.warehouse) ) return render( request, "stock/stock_purchases.html", { "purchase_orders": purchase_orders, "request_for_quotes": request_for_quotes, "suppliers": suppliers, "is_stock_manager": is_stock_manager, "can_create_purchase_order": can_create_purchase_order, "can_create_supplier": can_create_supplier, }, ) def new_purchase_order(request): from common.models import UserConfig 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('stock:stock_purchases') except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour créer des bons de commande.")) return redirect('stock:stock_purchases') accessible_warehouses = get_user_accessible_warehouses(request.user) warehouse = None rfq = None rfq_id = request.GET.get("rfq") if rfq_id: rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses), pk=rfq_id ) warehouse = rfq.warehouse if request.method == "POST": form = PurchaseOrderForm(request.POST) warehouse_id = request.POST.get("warehouse") if warehouse_id: warehouse = Warehouse.objects.filter(pk=warehouse_id).first() if warehouse and not (request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(warehouse)): messages.error(request, _("Vous n'avez pas la permission de créer un bon de commande pour cet entrepôt.")) return redirect('stock:stock_purchases') formset = PurchaseOrderItemFormSet( request.POST, form_kwargs={"warehouse": warehouse} ) if form.is_valid() and formset.is_valid(): purchase_order = form.save() formset.instance = purchase_order formset.save() if purchase_order.request_for_quote: original_rfq = purchase_order.request_for_quote original_rfq.status = "completed" original_rfq.save(update_fields=["status"]) if purchase_order.status == "received": purchase_order.receive() messages.success( request, _("La commande a été reçue et le stock mis à jour."), ) else: messages.success(request, _("La commande a été enregistrée.")) return redirect("stock:purchase_order_detail", pk=purchase_order.pk) else: if rfq: form = PurchaseOrderForm(initial={ "warehouse": rfq.warehouse, "request_for_quote": rfq, }) initial_items = [] for item in rfq.items.all(): # On essaie d'utiliser la location par défaut si disponible pour l'entrepôt default_loc = WarehouseLocation.objects.filter( warehouse=rfq.warehouse, is_default=True ).first() initial_items.append({ "product": item.product, "num_articles": item.num_articles, "quantity_per_article": item.quantity_per_article, "quantity": item.quantity, "location": default_loc.pk if default_loc else None, }) formset = PurchaseOrderItemFormSet(initial=initial_items, form_kwargs={"warehouse": rfq.warehouse}) formset.extra = max(1, len(initial_items)) else: form = PurchaseOrderForm() formset = PurchaseOrderItemFormSet() import json as _json products_data = [ { "id": p.id, "code": p.code, "name": p.name, "category_id": p.category_id, "unit": p.unit, "unit_display": p.get_unit_display(), "authorized_location_ids": list(p.authorized_locations.values_list('id', flat=True)) if p.authorized_locations.exists() else None, } for p in Product.objects.filter(is_active=True).select_related("category").prefetch_related("authorized_locations").order_by("name") ] categories = ProductCategory.objects.order_by("name_fr") from django.urls import reverse as _reverse cancel_url = _reverse("stock:stock_purchases") return render( request, "stock/purchase_order_form.html", {"form": form, "formset": formset, "products_data": products_data, "categories": categories, "cancel_url": cancel_url}, ) def edit_purchase_order(request, pk): """ Modifier un bon de commande existant (articles, emplacements, quantités). """ accessible_warehouses = get_user_accessible_warehouses(request.user) purchase_order = get_object_or_404( PurchaseOrder.objects.filter(warehouse__id__in=accessible_warehouses), pk=pk ) from common.models import UserConfig try: user_config = request.user.config can_manage = request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(purchase_order.warehouse) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas les droits pour modifier ce bon de commande.")) return redirect('stock:purchase_order_detail', pk=pk) if purchase_order.status != 'pending': messages.error(request, _("Ce bon de commande ne peut plus être modifié (statut : %(status)s).") % {'status': purchase_order.get_status_display()}) return redirect('stock:purchase_order_detail', pk=pk) warehouse = purchase_order.warehouse if request.method == "POST": form = PurchaseOrderForm(request.POST, instance=purchase_order) formset = PurchaseOrderItemFormSet( request.POST, instance=purchase_order, form_kwargs={"warehouse": warehouse} ) if form.is_valid() and formset.is_valid(): form.save() formset.save() messages.success(request, _("La commande a été mise à jour.")) return redirect("stock:purchase_order_detail", pk=purchase_order.pk) else: form = PurchaseOrderForm(instance=purchase_order) formset = PurchaseOrderItemFormSet(instance=purchase_order, form_kwargs={"warehouse": warehouse}) import json as _json current_product_ids = set(purchase_order.items.values_list('product_id', flat=True)) products_data = [ { "id": p.id, "code": p.code, "name": p.name, "category_id": p.category_id, "unit": p.unit, "unit_display": p.get_unit_display(), "authorized_location_ids": list(p.authorized_locations.values_list('id', flat=True)) if p.authorized_locations.exists() else None, } for p in Product.objects.filter(models.Q(is_active=True) | models.Q(id__in=current_product_ids)).select_related("category").prefetch_related("authorized_locations").order_by("name") ] categories = ProductCategory.objects.order_by("name_fr") from django.urls import reverse as _reverse from common.private_files.helpers import build_private_url cancel_url = _reverse("stock:purchase_order_detail", kwargs={"pk": pk}) documents = [] for doc in purchase_order.documents.all().order_by('uploaded_at'): documents.append({ 'id': doc.pk, 'filename': doc.filename(), 'is_image': doc.is_image(), 'uploaded_at': doc.uploaded_at.strftime('%d/%m/%Y %H:%M'), 'url': build_private_url('stock', 'PurchaseOrderDocument', doc.pk, 'file'), 'delete_url': _reverse('stock:purchase_order_document_delete', args=[doc.pk]), }) return render( request, "stock/purchase_order_form.html", { "form": form, "formset": formset, "products_data": products_data, "categories": categories, "purchase_order": purchase_order, "cancel_url": cancel_url, "documents": documents, "upload_url": _reverse('stock:purchase_order_document_upload', args=[purchase_order.pk]), }, ) def request_for_quote_detail(request, pk): accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses) .select_related("warehouse", "created_by") .prefetch_related("items__product", "suppliers"), pk=pk, ) from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(rfq.warehouse) ) except UserConfig.DoesNotExist: can_manage = False return render( request, "stock/request_for_quote_detail.html", { "rfq": rfq, "can_manage": can_manage, }, ) def new_request_for_quote(request): from common.models import UserConfig 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 demandes d'offre.")) return redirect('stock:stock_purchases') except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour créer des demandes d'offre.")) return redirect('stock:stock_purchases') if request.method == "POST": form = RequestForQuoteForm(request.POST) warehouse_id = request.POST.get("warehouse") if warehouse_id: warehouse = Warehouse.objects.filter(pk=warehouse_id).first() if warehouse and not (request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(warehouse)): messages.error(request, _("Vous n'avez pas la permission de créer une demande d'offre pour cet entrepôt.")) return redirect('stock:stock_purchases') formset = RequestForQuoteItemFormSet(request.POST) if form.is_valid() and formset.is_valid(): rfq = form.save(commit=False) rfq.created_by = request.user rfq.save() form.save_m2m() # For suppliers ManyToMany formset.instance = rfq formset.save() messages.success(request, _("La demande d'offre a été enregistrée.")) return redirect("stock:request_for_quote_detail", pk=rfq.pk) else: form = RequestForQuoteForm() formset = RequestForQuoteItemFormSet() import json as _json products_data = [ { "id": p.id, "code": p.code, "name": p.name, "category_id": p.category_id, "unit": p.unit, "unit_display": p.get_unit_display(), } for p in Product.objects.filter(is_active=True).select_related("category").order_by("name") ] categories = ProductCategory.objects.order_by("name_fr") from django.urls import reverse as _reverse cancel_url = _reverse("stock:stock_purchases") return render( request, "stock/request_for_quote_form.html", { "form": form, "formset": formset, "products_data": products_data, "categories": categories, "cancel_url": cancel_url, }, ) def edit_request_for_quote(request, pk): accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses), pk=pk ) from common.models import UserConfig try: user_config = request.user.config can_manage = request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(rfq.warehouse) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas les droits pour modifier cette demande d'offre.")) return redirect('stock:request_for_quote_detail', pk=pk) if rfq.status != 'draft': messages.error(request, _("Cette demande d'offre ne peut plus être modifiée (statut : %(status)s).") % {'status': rfq.get_status_display()}) return redirect('stock:request_for_quote_detail', pk=pk) if request.method == "POST": form = RequestForQuoteForm(request.POST, instance=rfq) formset = RequestForQuoteItemFormSet(request.POST, instance=rfq) if form.is_valid() and formset.is_valid(): form.save() formset.save() messages.success(request, _("La demande d'offre a été mise à jour.")) return redirect("stock:request_for_quote_detail", pk=rfq.pk) else: form = RequestForQuoteForm(instance=rfq) formset = RequestForQuoteItemFormSet(instance=rfq) import json as _json current_product_ids = set(rfq.items.values_list('product_id', flat=True)) products_data = [ { "id": p.id, "code": p.code, "name": p.name, "category_id": p.category_id, "unit": p.unit, "unit_display": p.get_unit_display(), } for p in Product.objects.filter(models.Q(is_active=True) | models.Q(id__in=current_product_ids)).select_related("category").order_by("name") ] categories = ProductCategory.objects.order_by("name_fr") from django.urls import reverse as _reverse cancel_url = _reverse("stock:request_for_quote_detail", kwargs={"pk": pk}) return render( request, "stock/request_for_quote_form.html", { "form": form, "formset": formset, "products_data": products_data, "categories": categories, "rfq": rfq, "cancel_url": cancel_url, }, ) @require_POST def request_for_quote_cancel(request, pk): accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses), pk=pk ) from common.models import UserConfig 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_for(rfq.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer cette demande d'offre.")) return redirect('stock:request_for_quote_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer cette demande d'offre.")) return redirect('stock:request_for_quote_detail', pk=pk) if rfq.status in ('draft', 'sent'): rfq.status = 'cancelled' rfq.save(update_fields=['status']) messages.success(request, _("La demande d'offre a été annulée.")) else: messages.error(request, _("Cette demande d'offre ne peut pas être annulée.")) return redirect('stock:request_for_quote_detail', pk=pk) @require_POST def request_for_quote_ordered(request, pk): accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses), pk=pk ) from common.models import UserConfig 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_for(rfq.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer cette demande d'offre.")) return redirect('stock:request_for_quote_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer cette demande d'offre.")) return redirect('stock:request_for_quote_detail', pk=pk) if rfq.status == 'draft': rfq.status = 'sent' rfq.save(update_fields=['status']) messages.success(request, _("La demande d'offre a été marquée comme envoyée.")) else: messages.error(request, _("Cette demande d'offre ne peut pas être marquée comme envoyée.")) return redirect('stock:request_for_quote_detail', pk=pk) def request_for_quote_pdf(request, pk): import base64 as _base64 import os as _os import tempfile as _tempfile from fpdf import FPDF from django.http import HttpResponse from django.conf import settings as _settings accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses) .select_related("warehouse") .prefetch_related("items__product", "suppliers"), pk=pk, ) class RequestForQuotePDF(FPDF): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.background_path = _os.path.join(_settings.BASE_DIR, 'common', 'static', 'common', 'background_bm_A4_fr.png') def header(self): if _os.path.exists(self.background_path): self.image(self.background_path, x=0, y=0, w=210, h=297) pdf = RequestForQuotePDF() pdf.add_page() pdf.set_auto_page_break(auto=True, margin=15) PAGE_W = 190 CELL_PAD = 2 LINE_H = 5 def wrap_text(text, col_w): available = col_w - 2 * CELL_PAD words = text.split() lines, current = [], "" for word in words: test = (current + " " + word).strip() if pdf.get_string_width(test) <= available: current = test else: if current: lines.append(current) current = word if current: lines.append(current) return lines if lines else [""] def draw_multiline_cell(x, y, w, lines, row_h): n_slots = round(row_h / LINE_H) for i in range(n_slots): b = "LR" if i == 0: b += "T" if i == n_slots - 1: b += "B" text = lines[i] if i < len(lines) else "" pdf.set_xy(x, y + i * LINE_H) pdf.cell(w, LINE_H, text, border=b) # En-tête pdf.set_y(45) pdf.set_font("Helvetica", "B", 18) pdf.cell(0, 12, "Demande d'offre", new_x="LMARGIN", new_y="NEXT", align="C") pdf.set_font("Helvetica", "B", 14) pdf.cell(0, 8, str(rfq.code), new_x="LMARGIN", new_y="NEXT", align="C") pdf.ln(4) # Informations générales col_w = PAGE_W / 2 def row(label, value): pdf.set_font("Helvetica", "B", 10) lbl = (label + " :") if label else "" pdf.cell(col_w, 7, lbl) pdf.set_font("Helvetica", "", 10) pdf.cell(col_w, 7, str(value), new_x="LMARGIN", new_y="NEXT") row("Date de création", rfq.date_created.strftime("%d/%m/%Y")) row("Entrepôt de livraison", rfq.warehouse.name) address_lines = [] if rfq.warehouse.manager: address_lines.append(rfq.warehouse.manager) if rfq.warehouse.location: address_lines.extend([line.strip() for line in rfq.warehouse.location.splitlines() if line.strip()]) for i, line in enumerate(address_lines): lbl = "Adresse de livraison" if i == 0 else "" row(lbl, line) if rfq.notes: pdf.set_font("Helvetica", "B", 10) pdf.cell(col_w, 7, "Notes :") pdf.set_font("Helvetica", "", 10) pdf.cell(col_w, 7, rfq.notes, new_x="LMARGIN", new_y="NEXT") pdf.ln(6) # Tableau des articles # Code(35) + Produit(80) + Unité(25) + Articles(25) + Qté/Art.(25) = 190 col_widths = [35, 80, 25, 25, 25] col_labels = ["Code", "Produit", "Unité", "Articles", "Qté/Art."] pdf.set_font("Helvetica", "B", 10) pdf.set_fill_color(230, 230, 230) for hdr, w in zip(col_labels, col_widths): pdf.cell(w, 8, hdr, border=1, fill=True, align="C") pdf.ln() pdf.set_font("Helvetica", "", 9) left_margin = pdf.l_margin for item in rfq.items.all(): product_lines = wrap_text(item.product.name, col_widths[1]) row_h = len(product_lines) * LINE_H y0 = pdf.get_y() col_x = [] x = left_margin for w in col_widths: col_x.append(x) x += w draw_multiline_cell(col_x[0], y0, col_widths[0], [item.product.code or ""], row_h) draw_multiline_cell(col_x[1], y0, col_widths[1], product_lines, row_h) draw_multiline_cell(col_x[2], y0, col_widths[2], [item.product.unit or ""], row_h) draw_multiline_cell(col_x[3], y0, col_widths[3], [str(item.num_articles) if item.num_articles else "-"], row_h) draw_multiline_cell(col_x[4], y0, col_widths[4], [str(item.quantity_per_article) if item.quantity_per_article else "-"], row_h) pdf.set_y(y0 + row_h) response = HttpResponse(bytes(pdf.output()), content_type="application/pdf") response["Content-Disposition"] = f'inline; filename="{rfq.code}.pdf"' return response def request_for_quote_xlsx(request, pk): import io from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from django.http import HttpResponse accessible_warehouses = get_user_accessible_warehouses(request.user) rfq = get_object_or_404( RequestForQuote.objects.filter(warehouse__id__in=accessible_warehouses) .select_related("warehouse") .prefetch_related("items__product"), pk=pk, ) wb = Workbook() ws = wb.active ws.title = "Demande d'offre" # Styles title_font = Font(bold=True, size=14) code_font = Font(bold=True, size=12) bold_font = Font(bold=True) header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") center_align = Alignment(horizontal="center", wrap_text=True) wrap_align = Alignment(wrap_text=True) thin = Side(style="thin") thin_border = Border(left=thin, right=thin, top=thin, bottom=thin) ws.merge_cells("A1:E1") ws["A1"] = "Demande d'offre" ws["A1"].font = title_font ws["A1"].alignment = center_align ws.merge_cells("A2:E2") ws["A2"] = str(rfq.code) ws["A2"].font = code_font ws["A2"].alignment = center_align info_rows = [ ("Date de création", rfq.date_created.strftime("%d/%m/%Y")), ("Entrepôt de livraison", rfq.warehouse.name), ("Statut", rfq.get_status_display()), ] if rfq.notes: info_rows.append(("Notes", rfq.notes)) row_num = 4 for label, value in info_rows: lbl_cell = ws.cell(row=row_num, column=1, value=label + " :") lbl_cell.font = bold_font val_cell = ws.cell(row=row_num, column=2, value=str(value)) val_cell.alignment = wrap_align ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=5) row_num += 1 row_num += 1 table_headers = ["Code", "Produit", "Unité", "Articles", "Quantité/Article"] for col, hdr in enumerate(table_headers, start=1): cell = ws.cell(row=row_num, column=col, value=hdr) cell.font = bold_font cell.fill = header_fill cell.border = thin_border cell.alignment = center_align row_num += 1 for item in rfq.items.all(): values = [ item.product.code or "", item.product.name, item.product.unit or "", item.num_articles if item.num_articles else None, item.quantity_per_article if item.quantity_per_article else None, ] for col, val in enumerate(values, start=1): cell = ws.cell(row=row_num, column=col, value=val) cell.border = thin_border cell.alignment = wrap_align row_num += 1 response_file = io.BytesIO() wb.save(response_file) response_file.seek(0) response = HttpResponse( response_file.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) response["Content-Disposition"] = f'attachment; filename="{rfq.code}.xlsx"' return response @require_POST def mark_purchase_order_ordered(request, pk): """ Marque un bon de commande comme envoyé au fournisseur. """ purchase_order = get_object_or_404(PurchaseOrder, pk=pk) from common.models import UserConfig 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_for(purchase_order.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) if purchase_order.status == 'pending': purchase_order.status = 'ordered' purchase_order.save(update_fields=['status']) messages.success(request, _("Le bon de commande a été marqué comme envoyé.")) else: messages.error(request, _("Ce bon de commande ne peut pas être marqué comme envoyé.")) return redirect('stock:purchase_order_detail', pk=pk) def purchase_order_pdf(request, pk): """ Génère un PDF pour un bon de commande. """ import base64 as _base64 import os as _os import tempfile as _tempfile from fpdf import FPDF from django.http import HttpResponse from django.conf import settings as _settings 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"), pk=pk, ) # Classe FPDF personnalisée pour ajouter l'arrière-plan class PurchaseOrderPDF(FPDF): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.background_path = _os.path.join(_settings.BASE_DIR, 'common', 'static', 'common', 'background_bm_A4_fr.png') def header(self): """Ajoute l'image d'arrière-plan sur chaque page.""" if _os.path.exists(self.background_path): # Placer l'image en arrière-plan (x=0, y=0, couvrant toute la page A4: 210x297) self.image(self.background_path, x=0, y=0, w=210, h=297) pdf = PurchaseOrderPDF() pdf.add_page() pdf.set_auto_page_break(auto=True, margin=15) # QR code généré (dynamique) en haut à droite - placé après le background pour être visible qr_b64 = purchase_order.generate_qr_code() qr_tmp_path = None try: qr_data = qr_b64.split(",", 1)[1] qr_bytes = _base64.b64decode(qr_data) with _tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp.write(qr_bytes) qr_tmp_path = tmp.name pdf.image(qr_tmp_path, x=170, y=8, w=30, h=30) except Exception: pass finally: if qr_tmp_path and _os.path.exists(qr_tmp_path): _os.unlink(qr_tmp_path) # Largeur utile : A4 (210mm) - marges gauche+droite (2×10mm) = 190mm PAGE_W = 190 CELL_PAD = 2 # espace horizontal de chaque côté dans les cellules LINE_H = 5 # hauteur d'une ligne dans le tableau def wrap_text(text, col_w): """Découpe text en lignes pour tenir dans col_w (avec CELL_PAD de chaque côté).""" available = col_w - 2 * CELL_PAD words = text.split() lines, current = [], "" for word in words: test = (current + " " + word).strip() if pdf.get_string_width(test) <= available: current = test else: if current: lines.append(current) current = word if current: lines.append(current) return lines if lines else [""] def draw_multiline_cell(x, y, w, lines, row_h): """Dessine une cellule sur toute la hauteur row_h, avec le texte en haut.""" n_slots = round(row_h / LINE_H) for i in range(n_slots): b = "LR" if i == 0: b += "T" if i == n_slots - 1: b += "B" text = lines[i] if i < len(lines) else "" pdf.set_xy(x, y + i * LINE_H) pdf.cell(w, LINE_H, text, border=b) # En-tête pdf.set_y(45) pdf.set_font("Helvetica", "B", 18) pdf.cell(0, 12, "Bon de commande", new_x="LMARGIN", new_y="NEXT", align="C") pdf.set_font("Helvetica", "B", 14) pdf.cell(0, 8, str(purchase_order.code), new_x="LMARGIN", new_y="NEXT", align="C") pdf.ln(4) # Informations générales (2 colonnes de 95mm = 190mm) col_w = PAGE_W / 2 # 95mm def row(label, value): pdf.set_font("Helvetica", "B", 10) pdf.cell(col_w, 7, label + " :") pdf.set_font("Helvetica", "", 10) pdf.cell(col_w, 7, str(value), new_x="LMARGIN", new_y="NEXT") row("Date", purchase_order.date_ordered.strftime("%d/%m/%Y")) row("Fournisseur", purchase_order.supplier.name) if purchase_order.supplier.contact_email: row("Email fournisseur", purchase_order.supplier.contact_email) if purchase_order.supplier.phone_number: row("Téléphone fournisseur", purchase_order.supplier.phone_number) row("Entrepôt de destination", purchase_order.warehouse.name) row("Statut", purchase_order.get_status_display()) if purchase_order.request_for_quote: row("Demande d'offre d'origine", purchase_order.request_for_quote.code) pdf.ln(6) # Tableau des articles # Code(20) + Produit(45) + Emplacement(25) + Qté(15) + Unité(12) + Art(13) + P.Unit(20) + TVA(15) + Total Net(25) = 190 col_widths = [20, 45, 25, 15, 12, 13, 20, 15, 25] col_labels = ["Code", "Produit", "Emplacement", "Quantité", "Unité", "Articles", "P.Unit HT", "TVA", "Total HTVA"] pdf.set_font("Helvetica", "B", 9) pdf.set_fill_color(230, 230, 230) for hdr, w in zip(col_labels, col_widths): pdf.cell(w, 8, hdr, border=1, fill=True, align="C") pdf.ln() pdf.set_font("Helvetica", "", 8) left_margin = pdf.l_margin for item in purchase_order.items.all(): loc_str = f"{item.location.warehouse.name} - {item.location.name}" qty_str = f"{item.quantity}" unit_str = item.product.unit or "" p_unit_str = f"{item.unit_price:.2f} EUR" if item.unit_price is not None else "-" vat_str = f"{item.vat_rate}%" net_str = f"{item.net_amount:.2f} EUR" if item.unit_price is not None else "-" product_lines = wrap_text(item.product.name, col_widths[1]) loc_lines = wrap_text(loc_str, col_widths[2]) row_h = max(len(product_lines), len(loc_lines)) * LINE_H y0 = pdf.get_y() # Positions X de chaque colonne col_x = [] x = left_margin for w in col_widths: col_x.append(x) x += w # Dessiner toutes les colonnes avec alignement haut draw_multiline_cell(col_x[0], y0, col_widths[0], [item.product.code or ""], row_h) draw_multiline_cell(col_x[1], y0, col_widths[1], product_lines, row_h) draw_multiline_cell(col_x[2], y0, col_widths[2], loc_lines, row_h) draw_multiline_cell(col_x[3], y0, col_widths[3], [qty_str], row_h) draw_multiline_cell(col_x[4], y0, col_widths[4], [unit_str], row_h) draw_multiline_cell(col_x[5], y0, col_widths[5], [str(item.num_articles) if item.num_articles else "-"], row_h) draw_multiline_cell(col_x[6], y0, col_widths[6], [p_unit_str], row_h) draw_multiline_cell(col_x[7], y0, col_widths[7], [vat_str], row_h) draw_multiline_cell(col_x[8], y0, col_widths[8], [net_str], row_h) pdf.set_y(y0 + row_h) # Section Totaux financiers pdf.ln(5) label_w = sum(col_widths[:-1]) # 165mm total_w = col_widths[-1] # 25mm pdf.set_font("Helvetica", "B", 9) pdf.cell(label_w, 7, "Valeur totale net HTVA :", align="R") pdf.set_font("Helvetica", "", 9) pdf.cell(total_w, 7, f"{purchase_order.total_net_amount:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "B", 9) pdf.cell(label_w, 7, "Total TVA :", align="R") pdf.set_font("Helvetica", "", 9) pdf.cell(total_w, 7, f"{purchase_order.total_vat_amount:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "B", 9) pdf.cell(label_w, 7, "Valeur Totale (TTC) :", align="R") pdf.set_font("Helvetica", "B", 9) pdf.cell(total_w, 7, f"{purchase_order.total_amount:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") pdf.ln(10) # Pied de page pdf.set_font("Helvetica", "I", 8) pdf.cell(0, 5, f"Référence UUID : {purchase_order.uuid}", align="C") response = HttpResponse(bytes(pdf.output()), content_type="application/pdf") response["Content-Disposition"] = f'inline; filename="{purchase_order.code}.pdf"' return response def purchase_order_xlsx(request, pk): """ Génère un fichier Excel XLSX pour un bon de commande. """ import io from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from django.http import HttpResponse 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"), pk=pk, ) wb = Workbook() ws = wb.active ws.title = "Bon de commande" # Styles title_font = Font(bold=True, size=14) code_font = Font(bold=True, size=12) bold_font = Font(bold=True) header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") center_align = Alignment(horizontal="center", wrap_text=True) right_align = Alignment(horizontal="right") wrap_align = Alignment(wrap_text=True) thin = Side(style="thin") thin_border = Border(left=thin, right=thin, top=thin, bottom=thin) # Titre ws.merge_cells("A1:J1") ws["A1"] = "Bon de commande" ws["A1"].font = title_font ws["A1"].alignment = center_align ws.merge_cells("A2:J2") ws["A2"] = str(purchase_order.code) ws["A2"].font = code_font ws["A2"].alignment = center_align # Bloc d'informations générales info_rows = [ ("Date", purchase_order.date_ordered.strftime("%d/%m/%Y")), ("Fournisseur", purchase_order.supplier.name), ] if purchase_order.supplier.contact_email: info_rows.append(("Email fournisseur", purchase_order.supplier.contact_email)) if purchase_order.supplier.phone_number: info_rows.append(("Téléphone fournisseur", purchase_order.supplier.phone_number)) info_rows.extend([ ("Entrepôt de destination", purchase_order.warehouse.name), ("Statut", purchase_order.get_status_display()), ]) if purchase_order.request_for_quote: info_rows.append(("Demande d'offre d'origine", purchase_order.request_for_quote.code)) row_num = 4 for label, value in info_rows: lbl_cell = ws.cell(row=row_num, column=1, value=label + " :") lbl_cell.font = bold_font val_cell = ws.cell(row=row_num, column=2, value=str(value)) val_cell.alignment = wrap_align ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=10) row_num += 1 row_num += 1 # ligne vide # En-têtes du tableau table_headers = ["Code", "Produit", "Unité", "Emplacement", "Articles", "Qté/Article", "Total", "P.Unit HT", "TVA (%)", "Total HTVA"] for col, hdr in enumerate(table_headers, start=1): cell = ws.cell(row=row_num, column=col, value=hdr) cell.font = bold_font cell.fill = header_fill cell.border = thin_border cell.alignment = center_align row_num += 1 # Lignes d'articles for item in purchase_order.items.all(): loc_str = f"{item.location.warehouse.name} - {item.location.name}" values = [ item.product.code or "", item.product.name, item.product.unit or "", loc_str, item.num_articles if item.num_articles else None, item.quantity_per_article if item.quantity_per_article else None, item.quantity, item.unit_price if item.unit_price is not None else None, item.vat_rate if item.unit_price is not None else None, item.net_amount if item.unit_price is not None else None, ] for col, val in enumerate(values, start=1): cell = ws.cell(row=row_num, column=col, value=val) cell.border = thin_border cell.alignment = wrap_align if col in (8, 10) and val is not None: cell.number_format = '#,##0.00' elif col == 9 and val is not None: cell.number_format = '0.00' row_num += 1 # Ligne Total HTVA ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=9) lbl_cell = ws.cell(row=row_num, column=1, value="Valeur totale net HTVA") lbl_cell.font = bold_font lbl_cell.border = thin_border lbl_cell.alignment = right_align val_cell = ws.cell(row=row_num, column=10, value=purchase_order.total_net_amount) val_cell.font = bold_font val_cell.border = thin_border val_cell.alignment = right_align val_cell.number_format = '#,##0.00' row_num += 1 # Ligne TVA ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=9) lbl_cell = ws.cell(row=row_num, column=1, value="Total TVA") lbl_cell.font = bold_font lbl_cell.border = thin_border lbl_cell.alignment = right_align val_cell = ws.cell(row=row_num, column=10, value=purchase_order.total_vat_amount) val_cell.font = bold_font val_cell.border = thin_border val_cell.alignment = right_align val_cell.number_format = '#,##0.00' row_num += 1 # Ligne Total TTC ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=9) lbl_cell = ws.cell(row=row_num, column=1, value="Valeur Totale (TTC)") lbl_cell.font = bold_font lbl_cell.border = thin_border lbl_cell.alignment = right_align val_cell = ws.cell(row=row_num, column=10, value=purchase_order.total_amount) val_cell.font = bold_font val_cell.border = thin_border val_cell.alignment = right_align val_cell.number_format = '#,##0.00' row_num += 2 # UUID ws.cell(row=row_num, column=1, value=f"Référence UUID : {purchase_order.uuid}") ws.merge_cells(start_row=row_num, start_column=1, end_row=row_num, end_column=10) # Largeurs des colonnes ws.column_dimensions["A"].width = 15 ws.column_dimensions["B"].width = 35 ws.column_dimensions["C"].width = 10 ws.column_dimensions["D"].width = 25 ws.column_dimensions["E"].width = 10 ws.column_dimensions["F"].width = 12 ws.column_dimensions["G"].width = 10 ws.column_dimensions["H"].width = 15 ws.column_dimensions["I"].width = 10 ws.column_dimensions["J"].width = 15 response_file = io.BytesIO() wb.save(response_file) response_file.seek(0) response = HttpResponse( response_file.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) response["Content-Disposition"] = f'attachment; filename="{purchase_order.code}.xlsx"' return response @require_POST def receive_purchase_order(request, pk): purchase_order = get_object_or_404(PurchaseOrder, pk=pk) # Vérifier les droits from common.models import UserConfig 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_for(purchase_order.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) purchase_order.receive() messages.success(request, _("La commande a été marquée comme reçue.")) return redirect("stock:stock_purchases") @require_POST def receive_purchase_order_items(request, pk): """Réceptionne partiellement des articles d'un bon de commande.""" purchase_order = get_object_or_404(PurchaseOrder, pk=pk) from common.models import UserConfig 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_for(purchase_order.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) if purchase_order.status in ('received', 'cancelled'): messages.error(request, _("Ce bon de commande ne peut plus être réceptionné.")) return redirect('stock:purchase_order_detail', pk=pk) # Construire le dict {item_id: quantity} item_quantities = {} for key, value in request.POST.items(): if key.startswith('qty_'): try: item_id = int(key[4:]) qty = int(value) if qty > 0: item_quantities[item_id] = qty except (ValueError, TypeError): pass if item_quantities: purchase_order.receive_items(item_quantities) messages.success(request, _("Les articles ont été réceptionnés avec succès.")) else: messages.info(request, _("Aucune quantité à réceptionner.")) return redirect('stock:purchase_order_detail', pk=pk) @require_POST def purchase_order_cancel(request, pk): """ Annule un bon de commande """ purchase_order = get_object_or_404(PurchaseOrder, pk=pk) from common.models import UserConfig 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_for(purchase_order.warehouse)): messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) except UserConfig.DoesNotExist: messages.error(request, _("Vous n'avez pas les droits pour gérer les bons de commande.")) return redirect('stock:purchase_order_detail', pk=pk) if purchase_order.status in ('pending', 'ordered'): purchase_order.status = 'cancelled' purchase_order.save(update_fields=['status']) messages.success(request, _("Le bon de commande a été annulé.")) else: messages.error(request, _("Ce bon de commande ne peut pas être annulé.")) return redirect("stock:stock_purchases") def purchase_order_detail(request, pk): """ Afficher les détails d'un bon de commande """ 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"), pk=pk ) # Générer le QR code qr_code = purchase_order.generate_qr_code() # Calculer le total des quantités total_quantity = sum(item.quantity for item in purchase_order.items.all()) # Calculer les données de stock par article from django.db.models import Sum as _Sum items_with_stock = [] for item in purchase_order.items.all(): product = item.product reserved = product.reserved_quantity in_purchase = ( PurchaseOrderItem.objects.filter( product=product, purchase_order__status="ordered", ).aggregate(total=_Sum("quantity"))["total"] or 0 ) available = product.total_stock items_with_stock.append({ "item": item, "reserved": reserved, "in_purchase": in_purchase, "available": available, "is_sufficient": available >= item.quantity, }) # Vérifier les droits from common.models import UserConfig try: user_config = request.user.config can_manage_purchase = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(purchase_order.warehouse) ) except UserConfig.DoesNotExist: can_manage_purchase = False from common.private_files.helpers import build_private_url from django.urls import reverse as _reverse documents = [] for doc in purchase_order.documents.all().order_by('uploaded_at'): documents.append({ 'id': doc.pk, 'filename': doc.filename(), 'is_image': doc.is_image(), 'uploaded_at': doc.uploaded_at.strftime('%d/%m/%Y %H:%M'), 'url': build_private_url('stock', 'PurchaseOrderDocument', doc.pk, 'file'), 'delete_url': _reverse('stock:purchase_order_document_delete', args=[doc.pk]), }) return render(request, "stock/purchase_order_detail.html", { "purchase_order": purchase_order, "qr_code": qr_code, "total_quantity": total_quantity, "items_with_stock": items_with_stock, "can_manage_purchase": can_manage_purchase, "documents": documents, "upload_url": _reverse('stock:purchase_order_document_upload', args=[purchase_order.pk]), }) def inventory_list(request): from django.db.models import Q config = get_user_config(request.user) if not config: inventories = Inventory.objects.none() elif request.user.is_superuser or request.user.is_staff or config.has_role('admin'): inventories = Inventory.objects.all() else: if config.userwarehouses.exists(): accessible_warehouses = get_user_accessible_warehouses(request.user) inventories = Inventory.objects.filter( warehouse_location__warehouse__id__in=accessible_warehouses ) elif config.userlocations.exists(): inventories = Inventory.objects.filter( Q(warehouse_location__user_accesses__user_config=config, warehouse_location__user_accesses__can_perform_inventory=True) | Q(warehouse_location__user_accesses__user_config=config, warehouse_location__user_accesses__can_validate_inventory=True) ) else: accessible_warehouses = get_user_accessible_warehouses(request.user) inventories = Inventory.objects.filter( warehouse_location__warehouse__id__in=accessible_warehouses ) inventories = inventories.select_related("warehouse_location__warehouse").prefetch_related("items").distinct() return render(request, "stock/inventory_list.html", {"inventories": inventories}) def inventory_detail(request, pk): inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) warehouse = inventory.warehouse_location.warehouse location = inventory.warehouse_location from stock.permissions import user_can_view_warehouse if not (user_can_view_warehouse(request.user, warehouse) or user_can_perform_inventory_on(request.user, location) or user_can_validate_inventory_on(request.user, location)): messages.error(request, _("Vous n'avez pas la permission de visualiser cet inventaire.")) return redirect('stock:inventory_list') items = inventory.items.select_related("product", "validated_by", "comment_by", "validator_comment_by").order_by("product__name") can_validate = user_can_validate_inventory_on(request.user, location) can_change_status = user_can_perform_inventory_on(request.user, location) current_product_ids = set(items.values_list('product_id', flat=True)) products_json = json.dumps([ {"id": p.id, "code": p.code, "name": p.name} for p in Product.objects.filter(models.Q(is_active=True) | models.Q(id__in=current_product_ids)).only("id", "code", "name").order_by("name") ]) return render(request, "stock/inventory_detail.html", { "inventory": inventory, "items": items, "can_validate": can_validate, "can_change_status": can_change_status, "products_json": products_json, }) def inventory_create(request): warehouse_id = request.GET.get("warehouse_id") or request.POST.get("warehouse_id") warehouse = None if warehouse_id: warehouse = Warehouse.objects.filter(pk=warehouse_id).first() user_config = getattr(request.user, "config", None) from stock.permissions import user_can_manage_warehouse, user_can_validate_inventory if warehouse: can_validate = user_can_validate_inventory(request.user, warehouse) else: if request.user.is_superuser or (user_config and user_config.has_role('admin')): can_validate = True elif user_config: can_validate = ( user_config.userwarehouses.filter(can_validate_inventory=True).exists() or user_config.userlocations.filter(can_validate_inventory=True).exists() ) else: can_validate = False # Create the formset dynamically with correct can_delete permission DynamicInventoryItemFormSet = inlineformset_factory( Inventory, InventoryItem, form=InventoryItemForm, extra=1, can_delete=can_validate, ) if request.method == "POST": form = InventoryForm(request.POST, warehouse=warehouse) form_is_valid = form.is_valid() formset = DynamicInventoryItemFormSet(request.POST, instance=form.instance) if form_is_valid and formset.is_valid(): # Get selected warehouse_location warehouse to validate permissions loc = form.cleaned_data.get("warehouse_location") if loc: target_warehouse = loc.warehouse # The user must have permission to manage the warehouse OR validate inventory OR perform/validate on location if not (user_can_manage_warehouse(request.user, target_warehouse) or user_can_validate_inventory(request.user, target_warehouse) or user_can_perform_inventory_on(request.user, loc) or user_can_validate_inventory_on(request.user, loc)): messages.error(request, _("Vous n'avez pas la permission de créer un inventaire pour cet emplacement.")) return redirect('stock:stock_movements') # Check validation permission if status is validated status = form.cleaned_data.get("status") if status == Inventory.STATUS_VALIDATED and not user_can_validate_inventory_on(request.user, loc): messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement.")) return redirect('stock:stock_movements') inventory = form.save() formset.save() if inventory.status == Inventory.STATUS_VALIDATED: inventory.validate(user=request.user) messages.success( request, _("Inventaire validé et stock ajusté."), ) else: messages.success(request, _("Inventaire enregistré.")) return redirect("stock:inventory_detail", pk=inventory.pk) else: form = InventoryForm(warehouse=warehouse) formset = DynamicInventoryItemFormSet() products_json = json.dumps([ {"id": p.id, "code": p.code, "name": p.name} for p in Product.objects.filter(is_active=True).only("id", "code", "name").order_by("name") ]) return render( request, "stock/inventory_form.html", { "form": form, "formset": formset, "products_json": products_json, "can_validate": can_validate, }, ) def inventory_prefill_api(request): """Retourne en JSON les produits avec stock > 0 pour un emplacement donné.""" from django.http import JsonResponse location_id = request.GET.get("location_id") if not location_id: return JsonResponse({"error": "location_id requis"}, status=400) stocks = ( Stock.objects.filter(location_id=location_id, quantity__gt=0) .select_related("product") .order_by("product__name") ) data = [ { "product_id": s.product.id, "product_name": s.product.name, "product_code": s.product.code, "quantity": s.quantity, } for s in stocks ] return JsonResponse({"stocks": data}) def inventory_edit(request, pk): """Permet d'éditer un inventaire tant qu'il n'est pas validé.""" inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not inventory.is_editable: messages.error(request, _("Cet inventaire est validé et ne peut plus être modifié.")) return redirect("stock:inventory_detail", pk=pk) warehouse = inventory.warehouse_location.warehouse location = inventory.warehouse_location from stock.permissions import user_can_manage_warehouse, user_can_validate_inventory if not (user_can_manage_warehouse(request.user, warehouse) or user_can_validate_inventory(request.user, warehouse) or user_can_perform_inventory_on(request.user, location) or user_can_validate_inventory_on(request.user, location)): messages.error(request, _("Vous n'avez pas la permission de modifier cet inventaire.")) return redirect("stock:inventory_detail", pk=pk) can_validate = user_can_validate_inventory_on(request.user, location) InventoryItemEditFormSet = inlineformset_factory( Inventory, InventoryItem, form=InventoryItemEditForm, extra=0, can_delete=can_validate, ) if request.method == "POST": form = InventoryForm(request.POST, instance=inventory) formset = InventoryItemEditFormSet(request.POST, instance=inventory) if form.is_valid() and formset.is_valid(): # Validate permissions on the new selected warehouse location (in case it changed) new_loc = form.cleaned_data.get("warehouse_location") if new_loc: new_warehouse = new_loc.warehouse if not (user_can_manage_warehouse(request.user, new_warehouse) or user_can_validate_inventory(request.user, new_warehouse) or user_can_perform_inventory_on(request.user, new_loc) or user_can_validate_inventory_on(request.user, new_loc)): messages.error(request, _("Vous n'avez pas la permission de gérer l'emplacement de destination.")) return redirect("stock:inventory_detail", pk=pk) # Check validation permission if status is validated status = form.cleaned_data.get("status") if status == Inventory.STATUS_VALIDATED and not user_can_validate_inventory_on(request.user, new_loc): messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement.")) return redirect("stock:inventory_detail", pk=pk) inventory = form.save() formset.save() messages.success(request, _("Inventaire mis à jour.")) return redirect("stock:inventory_detail", pk=inventory.pk) else: form = InventoryForm(instance=inventory) formset = InventoryItemEditFormSet(instance=inventory) current_product_ids = set(inventory.items.values_list('product_id', flat=True)) products_json = json.dumps([ {"id": p.id, "code": p.code, "name": p.name} for p in Product.objects.filter(models.Q(is_active=True) | models.Q(id__in=current_product_ids)).only("id", "code", "name").order_by("name") ]) return render( request, "stock/inventory_form.html", { "form": form, "formset": formset, "products_json": products_json, "inventory": inventory, "edit_mode": True, "can_validate": can_validate, }, ) @require_POST def inventory_change_status(request, pk): """Change le statut d'un inventaire (draft / in_progress / done) sans valider définitivement.""" inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not user_can_perform_inventory_on(request.user, inventory.warehouse_location): messages.error(request, _("Vous n'avez pas la permission de modifier cet inventaire.")) next_url = request.POST.get('next') or '' return redirect(next_url or "stock:inventory_detail", pk=pk) if not next_url else redirect(next_url) if inventory.is_validated: messages.warning(request, _("Cet inventaire est validé et ne peut plus être modifié.")) next_url = request.POST.get('next') or '' return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk) allowed = {Inventory.STATUS_DRAFT, Inventory.STATUS_IN_PROGRESS, Inventory.STATUS_DONE} new_status = request.POST.get('status') if new_status not in allowed: messages.error(request, _("Statut invalide.")) next_url = request.POST.get('next') or '' return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk) if inventory.status == Inventory.STATUS_DONE and new_status in (Inventory.STATUS_DRAFT, Inventory.STATUS_IN_PROGRESS): if not user_can_validate_inventory_on(request.user, inventory.warehouse_location): messages.error(request, _("Seul un validateur peut repasser l'inventaire au statut en cours ou en préparation.")) next_url = request.POST.get('next') or '' return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk) inventory.status = new_status inventory.save(update_fields=['status', 'updated_at']) messages.success(request, _("Statut mis à jour.")) next_url = request.POST.get('next') or '' return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk) @require_POST def inventory_validate(request, pk): """Valide un inventaire entier (tous les articles) – réservé aux utilisateurs avec le droit de validation.""" from django.utils import timezone as tz inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not user_can_validate_inventory_on(request.user, inventory.warehouse_location): messages.error(request, _("Vous n'avez pas la permission de valider cet inventaire.")) return redirect("stock:inventory_detail", pk=pk) if inventory.is_validated: messages.warning(request, _("Cet inventaire est déjà validé.")) return redirect("stock:inventory_detail", pk=pk) # Marquer tous les items comme validés inventory.items.filter(is_validated=False).update( is_validated=True, validated_at=tz.now(), validated_by=request.user, ) validator_comment = request.POST.get("validator_comment", "").strip() if validator_comment: inventory.validator_comment = validator_comment inventory.validate(user=request.user) messages.success(request, _("Inventaire validé et stock ajusté.")) return redirect("stock:inventory_detail", pk=pk) @require_POST def inventory_update_validator_comment(request, pk): """Met à jour le commentaire de validation général de l'inventaire.""" inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not user_can_validate_inventory_on(request.user, inventory.warehouse_location): messages.error(request, _("Vous n'avez pas la permission de modifier le commentaire de cet inventaire.")) return redirect("stock:inventory_detail", pk=pk) comment = request.POST.get("validator_comment", "").strip() inventory.validator_comment = comment inventory.save(update_fields=["validator_comment", "updated_at"]) messages.success(request, _("Commentaire du validateur mis à jour.")) return redirect("stock:inventory_detail", pk=pk) @require_POST def inventory_validate_items(request, pk): """Valide un lot d'articles (IDs envoyés en POST) sans finaliser l'inventaire.""" from django.utils import timezone as tz inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not user_can_validate_inventory_on(request.user, inventory.warehouse_location): messages.error(request, _("Vous n'avez pas la permission de valider ces articles.")) return redirect("stock:inventory_detail", pk=pk) if inventory.is_validated: messages.warning(request, _("Cet inventaire est déjà validé.")) return redirect("stock:inventory_detail", pk=pk) item_ids = request.POST.getlist("item_ids") if item_ids: updated = inventory.items.filter(pk__in=item_ids, is_validated=False).update( is_validated=True, validated_at=tz.now(), validated_by=request.user, ) messages.success(request, _("%(count)d article(s) validé(s).") % {"count": updated}) else: messages.info(request, _("Aucun article à valider.")) return redirect("stock:inventory_detail", pk=pk) @require_POST def inventory_validate_item(request, pk, item_id): """Valide ou annule la validation d'un article (toggle). Supporte les requêtes AJAX (X-Requested-With: XMLHttpRequest) : retourne un JsonResponse au lieu d'une redirection. """ from django.utils import timezone as tz is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) item = get_object_or_404(InventoryItem, pk=item_id, inventory=inventory) if not user_can_validate_inventory_on(request.user, inventory.warehouse_location): if is_ajax: return JsonResponse({"success": False, "error": str(_("Vous n'avez pas la permission de valider cet article."))}, status=403) messages.error(request, _("Vous n'avez pas la permission de valider cet article.")) return redirect("stock:inventory_detail", pk=pk) if inventory.is_validated: if is_ajax: return JsonResponse({"success": False, "error": str(_("Cet inventaire est déjà entièrement validé."))}, status=400) messages.warning(request, _("Cet inventaire est déjà entièrement validé.")) return redirect("stock:inventory_detail", pk=pk) if not item.is_validated: item.is_validated = True item.validated_at = tz.now() item.validated_by = request.user validator_comment = request.POST.get("validator_comment") if validator_comment is not None: item.validator_comment = validator_comment.strip() item.save(update_fields=["is_validated", "validated_at", "validated_by", "validator_comment"]) if is_ajax: return JsonResponse({ "success": True, "is_validated": True, "validated_by": request.user.get_full_name() or request.user.username, "validated_at": item.validated_at.strftime("%d/%m/%Y %H:%M"), "validator_comment": item.validator_comment, }) messages.success(request, _("Article « {product} » validé.").format(product=item.product.name)) else: item.is_validated = False item.validated_at = None item.validated_by = None item.save(update_fields=["is_validated", "validated_at", "validated_by"]) if is_ajax: return JsonResponse({"success": True, "is_validated": False, "validated_by": "", "validated_at": ""}) messages.info(request, _("Validation de « {product} » annulée.").format(product=item.product.name)) return redirect("stock:inventory_detail", pk=pk) @require_POST def inventory_item_update_api(request, pk, item_id): """Met à jour la quantité comptée et/ou le commentaire d'un article via AJAX.""" inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) location = inventory.warehouse_location can_validate = user_can_validate_inventory_on(request.user, location) can_perform = user_can_perform_inventory_on(request.user, location) if not (can_validate or can_perform): return JsonResponse( {"success": False, "error": str(_("Vous n'avez pas la permission de modifier cet inventaire."))}, status=403, ) item = get_object_or_404( InventoryItem.objects.select_related("comment_by", "validator_comment_by"), pk=item_id, inventory=inventory, ) update_fields = [] def _build_response(): return JsonResponse({ "success": True, "counted_quantity": item.counted_quantity, "expected_quantity": item.expected_quantity, "difference": item.difference, "comment": item.comment, "comment_by_short_name": get_short_name(item.comment_by), "validator_comment": item.validator_comment, "validator_comment_by_short_name": get_short_name(item.validator_comment_by), }) # --- Inventaire validé : seul le validateur peut encore toucher son commentaire --- if inventory.is_validated: if can_validate: validator_comment = request.POST.get("validator_comment") if validator_comment is not None: item.validator_comment = validator_comment.strip() item.validator_comment_by = request.user item.save(update_fields=["validator_comment", "validator_comment_by"]) return _build_response() return JsonResponse( {"success": False, "error": str(_("Cet inventaire est validé et ne peut plus être modifié."))}, status=400, ) # --- Inventaire terminé (done) : magasiniers en commentaire seul ; validateurs peuvent aussi modifier les quantités --- if not inventory.is_quantity_editable: if can_validate: counted_quantity_raw = request.POST.get("counted_quantity") if counted_quantity_raw is not None: try: counted_quantity = int(counted_quantity_raw) if counted_quantity < 0: return JsonResponse({"success": False, "error": str(_("La quantité ne peut pas être négative."))}, status=400) item.counted_quantity = counted_quantity update_fields.append("counted_quantity") except (ValueError, TypeError): return JsonResponse({"success": False, "error": str(_("Quantité invalide."))}, status=400) validator_comment = request.POST.get("validator_comment") if validator_comment is not None: item.validator_comment = validator_comment.strip() item.validator_comment_by = request.user update_fields.extend(["validator_comment", "validator_comment_by"]) else: comment = request.POST.get("comment") if comment is not None: item.comment = comment item.comment_by = request.user update_fields.extend(["comment", "comment_by"]) if update_fields: item.save(update_fields=update_fields) return _build_response() # --- Inventaire en cours (draft / in_progress) : édition complète --- try: counted_quantity = int(request.POST.get("counted_quantity", item.counted_quantity)) if counted_quantity < 0: return JsonResponse({"success": False, "error": str(_("La quantité ne peut pas être négative."))}, status=400) except (ValueError, TypeError): return JsonResponse({"success": False, "error": str(_("Quantité invalide."))}, status=400) item.counted_quantity = counted_quantity update_fields.append("counted_quantity") comment = request.POST.get("comment", item.comment) if comment != item.comment or not item.comment_by: item.comment = comment item.comment_by = request.user if comment else None update_fields.extend(["comment", "comment_by"]) if can_validate: validator_comment = request.POST.get("validator_comment") if validator_comment is not None: item.validator_comment = validator_comment.strip() item.validator_comment_by = request.user if validator_comment.strip() else None update_fields.extend(["validator_comment", "validator_comment_by"]) item.save(update_fields=update_fields) return _build_response() @require_POST def inventory_add_item_api(request, pk): """Ajoute un produit à l'inventaire en cours via AJAX.""" from django.urls import reverse from django.http import JsonResponse inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) location = inventory.warehouse_location if not (user_can_perform_inventory_on(request.user, location) or user_can_validate_inventory_on(request.user, location)): return JsonResponse( {"success": False, "error": str(_("Vous n'avez pas la permission d'ajouter des produits à cet inventaire."))}, status=403, ) if not inventory.is_editable: return JsonResponse( {"success": False, "error": str(_("Cet inventaire est validé et ne peut plus être modifié."))}, status=400, ) product_id = request.POST.get("product_id") if not product_id: return JsonResponse({"success": False, "error": str(_("Produit requis."))}, status=400) try: counted_quantity = int(request.POST.get("counted_quantity", 0)) if counted_quantity < 0: return JsonResponse({"success": False, "error": str(_("La quantité ne peut pas être négative."))}, status=400) except (ValueError, TypeError): return JsonResponse({"success": False, "error": str(_("Quantité invalide."))}, status=400) comment = request.POST.get("comment", "").strip() # Check unique constraint if InventoryItem.objects.filter(inventory=inventory, product_id=product_id).exists(): return JsonResponse({"success": False, "error": str(_("Ce produit est déjà présent dans cet inventaire."))}, status=400) product = get_object_or_404(Product, pk=product_id) # Check authorized location if configured location = inventory.warehouse_location if product.authorized_locations.exists() and location not in product.authorized_locations.all(): return JsonResponse({ "success": False, "error": str(_("Le produit « {product} » n'est pas autorisé dans l'emplacement de cet inventaire ({location}).").format( product=product.name, location=location.name )) }, status=400) item = InventoryItem( inventory=inventory, product=product, counted_quantity=counted_quantity, comment=comment, ) # This automatically computes expected_quantity in clean/save try: item.save() except Exception as e: return JsonResponse({"success": False, "error": str(e)}, status=400) # Return details can_validate = user_can_validate_inventory_on(request.user, location) return JsonResponse({ "success": True, "item_id": item.pk, "product_id": product.pk, "product_name": product.name, "expected_quantity": item.expected_quantity, "counted_quantity": item.counted_quantity, "difference": item.difference, "comment": item.comment, "is_validated": item.is_validated, "update_url": reverse("stock:inventory_item_update_api", args=[inventory.pk, item.pk]), "delete_url": reverse("stock:inventory_item_delete_api", args=[inventory.pk, item.pk]), "validate_url": reverse("stock:inventory_validate_item", args=[inventory.pk, item.pk]), "can_validate": can_validate, }) @require_POST def inventory_item_delete_api(request, pk, item_id): """Supprime un article d'inventaire via AJAX (pour les validateurs/admins).""" from django.http import JsonResponse inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) if not inventory.is_editable: return JsonResponse( {"success": False, "error": str(_("Cet inventaire est validé et ne peut plus être modifié."))}, status=400, ) location = inventory.warehouse_location can_validate = user_can_validate_inventory_on(request.user, location) if not can_validate: return JsonResponse( {"success": False, "error": str(_("Vous n'avez pas la permission de supprimer des articles."))}, status=403, ) item = get_object_or_404(InventoryItem, pk=item_id, inventory=inventory) deleted_id = item.pk item.delete() return JsonResponse({"success": True, "deleted": deleted_id}) def inventory_pdf(request, pk): """Génère une feuille d'inventaire imprimable en PDF. Propose deux modes via le paramètre GET 'type' : - type=blank : feuille de comptage vierge (colonnes « Qté comptée » et « Commentaire » vides, signature) - type=complete : tableau complet (toutes les données de comptage, écarts, commentaires et statut) Par défaut, exporte en mode 'complete' si l'inventaire est validé, sinon en mode 'blank'. """ import io import os from fpdf import FPDF from django.conf import settings from django.http import HttpResponse inventory = get_object_or_404( Inventory.objects.select_related("warehouse_location__warehouse"), pk=pk, ) from stock.permissions import user_can_view_warehouse location = inventory.warehouse_location warehouse = location.warehouse if not (user_can_view_warehouse(request.user, warehouse) or user_can_perform_inventory_on(request.user, location) or user_can_validate_inventory_on(request.user, location)): messages.error(request, _("Vous n'avez pas la permission d'exporter cet inventaire en PDF.")) return redirect('stock:inventory_list') items = list(inventory.items.select_related("product").order_by("product__name")) is_validated = inventory.is_validated export_type = request.GET.get('type') if export_type == 'complete': show_details = True elif export_type == 'blank': show_details = False else: show_details = is_validated font_dir = os.path.join(settings.BASE_DIR, "common", "static", "common") logo_path = os.path.join(settings.BASE_DIR, "common", "static", settings.ORGANIZATION_LOGO_PNG) class InventorySheetPDF(FPDF): def header(self): self.add_font("arial", style="", fname=os.path.join(font_dir, "arial.ttf")) self.add_font("arial", style="B", fname=os.path.join(font_dir, "arial_bold.ttf")) self.add_font("arial", style="I", fname=os.path.join(font_dir, "arial_italic.ttf")) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=45) self.set_font("arial", "B", 14) self.set_y(10) if show_details: title = f"{_('Inventaire')} — {inventory.get_status_display()}" else: title = str(_("Feuille d'inventaire")) self.cell(0, 8, title, align="C", new_x="LMARGIN", new_y="NEXT") self.set_font("arial", "", 10) self.cell( 0, 6, f"{inventory.warehouse_location.warehouse.name} — {inventory.warehouse_location.name}", align="C", new_x="LMARGIN", new_y="NEXT", ) if inventory.performed_by: self.cell( 0, 5, str(_("Responsable : ")) + inventory.performed_by, align="C", new_x="LMARGIN", new_y="NEXT", ) self.cell( 0, 5, str(_("Créé le : ")) + inventory.created_at.strftime("%d/%m/%Y %H:%M"), align="C", new_x="LMARGIN", new_y="NEXT", ) if is_validated and inventory.validated_at: validated_by = "" if inventory.validated_by: validated_by = " — " + (inventory.validated_by.get_full_name() or inventory.validated_by.username) self.cell( 0, 5, str(_("Validé le : ")) + inventory.validated_at.strftime("%d/%m/%Y %H:%M") + validated_by, align="C", new_x="LMARGIN", new_y="NEXT", ) self.ln(4) def footer(self): self.set_y(-15) self.set_font("arial", "I", 8) self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C") pdf = InventorySheetPDF(orientation="P", unit="mm", format="A4") pdf.alias_nb_pages() pdf.set_auto_page_break(auto=True, margin=20) pdf.add_page() # --- Fonts (also needed outside the header for the table) --- pdf.add_font("arial", style="", fname=os.path.join(font_dir, "arial.ttf")) pdf.add_font("arial", style="B", fname=os.path.join(font_dir, "arial_bold.ttf")) pdf.add_font("arial", style="I", fname=os.path.join(font_dir, "arial_italic.ttf")) # --- Column widths (total ≈ 190 mm for A4 portrait with 10 mm margins each side) --- if show_details: col_w = {"product": 64, "code": 27, "expected": 18, "counted": 18, "diff": 18, "comment": 45} else: col_w = {"product": 70, "code": 30, "expected": 30, "counted": 30, "comment": 30} # --- Table header --- pdf.set_font("arial", "B", 9) pdf.set_fill_color(0, 91, 153) pdf.set_text_color(255, 255, 255) pdf.cell(col_w["product"], 8, str(_("Produit")), border=1, fill=True, align="C") pdf.cell(col_w["code"], 8, str(_("Code")), border=1, fill=True, align="C") pdf.cell(col_w["expected"], 8, str(_("Qté attendue")), border=1, fill=True, align="C") pdf.cell(col_w["counted"], 8, str(_("Qté comptée")), border=1, fill=True, align="C") if show_details: pdf.cell(col_w["diff"], 8, str(_("Écart")), border=1, fill=True, align="C") pdf.cell(col_w["comment"], 8, str(_("Commentaire")), border=1, fill=True, align="C") pdf.ln() # --- Table rows --- pdf.set_text_color(0, 0, 0) pdf.set_font("arial", "", 9) fill = False row_h = 9 if show_details else 7 for item in items: diff = item.difference # counted - expected if show_details: # Colour rows by discrepancy if diff < 0: pdf.set_fill_color(255, 220, 220) # rouge pâle elif diff > 0: pdf.set_fill_color(220, 245, 220) # vert pâle else: pdf.set_fill_color(245, 245, 245) if fill else pdf.set_fill_color(255, 255, 255) else: pdf.set_fill_color(245, 245, 245) if fill else pdf.set_fill_color(255, 255, 255) pdf.cell(col_w["product"], row_h, item.product.name[:45], border=1, fill=True, align="L") pdf.cell(col_w["code"], row_h, item.product.code or "", border=1, fill=True, align="C") pdf.cell(col_w["expected"], row_h, str(item.expected_quantity or ""), border=1, fill=True, align="C") if show_details: pdf.cell(col_w["counted"], row_h, str(item.counted_quantity), border=1, fill=True, align="C") diff_str = (f"+{diff}" if diff > 0 else str(diff)) if diff != 0 else "0" pdf.set_font("arial", "B", 9) pdf.cell(col_w["diff"], row_h, diff_str, border=1, fill=True, align="C") pdf.set_font("arial", "", 9) # Comments inside a single cell with line breaks and custom color for validator comment x = pdf.get_x() y = pdf.get_y() pdf.cell(col_w["comment"], row_h, "", border=1, fill=True) has_worker = bool(item.comment and item.comment.strip()) has_validator = bool(item.validator_comment and item.validator_comment.strip()) if has_worker and has_validator: # Print worker comment (black) pdf.set_xy(x + 2, y + 1) pdf.set_text_color(0, 0, 0) pdf.set_font("arial", "", 8) pdf.cell(col_w["comment"] - 4, 3.5, item.comment[:35], border=0, align="L") # Print validator comment (dark red) pdf.set_xy(x + 2, y + 4.5) pdf.set_text_color(160, 40, 40) pdf.set_font("arial", "I", 8) pdf.cell(col_w["comment"] - 4, 3.5, item.validator_comment[:35], border=0, align="L") elif has_worker: pdf.set_xy(x + 2, y + 2.5) pdf.set_text_color(0, 0, 0) pdf.set_font("arial", "", 9) pdf.cell(col_w["comment"] - 4, 4, item.comment[:35], border=0, align="L") elif has_validator: pdf.set_xy(x + 2, y + 2.5) pdf.set_text_color(160, 40, 40) pdf.set_font("arial", "I", 9) pdf.cell(col_w["comment"] - 4, 4, item.validator_comment[:35], border=0, align="L") # Restore position, font, and text color for next cell/row pdf.set_xy(x + col_w["comment"], y) pdf.set_text_color(0, 0, 0) pdf.set_font("arial", "", 9) else: pdf.cell(col_w["counted"], row_h, "", border=1, fill=True, align="C") # vide pdf.cell(col_w["comment"], row_h, "", border=1, fill=True, align="C") # vide pdf.ln(row_h) fill = not fill # --- Summary --- if show_details: pdf.ln(4) pdf.set_font("arial", "B", 9) pdf.set_fill_color(0, 91, 153) pdf.set_text_color(255, 255, 255) total_diff = sum(item.difference for item in items) total_str = (f"+{total_diff}" if total_diff > 0 else str(total_diff)) if total_diff != 0 else "0" summary_w = col_w["product"] + col_w["code"] + col_w["expected"] + col_w["counted"] pdf.cell(summary_w, 7, str(_("TOTAL ÉCART")), border=1, fill=True, align="R") pdf.cell(col_w["diff"], 7, total_str, border=1, fill=True, align="C") pdf.cell(col_w["comment"], 7, "", border=1, fill=True, align="C") pdf.ln() pdf.set_text_color(0, 0, 0) else: # --- Signature area (non validé uniquement) --- pdf.ln(10) pdf.set_font("arial", "I", 9) pdf.cell(0, 6, str(_("Signature du magasinier : ___________________________ Date : _____ / _____ / _______")), align="L") buffer = io.BytesIO() pdf.output(buffer) buffer.seek(0) filename = f"inventaire_{inventory.pk}_{inventory.warehouse_location.name.replace(' ', '_')}.pdf" response = HttpResponse(buffer.read(), content_type="application/pdf") response["Content-Disposition"] = f'attachment; filename="{filename}"' return response def stock_alerts(request): # Filtrer les alertes selon les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) alerts = ( StockAlert.objects.filter( is_active=True, warehouse__id__in=accessible_warehouses ) .select_related("product", "warehouse") .order_by("warehouse__name", "product__name") ) # Prêts de matériel en retard from django.utils import timezone today = timezone.now().date() overdue_batches = ( StockMovementBatch.objects.filter( is_loan=True, loan_returned=False, estimated_return_date__lt=today, movement_type=StockMovementBatch.TYPE_OUT, ) .filter( models.Q(movements__source_location__warehouse__id__in=accessible_warehouses) | models.Q(movements__destination_location__warehouse__id__in=accessible_warehouses) ) .distinct() .select_related("person", "company", "contract", "created_by") .prefetch_related("movements__product") .order_by("estimated_return_date") ) return render( request, "stock/stock_alerts.html", { "alerts": alerts, "overdue_batches": overdue_batches, }, ) def new_preparation_order(request): from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de créer des bons de préparation.")) return redirect('stock:stock_movements') preparation_order_item_formset = inlineformset_factory( PreparationOrder, PreparationOrderItem, form=PreparationOrderItemForm, extra=1 ) if request.method == "POST": form = PreparationOrderForm(request.POST) formset = preparation_order_item_formset(request.POST) if form.is_valid() and formset.is_valid(): preparation_order = form.save() formset.instance = preparation_order formset.save() # Enregistrer les lignes "Autres produits" _create_other_items(request, preparation_order) # Enregistrer les documents joints for f in request.FILES.getlist('documents'): PreparationOrderDocument.objects.create( preparation_order=preparation_order, file=f, name=f.name, ) messages.success(request, _("Commande de préparation enregistrée.")) return redirect("stock:stock_movements") else: import datetime today = datetime.date.today() prefix = f"BP-{today.strftime('%Y%m%d')}-" existing = PreparationOrder.objects.filter(order_number__startswith=prefix).values_list('order_number', flat=True) existing_nums = [] for num in existing: suffix = num[len(prefix):] if suffix.isdigit(): existing_nums.append(int(suffix)) next_num = max(existing_nums, default=0) + 1 order_number = f"{prefix}{next_num:03d}" form = PreparationOrderForm(initial={'order_number': order_number, 'status': 'to_process'}) formset = preparation_order_item_formset() categories = list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')) signalisation_ids = [ c['id'] for c in categories if 'signalisation' in (c['code'] or '').lower() or 'signalisation' in (c['name_fr'] or '').lower() ] return render( request, "stock/preparation_order_add.html", { "form": form, "formset": formset, "categories": categories, "signalisation_category_ids": signalisation_ids, }, ) def _create_other_items(request, preparation_order): """ Parse les lignes 'Autres produits' depuis la requête POST et les enregistre. """ total = int(request.POST.get('other-TOTAL_FORMS', 0)) for i in range(total): description = request.POST.get(f'other-{i}-description', '').strip() if not description: continue try: qty = max(1, int(request.POST.get(f'other-{i}-quantity', 1))) except (ValueError, TypeError): qty = 1 category_id = request.POST.get(f'other-{i}-category_id', '').strip() or None sign_type_id = request.POST.get(f'other-{i}-sign_panel_type_id', '').strip() or None category = None if category_id: try: category = ProductCategory.objects.get(pk=category_id) except ProductCategory.DoesNotExist: pass sign_panel_type = None if sign_type_id: from sign.models import SignPanelType try: sign_panel_type = SignPanelType.objects.get(pk=sign_type_id) except SignPanelType.DoesNotExist: import logging logging.getLogger(__name__).warning( "SignPanelType id=%s introuvable dans _create_other_items", sign_type_id ) PreparationOrderOtherItem.objects.create( preparation_order=preparation_order, description=description, quantity=qty, category=category, sign_panel_type=sign_panel_type, ) # def _create_design_orders(request, preparation_order): # """Créait des ProductionOrder liés à des ProductDesign (modèle désactivé).""" # pass def new_preparation_order_from_intervention(request, intervention_id): """ Créer un bon de préparation depuis une intervention. Accessible uniquement aux utilisateurs: - admin - ou ayant le droit d'édition de l'intervention ET le droit de gérer les bons de préparation sur au moins un entrepôt. """ from interventions.models import Intervention from interventions.permissions import can_edit_intervention from common.models import UserConfig # Vérifier les permissions try: user_config = request.user.config except UserConfig.DoesNotExist: messages.error(request, _("Configuration utilisateur non trouvée.")) return redirect("interventions:intervention_detail", intervention_id=intervention_id) intervention = get_object_or_404(Intervention, pk=intervention_id) can_create_from_intervention = bool( user_config.has_role('admin') or ( can_edit_intervention(request.user, intervention, user_config=user_config) and user_config.can_manage_preparation_orders_any() ) ) if not can_create_from_intervention: messages.error(request, _("Vous n'avez pas la permission de créer des bons de préparation.")) return redirect("interventions:intervention_detail", intervention_id=intervention_id) preparation_order_item_formset = inlineformset_factory( PreparationOrder, PreparationOrderItem, form=PreparationOrderItemForm, extra=3, can_delete=True ) if request.method == "POST": form = PreparationOrderForm(request.POST) formset = preparation_order_item_formset(request.POST) if form.is_valid() and formset.is_valid(): preparation_order = form.save(commit=False) preparation_order.intervention = intervention preparation_order.save() formset.instance = preparation_order formset.save() # Enregistrer les lignes "Autres produits" _create_other_items(request, preparation_order) # Enregistrer les documents joints for f in request.FILES.getlist('documents'): PreparationOrderDocument.objects.create( preparation_order=preparation_order, file=f, name=f.name, ) messages.success(request, _("Bon de préparation créé avec succès.")) return redirect("interventions:intervention_detail", intervention_id=intervention_id) else: # Générer un numéro de bon automatique import datetime today = datetime.date.today() order_number = f"BP-{today.strftime('%Y%m%d')}-I{intervention.id}" form = PreparationOrderForm(initial={ 'order_number': order_number, 'status': 'to_process', 'priority': intervention.priority, }) formset = preparation_order_item_formset() categories = list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')) signalisation_ids = [ c['id'] for c in categories if 'signalisation' in (c['code'] or '').lower() or 'signalisation' in (c['name_fr'] or '').lower() ] return render( request, "stock/preparation_order_add.html", { "form": form, "formset": formset, "intervention": intervention, "from_intervention": True, "categories": categories, "signalisation_category_ids": signalisation_ids, }, ) @product_view_required def product_detail(request, product_id): """ Affiche les détails d'un produit avec ses stocks et commandes associées """ is_stock_manager = user_can_manage_products(request.user) product = get_object_or_404(Product.objects.select_related("category"), id=product_id) # Récupérer tous les stocks associés au produit stocks = list(Stock.objects.filter(product=product).select_related( "location__warehouse" ).order_by("location__warehouse__name", "location__name")) # Ajouter virtuellement les emplacements autorisés sans stock physique enregistré existing_location_ids = {s.location_id for s in stocks} if product.authorized_locations.exists(): for loc in product.authorized_locations.select_related("warehouse").all(): if loc.id not in existing_location_ids: virtual_stock = Stock( product=product, location=loc, quantity=0 ) stocks.append(virtual_stock) # Réordonner la liste des stocks stocks.sort(key=lambda s: (s.location.warehouse.name, s.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) # Récupérer les mouvements de stock récents recent_movements = StockMovement.objects.filter( product=product ).select_related( "source_location__warehouse", "destination_location__warehouse" ).order_by("-date")[:50] # Récupérer les commandes en cours pending_orders = PurchaseOrderItem.objects.filter( product=product, purchase_order__status="pending" ).select_related( "purchase_order__supplier", "purchase_order__warehouse", "location" ).order_by("-purchase_order__date_ordered") # Récupérer l'historique des commandes completed_orders = PurchaseOrderItem.objects.filter( product=product, purchase_order__status__in=["received", "cancelled"] ).select_related( "purchase_order__supplier", "purchase_order__warehouse", "location" ).order_by("-purchase_order__date_ordered")[:20] # Récupérer les alertes de stock actives alerts = StockAlert.objects.filter( product=product, is_active=True ).select_related("warehouse") # Articles tracés (si le produit est sérialisé) serialized_items = None if product.is_serialized: serialized_items = product.serialized_items.select_related( "current_holder", "current_location__warehouse" ).order_by("serial_number") context = { "product": product, "stocks": stocks, "recent_movements": recent_movements, "pending_orders": pending_orders, "completed_orders": completed_orders, "alerts": alerts, "serialized_items": serialized_items, "is_stock_manager": is_stock_manager, } return render(request, "stock/product_detail.html", context) @location_view_required def warehouse_location_detail(request, location_id): """ Affiche les détails d'un emplacement d'entrepôt avec les produits stockés """ location = get_object_or_404( WarehouseLocation.objects.select_related("warehouse"), id=location_id ) # Récupérer tous les stocks dans cet emplacement from django.db.models import Sum from collections import defaultdict stocks = Stock.objects.filter( location=location ).select_related( 'product__category' ).values( 'product__id', 'product__name', 'product__code', 'product__threshold', 'product__category__name_fr', 'quantity' ).order_by('product__category__name_fr', 'product__name') # Regrouper par catégorie products_by_category = defaultdict(list) for stock in stocks: category_name = stock['product__category__name_fr'] or _("Sans catégorie") products_by_category[category_name].append({ 'id': stock['product__id'], 'name': stock['product__name'], 'code': stock['product__code'], 'stock': stock['quantity'], 'threshold': stock['product__threshold'], 'is_below_threshold': stock['quantity'] < stock['product__threshold'] }) # Trier les catégories products_by_category = dict(sorted(products_by_category.items())) # Récupérer les mouvements récents pour cet emplacement recent_movements = StockMovement.objects.filter( models.Q(source_location=location) | models.Q(destination_location=location) ).select_related( 'product', 'source_location__warehouse', 'destination_location__warehouse' ).order_by('-date')[:30] context = { "location": location, "products_by_category": products_by_category, "recent_movements": recent_movements, "can_administer": user_can_administer_warehouse(request.user, location.warehouse), "is_stock_manager": user_can_manage_products(request.user), } return render(request, "stock/warehouse_location_detail.html", context) def preparation_order_detail(request, pk): """ Affiche le détail d'un bon de préparation """ 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'): 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('stock:preparation_order_detail', pk=pk) # Calculer les informations sur les produits items_with_stock = [] for item in preparation_order.preparationorderitem_set.all(): product = item.product raw_available = product.total_stock # Quantité réservée totale (bon courant inclus) reserved_stock = product.reserved_quantity # Stock disponible (stock total physique) available_stock = raw_available # Bon de confection lié à cet article prod_order = item.production_orders.first() items_with_stock.append({ 'item': item, 'available_stock': available_stock, 'total_stock': product.total_stock, 'reserved_stock': reserved_stock, 'is_sufficient': available_stock >= item.quantity, 'is_below_threshold': available_stock < product.threshold, 'production_order': prod_order, 'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse') }) # Vérifier les permissions from common.models import UserConfig try: user_config = request.user.config can_manage = request.user.is_superuser 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 # Enrichir les autres produits avec leur bon de confection éventuel other_items_with_info = [] for oi in preparation_order.other_items.all(): prod_order = oi.production_orders.first() other_items_with_info.append({ 'item': oi, 'production_order': prod_order, }) # Lots de mouvements liés (sortie lors du traitement, et retours éventuels) 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') context = { '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, 'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')), 'exit_batches': exit_batches, 'return_batches': return_batches, } return render(request, 'stock/preparation_order_detail.html', context) @require_POST def preparation_order_cancel(request, pk): """ Annule un bon de préparation et libère les quantités réservées """ # Vérifier les permissions from common.models import UserConfig try: user_config = request.user.config can_manage = user_config.can_manage_preparation_orders_any() except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission d'annuler des bons de préparation.")) return redirect('stock:preparation_order_detail', pk=pk) preparation_order = get_object_or_404(PreparationOrder, pk=pk) if preparation_order.status in ['to_process', 'pending']: preparation_order.cancel() messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number)) else: messages.error(request, _("Ce bon ne peut pas être annulé.")) return redirect('stock:preparation_order_detail', pk=pk) @login_required def preparation_order_edit(request, pk): """ API JSON pour modifier les articles d'un bon de préparation (ajout, suppression, modification de quantité). Réservé aux utilisateurs avec le droit de gérer les bons de préparation. """ from common.models import UserConfig from django.http import JsonResponse try: user_config = request.user.config if not user_config.can_manage_preparation_orders_any(): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) preparation_order = get_object_or_404(PreparationOrder, pk=pk) if preparation_order.status not in ['to_process', 'pending']: return JsonResponse({'error': _("Ce bon ne peut plus être modifié.")}, status=400) if request.method == 'GET': items = [] for item in preparation_order.preparationorderitem_set.select_related('product').all(): prod_order = item.production_orders.first() item_data = { 'id': item.pk, 'product_id': item.product.pk, 'product_name': item.product.name, 'product_code': item.product.code, 'quantity': item.quantity, 'has_production_order': prod_order is not None, 'production_order_status': prod_order.status if prod_order else None, 'production_order_code': prod_order.code if prod_order else None, 'can_edit': prod_order is None or prod_order.status == ProductionOrder.STATUS_TO_PROCESS, } items.append(item_data) other_items = [] for oi in preparation_order.other_items.select_related('category', 'sign_panel_type').all(): prod_order = oi.production_orders.first() other_items.append({ 'id': oi.pk, 'description': oi.description, 'quantity': oi.quantity, 'category_id': oi.category_id, 'category_name': oi.category.name_fr if oi.category else '', 'sign_panel_type_id': oi.sign_panel_type_id, 'sign_panel_type_text': str(oi.sign_panel_type) if oi.sign_panel_type else '', 'has_production_order': prod_order is not None, 'production_order_status': prod_order.status if prod_order else None, 'production_order_code': prod_order.code if prod_order else None, 'can_edit': prod_order is None or prod_order.status == ProductionOrder.STATUS_TO_PROCESS, }) return JsonResponse({'items': items, 'other_items': other_items}) if request.method == 'POST': import json as json_lib try: data = json_lib.loads(request.body) except Exception: return JsonResponse({'error': _("Données invalides.")}, status=400) action = data.get('action') if action == 'add_item': product_id = data.get('product_id') quantity = int(data.get('quantity', 1)) if not product_id or quantity < 1: return JsonResponse({'error': _("Données invalides.")}, status=400) product = get_object_or_404(Product, pk=product_id) item, created = PreparationOrderItem.objects.get_or_create( preparation_order=preparation_order, product=product, defaults={'quantity': quantity}, ) if not created: item.quantity += quantity item.save() return JsonResponse({ 'id': item.pk, 'product_id': product.pk, 'product_name': product.name, 'product_code': product.code, 'quantity': item.quantity, }) elif action == 'update_quantity': item_id = data.get('item_id') quantity = int(data.get('quantity', 1)) if not item_id or quantity < 1: return JsonResponse({'error': _("Données invalides.")}, status=400) item = get_object_or_404(PreparationOrderItem, pk=item_id, preparation_order=preparation_order) # Vérifier si un bon de confection est lié prod_order = item.production_orders.first() if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Modification impossible.").format( code=prod_order.code, status=prod_order.get_status_display())}, status=400) old_quantity = item.quantity # Si l'article était déjà préparé, marquer comme modifié pour alerter le magasinier if item.is_prepared and quantity != old_quantity: item.quantity_modified = True item.previous_quantity = old_quantity item.quantity = quantity item.save() # Synchroniser la quantité dans le bon de confection associé if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: prod_order.quantity = quantity prod_order.save(update_fields=['quantity', 'updated_at']) return JsonResponse({'id': item.pk, 'quantity': item.quantity}) elif action == 'remove_item': item_id = data.get('item_id') if not item_id: return JsonResponse({'error': _("Données invalides.")}, status=400) item = get_object_or_404(PreparationOrderItem, pk=item_id, preparation_order=preparation_order) # Vérifier si un bon de confection est lié prod_order = item.production_orders.first() if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Suppression impossible.").format( code=prod_order.code, status=prod_order.get_status_display())}, status=400) # Annuler le bon de confection associé si encore à traiter if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: prod_order.cancel() item.delete() return JsonResponse({'deleted': item_id}) elif action == 'add_other_item': description = (data.get('description') or '').strip() quantity = max(1, int(data.get('quantity', 1) or 1)) category_id = data.get('category_id') or None sign_panel_type_id = data.get('sign_panel_type_id') or None if not description: return JsonResponse({'error': _("La description est obligatoire.")}, status=400) category = None if category_id: try: category = ProductCategory.objects.get(pk=category_id) except ProductCategory.DoesNotExist: pass sign_panel_type = None if sign_panel_type_id: from sign.models import SignPanelType try: sign_panel_type = SignPanelType.objects.get(pk=sign_panel_type_id) except SignPanelType.DoesNotExist: import logging logging.getLogger(__name__).warning( "SignPanelType id=%s introuvable lors de l'ajout d'un autre produit", sign_panel_type_id ) oi = PreparationOrderOtherItem.objects.create( preparation_order=preparation_order, description=description, quantity=quantity, category=category, sign_panel_type=sign_panel_type, ) return JsonResponse({ 'id': oi.pk, 'description': oi.description, 'quantity': oi.quantity, 'category_id': oi.category_id, 'category_name': oi.category.name_fr if oi.category else '', 'sign_panel_type_id': oi.sign_panel_type_id, 'sign_panel_type_text': str(oi.sign_panel_type) if oi.sign_panel_type else '', }) elif action == 'update_other_item': oi_id = data.get('item_id') if not oi_id: return JsonResponse({'error': _("Données invalides.")}, status=400) oi = get_object_or_404(PreparationOrderOtherItem, pk=oi_id, preparation_order=preparation_order) # Vérifier si un bon de confection est lié prod_order = oi.production_orders.first() if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Modification impossible.").format( code=prod_order.code, status=prod_order.get_status_display())}, status=400) description = (data.get('description') or '').strip() quantity = data.get('quantity') if description: oi.description = description if quantity is not None: oi.quantity = max(1, int(quantity)) # Mise à jour de la catégorie si fournie if 'category_id' in data: category_id = data.get('category_id') or None if category_id: try: oi.category = ProductCategory.objects.get(pk=category_id) except ProductCategory.DoesNotExist: oi.category = None else: oi.category = None # Mise à jour du type de signalisation si fourni if 'sign_panel_type_id' in data: sign_panel_type_id = data.get('sign_panel_type_id') or None if sign_panel_type_id: from sign.models import SignPanelType try: oi.sign_panel_type = SignPanelType.objects.get(pk=sign_panel_type_id) except SignPanelType.DoesNotExist: oi.sign_panel_type = None else: oi.sign_panel_type = None oi.save() # Synchroniser la quantité dans le bon de confection associé si la quantité a changé if quantity is not None and prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: prod_order.quantity = oi.quantity prod_order.save(update_fields=['quantity', 'updated_at']) return JsonResponse({ 'id': oi.pk, 'description': oi.description, 'quantity': oi.quantity, 'category_id': oi.category_id, 'category_name': oi.category.name_fr if oi.category else '', 'sign_panel_type_id': oi.sign_panel_type_id, 'sign_panel_type_text': str(oi.sign_panel_type) if oi.sign_panel_type else '', }) elif action == 'remove_other_item': oi_id = data.get('item_id') if not oi_id: return JsonResponse({'error': _("Données invalides.")}, status=400) oi = get_object_or_404(PreparationOrderOtherItem, pk=oi_id, preparation_order=preparation_order) # Vérifier si un bon de confection est lié prod_order = oi.production_orders.first() if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Suppression impossible.").format( code=prod_order.code, status=prod_order.get_status_display())}, status=400) # Annuler le bon de confection associé si encore à traiter if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: prod_order.cancel() oi.delete() return JsonResponse({'deleted': oi_id}) return JsonResponse({'error': _("Action inconnue.")}, status=400) return JsonResponse({'error': _("Méthode non autorisée.")}, status=405) def preparation_order_process(request, pk): """ Traite un bon de préparation - permet de sortir les produits du stock """ # Vérifier les permissions try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de traiter des bons de préparation.")) return redirect('stock:stock_movements') # Récupérer les entrepôts autorisés pour l'utilisateur if request.user.is_superuser or user_config.has_role('admin'): allowed_locations = WarehouseLocation.objects.select_related('warehouse').all() else: user_warehouses = user_config.userwarehouses.filter(can_manage_preparation_orders=True).values_list('warehouse_id', flat=True) allowed_locations = WarehouseLocation.objects.filter( warehouse_id__in=user_warehouses ).select_related('warehouse') preparation_order = get_object_or_404( PreparationOrder.objects.prefetch_related('preparationorderitem_set__product'), pk=pk ) if preparation_order.status not in ['to_process', 'pending']: messages.error(request, _("Ce bon ne peut plus être traité.")) return redirect('stock:preparation_order_detail', pk=pk) if request.method == 'POST': # Gérer l'ajout d'un nouveau produit if 'add_product' in request.POST: product_id = request.POST.get('new_product_id') new_quantity = request.POST.get('new_product_quantity') if product_id and new_quantity: try: product = Product.objects.get(id=product_id) quantity = int(new_quantity) # Vérifier si le produit existe déjà existing_item = PreparationOrderItem.objects.filter( preparation_order=preparation_order, product=product ).first() if existing_item: existing_item.quantity += quantity existing_item.save() messages.success(request, _("Quantité mise à jour pour {}").format(product.name)) else: PreparationOrderItem.objects.create( preparation_order=preparation_order, product=product, quantity=quantity, ) messages.success(request, _("Produit {} ajouté au bon").format(product.name)) return redirect('stock:preparation_order_process', pk=pk) except (Product.DoesNotExist, ValueError): messages.error(request, _("Erreur lors de l'ajout du produit")) # Traiter les sorties de stock products_to_move = [] # Mettre à jour l'état "préparé" pour chaque item for item in preparation_order.preparationorderitem_set.all(): is_prepared = request.POST.get(f'is_prepared_{item.id}') == 'true' if item.is_prepared != is_prepared: item.is_prepared = is_prepared item.save(update_fields=['is_prepared']) # Si on sauvegarde en brouillon, rediriger sans traiter if 'save_draft' in request.POST: messages.success(request, _("Le bon de préparation a été sauvegardé.")) return redirect('stock:preparation_order_process', pk=pk) # Collecter les produits à sortir seulement si on marque comme traité for key, value in request.POST.items(): if key.startswith('quantity_') and value: try: item_id = int(key.replace('quantity_', '')) quantity = int(value) location_id = request.POST.get(f'location_{item_id}') if quantity > 0 and location_id: item = PreparationOrderItem.objects.get(id=item_id, preparation_order=preparation_order) location = WarehouseLocation.objects.get(id=location_id) from stock.permissions import user_can_manage_warehouse if not user_can_manage_warehouse(request.user, location.warehouse): messages.error(request, _("Vous n'avez pas la permission de gérer l'entrepôt de l'emplacement {}.").format(location.name)) return redirect('stock:preparation_order_process', pk=pk) products_to_move.append({ 'item': item, 'quantity': quantity, 'location': location }) except (ValueError, PreparationOrderItem.DoesNotExist, WarehouseLocation.DoesNotExist): continue # Vérifier si on marque comme traité if 'mark_as_processed' in request.POST: # Créer le lot de mouvements lié au bon de préparation with transaction.atomic(): batch = StockMovementBatch.objects.create( movement_type=StockMovementBatch.TYPE_OUT, movement_subtype='preparation_order', reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number), preparation_order=preparation_order, created_by=request.user, is_loan=preparation_order.is_loan, estimated_return_date=preparation_order.estimated_return_date, loan_returned=preparation_order.loan_returned, ) for move in products_to_move: stock, _created = Stock.objects.select_for_update().get_or_create( product=move['item'].product, location=move['location'], defaults={'quantity': 0}, ) StockMovement.objects.create( product=move['item'].product, source_location=move['location'], destination_location=None, quantity=move['quantity'], reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number), batch=batch, ) stock.quantity -= move['quantity'] stock.save() # Marquer comme traité preparation_order.mark_as_processed() messages.success(request, _("Le bon de préparation #{} a été traité avec succès.").format( preparation_order.order_number )) return redirect('stock:preparation_order_detail', pk=pk) # Préparer les données pour le formulaire items_with_locations = [] for item in preparation_order.preparationorderitem_set.all(): # Récupérer les emplacements avec stock pour ce produit (inclure tous, même qty=0) stocks = Stock.objects.filter( product=item.product, ).select_related('location__warehouse').order_by('-quantity') # Si aucun stock n'existe, proposer tous les emplacements autorisés if not stocks.exists(): fallback_locations = allowed_locations else: fallback_locations = None # Calculer la quantité en commande pending_orders = PurchaseOrderItem.objects.filter( product=item.product, purchase_order__status='ordered' ).aggregate(total=models.Sum('quantity'))['total'] or 0 # Quantité réservée totale (bon courant inclus) et stock disponible reserved = item.product.reserved_quantity available = item.product.total_stock # Bon de confection lié à cet article prod_order = item.production_orders.first() items_with_locations.append({ 'item': item, 'stocks': stocks, 'fallback_locations': fallback_locations, 'total_stock': item.product.total_stock, 'pending_orders': pending_orders, 'reserved': reserved, 'available': available, 'has_single_location': stocks.count() == 1, 'single_location_id': stocks.first().location.id if stocks.count() == 1 else None, 'production_order': prod_order, }) # Enrichir les autres produits avec leur bon de confection éventuel other_items_with_info = [] for oi in preparation_order.other_items.all(): prod_order = oi.production_orders.first() other_items_with_info.append({ 'item': oi, 'production_order': prod_order, }) # Détecter si des articles préparés ont été modifiés has_modified_items = any( item_data['item'].quantity_modified for item_data in items_with_locations ) context = { 'preparation_order': preparation_order, 'items_with_locations': items_with_locations, 'other_items': other_items_with_info, 'allowed_locations': allowed_locations, 'has_modified_items': has_modified_items, } return render(request, 'stock/preparation_order_process.html', context) @require_POST def quick_stock_adjustment(request): """ Effectue un ajustement rapide de stock """ # Vérifier les permissions from stock.permissions import user_can_administer_warehouse location_id = request.POST.get('location_id') try: location = WarehouseLocation.objects.get(id=location_id) except WarehouseLocation.DoesNotExist: return JsonResponse({'success': False, 'error': 'Emplacement invalide.'}, status=400) if not user_can_administer_warehouse(request.user, location.warehouse): return JsonResponse({'success': False, 'error': 'Permission refusée'}, status=403) try: product_id = request.POST.get('product_id') location_id = request.POST.get('location_id') new_quantity = int(request.POST.get('quantity', 0)) reason = request.POST.get('reason', _('Correction manuelle')) product = Product.objects.get(id=product_id) location = WarehouseLocation.objects.get(id=location_id) if product.authorized_locations.exists() and location not in product.authorized_locations.all(): return JsonResponse({ 'success': False, 'error': _("Cet emplacement n'est pas autorisé pour ce produit.") }, status=400) # Récupérer ou créer le stock stock, created = Stock.objects.get_or_create( product=product, location=location, defaults={'quantity': 0} ) # Calculer la différence difference = new_quantity - stock.quantity if difference != 0: with transaction.atomic(): batch = StockMovementBatch.objects.create( movement_type=StockMovementBatch.TYPE_IN if difference > 0 else StockMovementBatch.TYPE_OUT, movement_subtype='correction', reason=reason, created_by=request.user, ) StockMovement.objects.create( product=product, source_location=location if difference < 0 else None, destination_location=location if difference > 0 else None, quantity=abs(difference), reason=reason, batch=batch, ) # Mettre à jour le stock stock.quantity = new_quantity stock.save() return JsonResponse({ 'success': True, 'new_quantity': new_quantity, 'batch_id': batch.pk, 'message': _('Stock mis à jour avec succès') }) else: return JsonResponse({'success': True, 'message': _('Aucune modification')}) except (Product.DoesNotExist, WarehouseLocation.DoesNotExist, ValueError) as e: return JsonResponse({'success': False, 'error': str(e)}, status=400) @require_POST def preparation_order_item_toggle_prepared(request, item_id): """ Bascule l'état is_prepared d'un item de bon de préparation via AJAX """ from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: return JsonResponse({'success': False, 'error': 'Permission refusée'}, status=403) try: item = PreparationOrderItem.objects.get(pk=item_id) item.is_prepared = not item.is_prepared # Si le magasinier marque comme préparé, acquitter l'alerte de modification if item.is_prepared and item.quantity_modified: item.quantity_modified = False item.previous_quantity = None item.save(update_fields=['is_prepared', 'quantity_modified', 'previous_quantity']) else: item.save(update_fields=['is_prepared']) return JsonResponse({'success': True, 'is_prepared': item.is_prepared, 'quantity_modified': item.quantity_modified}) except PreparationOrderItem.DoesNotExist: return JsonResponse({'success': False, 'error': 'Item introuvable'}, status=404) @require_POST def preparation_order_other_item_toggle_prepared(request, item_id): """ Bascule l'état is_prepared d'un 'autre produit' de bon de préparation via AJAX """ from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: return JsonResponse({'success': False, 'error': 'Permission refusée'}, status=403) try: item = PreparationOrderOtherItem.objects.get(pk=item_id) item.is_prepared = not item.is_prepared item.save(update_fields=['is_prepared']) return JsonResponse({'success': True, 'is_prepared': item.is_prepared}) except PreparationOrderOtherItem.DoesNotExist: return JsonResponse({'success': False, 'error': 'Item introuvable'}, status=404) @login_required @require_POST def preparation_order_document_upload(request, pk): """ Upload AJAX d'un ou plusieurs documents sur un bon de préparation. """ from common.models import UserConfig from common.private_files.helpers import build_private_url from django.urls import reverse try: user_config = request.user.config if not user_config.can_manage_preparation_orders_any(): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) preparation_order = get_object_or_404(PreparationOrder, pk=pk) files = request.FILES.getlist('documents') if not files: return JsonResponse({'error': _("Aucun fichier fourni.")}, status=400) created = [] for f in files: doc = PreparationOrderDocument.objects.create( preparation_order=preparation_order, file=f, name=f.name, ) created.append({ 'id': doc.pk, 'filename': doc.filename(), 'is_image': doc.is_image(), 'uploaded_at': doc.uploaded_at.strftime('%d/%m/%Y %H:%M'), 'url': build_private_url('stock', 'PreparationOrderDocument', doc.pk, 'file'), 'delete_url': reverse('stock:preparation_order_document_delete', args=[doc.pk]), }) return JsonResponse({'created': created}) @login_required @require_POST def preparation_order_document_delete(request, doc_id): """ Suppression AJAX d'un document de bon de préparation. """ from common.models import UserConfig try: user_config = request.user.config if not user_config.can_manage_preparation_orders_any(): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) doc = get_object_or_404(PreparationOrderDocument, pk=doc_id) doc.delete() return JsonResponse({'deleted': doc_id}) @login_required @require_POST def purchase_order_document_upload(request, pk): """ Upload AJAX d'un ou plusieurs documents sur une commande fournisseur. """ from common.models import UserConfig from common.private_files.helpers import build_private_url from django.urls import reverse try: user_config = request.user.config if not user_config.can_manage_purchase_orders_any(): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) purchase_order = get_object_or_404(PurchaseOrder, pk=pk) files = request.FILES.getlist('documents') if not files: return JsonResponse({'error': _("Aucun fichier fourni.")}, status=400) created = [] for f in files: doc = PurchaseOrderDocument.objects.create( purchase_order=purchase_order, file=f, name=f.name, ) created.append({ 'id': doc.pk, 'filename': doc.filename(), 'is_image': doc.is_image(), 'uploaded_at': doc.uploaded_at.strftime('%d/%m/%Y %H:%M'), 'url': build_private_url('stock', 'PurchaseOrderDocument', doc.pk, 'file'), 'delete_url': reverse('stock:purchase_order_document_delete', args=[doc.pk]), }) return JsonResponse({'created': created}) @login_required @require_POST def purchase_order_document_delete(request, doc_id): """ Suppression AJAX d'un document de commande fournisseur. """ from common.models import UserConfig try: user_config = request.user.config if not user_config.can_manage_purchase_orders_any(): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) doc = get_object_or_404(PurchaseOrderDocument, pk=doc_id) doc.delete() return JsonResponse({'deleted': doc_id}) @login_required @require_POST def production_order_document_upload(request, pk): """ Upload AJAX d'un ou plusieurs documents sur un bon de confection. """ production_order = get_object_or_404(ProductionOrder, pk=pk) from common.models import UserConfig from common.private_files.helpers import build_private_url from django.urls import reverse 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_warehouse(production_order.warehouse) ) if not can_manage: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) files = request.FILES.getlist('documents') if not files: return JsonResponse({'error': _("Aucun fichier fourni.")}, status=400) created = [] for f in files: doc = ProductionOrderDocument.objects.create( production_order=production_order, file=f, name=f.name, ) created.append({ 'id': doc.pk, 'filename': doc.filename(), 'is_image': doc.is_image(), 'uploaded_at': doc.uploaded_at.strftime('%d/%m/%Y %H:%M'), 'url': build_private_url('stock', 'ProductionOrderDocument', doc.pk, 'file'), 'delete_url': reverse('stock:production_order_document_delete', args=[doc.pk]), }) return JsonResponse({'created': created}) @login_required @require_POST def production_order_document_delete(request, doc_id): """ Suppression AJAX d'un document de bon de confection. """ doc = get_object_or_404(ProductionOrderDocument, pk=doc_id) from common.models import UserConfig 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_warehouse(doc.production_order.warehouse) ) if not can_manage: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) except UserConfig.DoesNotExist: return JsonResponse({'error': _("Droits insuffisants.")}, status=403) doc = get_object_or_404(ProductionOrderDocument, pk=doc_id) doc.delete() return JsonResponse({'deleted': doc_id}) def preparation_order_create_purchase(request, pk): """ Crée un bon de commande à partir d'un bon de préparation Propose par défaut les produits manquants """ # Vérifier les permissions from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de créer des bons de commande.")) return redirect('stock:stock_movements') preparation_order = get_object_or_404( PreparationOrder.objects.prefetch_related('preparationorderitem_set__product'), pk=pk ) # Identifier les produits manquants ou en stock insuffisant missing_products = [] for item in preparation_order.preparationorderitem_set.all(): product = item.product if product.total_stock < item.quantity: missing_products.append({ 'product': product, 'needed': item.quantity, 'available': product.total_stock, 'to_order': item.quantity - product.total_stock }) if request.method == 'POST': form = PurchaseOrderForm(request.POST) formset = PurchaseOrderItemFormSet(request.POST) if form.is_valid() and formset.is_valid(): warehouse = form.cleaned_data.get("warehouse") if warehouse and not (request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_for(warehouse)): messages.error(request, _("Vous n'avez pas la permission de créer un bon de commande pour cet entrepôt.")) return redirect('stock:stock_purchases') purchase_order = form.save() formset.instance = purchase_order formset.save() messages.success(request, _("Bon de commande créé avec succès.")) return redirect('stock:purchase_order_detail', pk=purchase_order.pk) else: # Pré-remplir le formulaire avec l'entrepôt par défaut de l'utilisateur initial_data = {} if hasattr(request.user, 'config'): default_warehouse = request.user.config.get_default_warehouse() if default_warehouse: initial_data['warehouse'] = default_warehouse form = PurchaseOrderForm(initial=initial_data) formset = PurchaseOrderItemFormSet() context = { 'preparation_order': preparation_order, 'missing_products': missing_products, 'form': form, 'formset': formset, } return render(request, 'stock/preparation_order_create_purchase.html', context) def supplier_list(request): """ Liste tous les fournisseurs - uniquement pour les stock_manager """ from common.models import UserConfig try: user_config = request.user.config is_stock_manager = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses().exists() ) 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('stock:stock_purchases') suppliers = Supplier.objects.all().order_by("name") return render(request, 'stock/supplier_list.html', {'suppliers': suppliers}) def supplier_create(request): """ Créer un nouveau fournisseur - uniquement pour les stock_manager """ from common.models import UserConfig try: user_config = request.user.config is_stock_manager = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any() ) 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('stock:stock_purchases') if request.method == 'POST': form = SupplierForm(request.POST) if form.is_valid(): supplier = form.save() messages.success(request, _("Fournisseur créé avec succès.")) return redirect('stock:supplier_detail', pk=supplier.pk) else: form = SupplierForm() return render(request, 'stock/supplier_form.html', {'form': form, 'action': 'create'}) def supplier_detail(request, pk): """ Voir et modifier un fournisseur - uniquement pour les stock_manager """ from common.models import UserConfig try: user_config = request.user.config can_view = ( request.user.is_superuser or user_config.has_role('admin') or user_config.get_accessible_warehouses().exists() ) can_edit = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_purchase_orders_any() ) except UserConfig.DoesNotExist: can_view = False can_edit = False if not can_view: messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:stock_purchases') supplier = get_object_or_404(Supplier, pk=pk) if request.method == 'POST': if not can_edit: messages.error(request, _("Vous n'avez pas la permission de modifier un fournisseur.")) return redirect('stock:supplier_detail', pk=supplier.pk) form = SupplierForm(request.POST, instance=supplier) if form.is_valid(): form.save() messages.success(request, _("Fournisseur mis à jour avec succès.")) return redirect('stock:supplier_detail', pk=supplier.pk) else: form = SupplierForm(instance=supplier) if not can_edit: for field in form.fields.values(): field.widget.attrs['disabled'] = 'disabled' field.required = False # Récupérer les commandes liées à ce fournisseur purchase_orders = supplier.purchase_orders.all().order_by('-date_ordered')[:10] return render(request, 'stock/supplier_detail.html', { 'supplier': supplier, 'form': form, 'purchase_orders': purchase_orders, 'can_edit': can_edit, }) # ============================================= # Gestion des emplacements (CRUD can_administer) # ============================================= @login_required def warehouse_location_create(request, warehouse_id): """ Créer un nouvel emplacement dans un entrepôt - réservé aux administrateurs d'entrepôt. """ warehouse = get_object_or_404(Warehouse, id=warehouse_id) if not user_can_administer_warehouse(request.user, warehouse): messages.error(request, _("Vous n'avez pas la permission d'administrer cet entrepôt.")) return redirect('stock:warehouse_detail', warehouse_id=warehouse_id) if request.method == 'POST': form = WarehouseLocationForm(request.POST, warehouse=warehouse) if form.is_valid(): location = form.save() messages.success(request, _("Emplacement créé avec succès.")) return redirect('stock:warehouse_location_detail', location_id=location.pk) else: form = WarehouseLocationForm(warehouse=warehouse) return render(request, 'stock/warehouse_location_form.html', { 'form': form, 'warehouse': warehouse, 'action': 'create', }) @login_required def warehouse_location_edit(request, location_id): """ Modifier un emplacement existant - réservé aux administrateurs d'entrepôt. """ location = get_object_or_404(WarehouseLocation.objects.select_related('warehouse'), id=location_id) warehouse = location.warehouse if not user_can_administer_warehouse(request.user, warehouse): messages.error(request, _("Vous n'avez pas la permission d'administrer cet entrepôt.")) return redirect('stock:warehouse_location_detail', location_id=location_id) if request.method == 'POST': form = WarehouseLocationForm(request.POST, instance=location, warehouse=warehouse) if form.is_valid(): form.save() messages.success(request, _("Emplacement mis à jour avec succès.")) return redirect('stock:warehouse_location_detail', location_id=location.pk) else: form = WarehouseLocationForm(instance=location, warehouse=warehouse) return render(request, 'stock/warehouse_location_form.html', { 'form': form, 'warehouse': warehouse, 'location': location, 'action': 'edit', }) @login_required @require_POST def warehouse_location_delete(request, location_id): """ Supprimer un emplacement - réservé aux administrateurs d'entrepôt. """ location = get_object_or_404(WarehouseLocation.objects.select_related('warehouse'), id=location_id) warehouse = location.warehouse if not user_can_administer_warehouse(request.user, warehouse): messages.error(request, _("Vous n'avez pas la permission d'administrer cet entrepôt.")) return redirect('stock:warehouse_location_detail', location_id=location_id) location_name = location.name warehouse_id = warehouse.id location.delete() messages.success(request, _("Emplacement \"%(name)s\" supprimé avec succès.") % {'name': location_name}) return redirect('stock:warehouse_detail', warehouse_id=warehouse_id) # ============================================= # Gestion des produits (CRUD stock_manager) # ============================================= def product_list(request): """ Redirige vers l'onglet produits de la page des entrepôts. """ from django.urls import reverse return redirect(reverse('stock:warehouses') + '?tab=products') def _next_product_number(): """Returns the next sequential product number based on existing SKUs.""" import re max_num = 0 for sku in Product.objects.values_list('sku', flat=True): m = re.fullmatch(r'(\d+)', sku.strip()) if m: max_num = max(max_num, int(m.group(1))) return max_num + 1 def _generate_product_code_and_sku(): """Returns (code, sku) based on the next sequential number.""" num = _next_product_number() return f"PROD_{num}", str(num) @login_required def product_create(request): """ Créer un nouveau produit - réservé aux stock_manager et aux administrateurs d'entrepôt. """ if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:warehouses') if request.method == 'POST': form = ProductForm(request.POST) if form.is_valid(): with transaction.atomic(): product = form.save() # Récupérer les quantités initiales pour les emplacements autorisés authorized_locations = form.cleaned_data.get('authorized_locations', []) for loc in authorized_locations: qty_key = f"initial_qty_{loc.id}" try: qty = int(request.POST.get(qty_key, 0)) except (ValueError, TypeError): qty = 0 if qty > 0: # Créer l'entrée de stock Stock.objects.create( product=product, location=loc, quantity=qty ) # Créer un mouvement de stock de type entrée pour l'historique batch = StockMovementBatch.objects.create( movement_type=StockMovementBatch.TYPE_IN, movement_subtype='correction', reason=_("Stock initial à la création du produit"), created_by=request.user, ) StockMovement.objects.create( product=product, source_location=None, destination_location=loc, quantity=qty, reason=_("Stock initial à la création du produit"), batch=batch, ) messages.success(request, _("Produit créé avec succès.")) return redirect('stock:product_detail', product_id=product.pk) else: code, sku = _generate_product_code_and_sku() form = ProductForm(initial={'code': code, 'sku': sku}) return render(request, 'stock/product_form.html', {'form': form, 'action': 'create'}) @login_required def product_create_at_location(request, location_id): """ Créer un nouveau produit directement depuis la page de détail d'un emplacement. Le produit est alors automatiquement lié à cet emplacement, et la quantité initiale est saisie directement. Accessible uniquement pour les utilisateurs ayant les permissions suffisantes (user_can_manage_products). """ if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'ajouter un produit.")) return redirect('stock:warehouse_location_detail', location_id=location_id) location = get_object_or_404(WarehouseLocation.objects.select_related('warehouse'), id=location_id) from stock.permissions import user_can_administer_warehouse if not (request.user.is_superuser or request.user.config.has_role('admin') or user_can_administer_warehouse(request.user, location.warehouse)): messages.error(request, _("Vous n'avez pas la permission d'ajouter un produit pour cet entrepôt.")) return redirect('stock:warehouse_location_detail', location_id=location_id) if request.method == 'POST': form = ProductAtLocationForm(request.POST) if form.is_valid(): with transaction.atomic(): product = form.save(commit=False) product.save() form.save_m2m() quantity = form.cleaned_data.get('quantity', 0) # Associer automatiquement le produit à cet emplacement Stock.objects.create( product=product, location=location, quantity=quantity ) if quantity > 0: batch = StockMovementBatch.objects.create( movement_type=StockMovementBatch.TYPE_IN, movement_subtype='correction', reason=_("Stock initial à la création du produit"), created_by=request.user, ) StockMovement.objects.create( product=product, source_location=None, destination_location=location, quantity=quantity, reason=_("Stock initial à la création du produit"), batch=batch, ) messages.success(request, _("Produit créé et associé à cet emplacement avec succès.")) return redirect('stock:warehouse_location_detail', location_id=location.pk) else: code, sku = _generate_product_code_and_sku() form = ProductAtLocationForm(initial={ 'code': code, 'sku': sku, 'quantity': 0 }) return render(request, 'stock/product_create_at_location.html', { 'form': form, 'location': location, 'action': 'create' }) @login_required def product_edit(request, product_id): """ Modifier un produit existant - réservé aux stock_manager et aux administrateurs d'entrepôt. """ if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:warehouses') product = get_object_or_404(Product, id=product_id) if request.method == 'POST': form = ProductForm(request.POST, instance=product) if form.is_valid(): form.save() messages.success(request, _("Produit mis à jour avec succès.")) return redirect('stock:product_detail', product_id=product.pk) else: form = ProductForm(instance=product) return render(request, 'stock/product_form.html', {'form': form, 'action': 'edit', 'product': product}) @login_required @require_POST def product_delete(request, product_id): """ Supprimer un produit - réservé aux stock_manager et aux administrateurs d'entrepôt. """ if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:warehouses') product = get_object_or_404(Product, id=product_id) product_name = product.name product.is_active = False product.save(update_fields=['is_active']) product.serialized_items.update(is_active=False) messages.success(request, _("Produit \"%(name)s\" rendu inactif.") % {'name': product_name}) from django.urls import reverse return redirect(reverse('stock:warehouses') + '?tab=products') # ============================================= # Conceptions de produits (Product Designs) # ============================================= def production_center(request): """Redirige vers l'onglet Bons de confection de la page mouvements de stock.""" from django.urls import reverse return redirect(reverse('stock:stock_movements') + '?tab=production-orders') def product_design_list(request): """Redirige vers la page bons de confection""" return redirect('stock:production_center') # def product_design_create(request): ... # def product_design_detail(request, pk): ... # def product_design_edit(request, pk): ... # (Ces vues ont été désactivées avec le modèle ProductDesign) def product_design_create(request): return redirect('stock:production_center') def product_design_detail(request, pk): return redirect('stock:production_center') def product_design_edit(request, pk): return redirect('stock:production_center') # ============================================= # Ordres de production (Production Orders) # ============================================= def production_order_list(request): """Alias : redirige vers la page unifiée (onglet production)""" return redirect('stock:production_center') def production_order_create(request): """Créer un nouveau bon de confection (ex bon de production)""" 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') or ( user_config.has_role('top_manager') and user_config.get_accessible_warehouses(can_manage=True).exists() ) ) 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('stock:production_order_list') # Pré-remplir depuis un bon de préparation / item personnalisé 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 # Entrepôt par défaut de l'utilisateur try: default_warehouse = request.user.config.get_default_warehouse() if default_warehouse: initial['warehouse'] = default_warehouse except Exception: pass # Pré-remplissage depuis un article du bon de préparation 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 # Pré-remplissage depuis un "autre produit demandé" dans un bon de préparation 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 not (request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_warehouse(order.warehouse)): messages.error(request, _("Vous n'avez pas la permission de gérer cet entrepôt.")) return redirect('stock:production_order_list') # Lier au produit du bon de préparation 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() 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('stock:production_order_detail', pk=order.pk) else: form = ProductionOrderForm(initial=initial) formset = ProductionOrderComponentFormSet(prefix='components') return render(request, 'stock/production_order_form.html', { 'form': form, 'formset': formset, 'action': 'create', 'other_item_context': other_item_context, 'prep_item_context': prep_item_context, }) def production_order_detail(request, pk): """Détail d'un bon de production""" order = get_object_or_404( ProductionOrder.objects.select_related( 'warehouse', 'preparation_order' ).prefetch_related('documents'), pk=pk ) from common.models import UserConfig try: user_config = request.user.config has_access = ( user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_view_warehouse(order.warehouse) ) can_manage = ( user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_warehouse(order.warehouse) ) except UserConfig.DoesNotExist: has_access = False can_manage = False if not has_access: messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:warehouses') 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] context = { 'order': order, 'components_status': components_status, 'can_start': can_start, 'can_manage': can_manage, 'other_item_description': other_item_description, } return render(request, 'stock/production_order_detail.html', context) def production_order_edit(request, pk): """Modifier un bon de confection existant""" order = get_object_or_404(ProductionOrder, pk=pk) from common.models import UserConfig 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_warehouse(order.warehouse) ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:production_order_detail', pk=pk) if request.method == 'POST': form = ProductionOrderForm(request.POST, request.FILES, instance=order) formset = ProductionOrderComponentFormSet(request.POST, instance=order, prefix='components') if form.is_valid() and formset.is_valid(): form.save() formset.save() 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 mis à jour.")) return redirect('stock:production_order_detail', pk=order.pk) else: form = ProductionOrderForm(instance=order) formset = ProductionOrderComponentFormSet(instance=order, prefix='components') return render(request, 'stock/production_order_form.html', { 'form': form, 'formset': formset, 'order': order, 'action': 'edit', }) @require_POST def production_order_start(request, pk): """Démarrer la production (passer en 'En production')""" order = get_object_or_404(ProductionOrder, pk=pk) from common.models import UserConfig 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_warehouse(order.warehouse) ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Permission refusée.")) return redirect('stock:production_order_detail', pk=pk) if order.status in [ProductionOrder.STATUS_TO_PROCESS, ProductionOrder.STATUS_DRAFT]: if order.can_start(): order.status = ProductionOrder.STATUS_IN_PRODUCTION order.save(update_fields=['status', 'updated_at']) messages.success(request, _("Production démarrée.")) else: messages.error(request, _("Stock insuffisant pour démarrer la production.")) else: messages.warning(request, _("Cet ordre ne peut pas être démarré.")) next_url = request.POST.get('next') if next_url: return redirect(next_url) return redirect('stock:production_order_detail', pk=pk) @require_POST def production_order_complete(request, pk): """Terminer la production et consommer le stock""" order = get_object_or_404(ProductionOrder, pk=pk) from common.models import UserConfig 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_warehouse(order.warehouse) ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Permission refusée.")) return redirect('stock:production_order_detail', pk=pk) if order.status == ProductionOrder.STATUS_IN_PRODUCTION: if order.can_start(): # Re-vérifier le stock order.complete() messages.success(request, _("Production terminée. Stock mis à jour.")) else: messages.error(request, _("Stock insuffisant pour terminer la production.")) else: messages.warning(request, _("Cet ordre ne peut pas être complété.")) next_url = request.POST.get('next') if next_url: return redirect(next_url) return redirect('stock:production_order_detail', pk=pk) @require_POST def production_order_cancel(request, pk): """Annuler un bon de production""" order = get_object_or_404(ProductionOrder, pk=pk) from common.models import UserConfig 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_warehouse(order.warehouse) ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Permission refusée.")) return redirect('stock:production_order_detail', pk=pk) if order.status in [ProductionOrder.STATUS_DRAFT, ProductionOrder.STATUS_IN_PRODUCTION]: order.cancel() messages.success(request, _("Bon de production annulé.")) else: messages.warning(request, _("Cet ordre ne peut pas être annulé.")) next_url = request.POST.get('next') if next_url: return redirect(next_url) return redirect('stock:production_order_detail', pk=pk) def product_design_api(request): """Stub — ProductDesign désactivé.""" return JsonResponse({'components': []}) # ─── Articles tracés (numéros de série) ─────────────────────────────────────── @login_required def serialized_items_list(request): """Redirige vers l'onglet 'Articles tracés' de la page entrepôts.""" from django.urls import reverse from urllib.parse import urlencode params = request.GET.copy() redirect_params = {"tab": "serialized-items"} if params.get("q"): redirect_params["si_q"] = params["q"] if params.get("product"): redirect_params["si_product"] = params["product"] if params.get("status"): redirect_params["si_status"] = params["status"] return redirect(reverse("stock:warehouses") + "?" + urlencode(redirect_params)) @login_required def serialized_item_detail(request, pk): """Détail d'un article tracé et son historique complet.""" from contracts.models import CompanyMember item = get_object_or_404( SerializedItem.objects.select_related( "product", "current_location__warehouse", "current_holder", "created_by" ), 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/serialized_item_detail.html", { "item": item, "events": events, "locations": locations, "members": members, "can_manage": user_can_manage_products(request.user), }) @login_required def serialized_item_create(request, product_id=None): """Enregistrement d'un nouvel article tracé.""" from contracts.models import CompanyMember if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'enregistrer des articles.")) return redirect("stock:serialized_items_list") initial_product = None if product_id: initial_product = get_object_or_404(Product, pk=product_id, is_serialized=True) serialized_products = Product.objects.filter(is_serialized=True).order_by("name") locations = WarehouseLocation.objects.select_related("warehouse").order_by("warehouse__name", "name") members = CompanyMember.objects.prefetch_related("teams").order_by("name") if request.method == "POST": product_pk = request.POST.get("product") serial_number = request.POST.get("serial_number", "").strip() location_pk = request.POST.get("current_location") or None holder_pk = request.POST.get("current_holder") or None notes = request.POST.get("notes", "").strip() errors = [] product = None if not product_pk: errors.append(_("Le produit est obligatoire.")) else: try: product = Product.objects.get(pk=product_pk, is_serialized=True) except Product.DoesNotExist: errors.append(_("Produit invalide.")) if not serial_number: errors.append(_("Le numéro de série est obligatoire.")) elif SerializedItem.objects.filter(serial_number=serial_number).exists(): errors.append(_("Ce numéro de série existe déjà.")) location = WarehouseLocation.objects.filter(pk=location_pk).first() if location_pk else None holder = CompanyMember.objects.filter(pk=holder_pk).first() if holder_pk else None if location and product and product.authorized_locations.exists(): if not product.authorized_locations.filter(id=location.id).exists(): errors.append(_("L'emplacement initial n'est pas autorisé pour ce produit.")) if errors: for e in errors: messages.error(request, e) return render(request, "stock/serialized_item_form.html", { "serialized_products": serialized_products, "locations": locations, "members": members, "initial_product": initial_product, "post": request.POST, }) item = SerializedItem.objects.create( product=product, serial_number=serial_number, current_location=location, current_holder=holder, notes=notes, status=SerializedItem.STATUS_ASSIGNED if holder else SerializedItem.STATUS_AVAILABLE, created_by=request.user, ) SerializedItemEvent.objects.create( item=item, event_type=SerializedItemEvent.TYPE_ENTRY, person=holder, to_location=location, notes=notes, created_by=request.user, ) _create_stock_movement_for_serialized_event( item=item, event_type=SerializedItemEvent.TYPE_ENTRY, from_location=None, to_location=location, person=holder, notes=notes or str(_('Enregistrement initial de l\'article %(sn)s') % {'sn': serial_number}), user=request.user, ) if holder: SerializedItemEvent.objects.create( item=item, event_type=SerializedItemEvent.TYPE_ASSIGNED, person=holder, to_location=location, notes=_('Attribution initiale'), created_by=request.user, ) # Pas de mouvement de stock supplémentaire pour l'attribution initiale # (l'entrée couvre déjà la mise en stock) messages.success(request, _("Article %(sn)s enregistré avec succès.") % {"sn": serial_number}) return redirect("stock:serialized_item_detail", pk=item.pk) return render(request, "stock/serialized_item_form.html", { "serialized_products": serialized_products, "locations": locations, "members": members, "initial_product": initial_product, }) @login_required @require_POST def serialized_item_event_create(request, pk): """Ajout d'un événement (attribution, retour, transfert…) sur un article tracé.""" from contracts.models import CompanyMember if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission d'enregistrer des événements.")) return redirect("stock:serialized_item_detail", pk=pk) item = get_object_or_404(SerializedItem, pk=pk) event_type = request.POST.get("event_type") valid_types = [t for t, _ in SerializedItemEvent.TYPE_CHOICES] if event_type not in valid_types: messages.error(request, _("Type d'événement invalide.")) return redirect("stock:serialized_item_detail", pk=pk) person_pk = request.POST.get("person") or None to_location_pk = request.POST.get("to_location") or None from_location_pk = request.POST.get("from_location") or None notes = request.POST.get("notes", "").strip() person = CompanyMember.objects.filter(pk=person_pk).first() if person_pk else None to_location = WarehouseLocation.objects.filter(pk=to_location_pk).first() if to_location_pk else None from_location = WarehouseLocation.objects.filter(pk=from_location_pk).first() if from_location_pk else None if item.product.authorized_locations.exists(): if to_location and not item.product.authorized_locations.filter(id=to_location.id).exists(): messages.error(request, _("L'emplacement de destination n'est pas autorisé pour ce produit.")) return redirect("stock:serialized_item_detail", pk=pk) if from_location and not item.product.authorized_locations.filter(id=from_location.id).exists(): messages.error(request, _("L'emplacement d'origine n'est pas autorisé pour ce produit.")) return redirect("stock:serialized_item_detail", pk=pk) SerializedItemEvent.objects.create( item=item, event_type=event_type, person=person, from_location=from_location or item.current_location, to_location=to_location, notes=notes, created_by=request.user, ) # Mouvement de stock automatique lié à l'événement _create_stock_movement_for_serialized_event( item=item, event_type=event_type, from_location=from_location or item.current_location, to_location=to_location, person=person, notes=notes or None, user=request.user, ) # Mettre à jour le statut et les champs courants de l'article if event_type == SerializedItemEvent.TYPE_ASSIGNED: item.status = SerializedItem.STATUS_ASSIGNED item.current_holder = person if to_location: item.current_location = to_location elif event_type == SerializedItemEvent.TYPE_RETURNED: item.status = SerializedItem.STATUS_AVAILABLE item.current_holder = None if to_location: item.current_location = to_location elif event_type == SerializedItemEvent.TYPE_TRANSFERRED: if to_location: item.current_location = to_location if person: item.current_holder = person elif event_type == SerializedItemEvent.TYPE_LOST: item.status = SerializedItem.STATUS_LOST item.current_holder = None item.current_location = None elif event_type == SerializedItemEvent.TYPE_RETIRED: item.status = SerializedItem.STATUS_RETIRED item.current_holder = None item.save() messages.success(request, _("Événement enregistré.")) next_url = request.GET.get("next", "") if next_url and next_url.startswith("/"): return redirect(next_url) return redirect("stock:serialized_item_detail", pk=pk) @login_required @require_POST def serialized_item_delete(request, pk): """ Rendre un article tracé inactif - réservé aux stock_manager et aux administrateurs d'entrepôt. """ if not user_can_manage_products(request.user): messages.error(request, _("Vous n'avez pas la permission de supprimer des articles.")) return redirect("stock:serialized_items_list") item = get_object_or_404(SerializedItem, pk=pk) sn = item.serial_number item.is_active = False item.save(update_fields=['is_active']) messages.success(request, _("Article \"%(sn)s\" rendu inactif.") % {'sn': sn}) return redirect("stock:serialized_items_list") @login_required @require_POST def preparation_order_toggle_loan_return(request, pk): from django.http import HttpResponseRedirect from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) return redirect('stock:preparation_order_detail', pk=pk) preparation_order = get_object_or_404(PreparationOrder, pk=pk) returned = request.POST.get('returned') == 'true' preparation_order.loan_returned = returned preparation_order.save(update_fields=['loan_returned']) # Synchroniser les lots sortants liés preparation_order.stock_batches.filter(movement_type=StockMovementBatch.TYPE_OUT).update(loan_returned=returned) messages.success(request, _("Statut de retour mis à jour.")) referer = request.META.get('HTTP_REFERER') if referer: return HttpResponseRedirect(referer) return redirect('stock:preparation_order_detail', pk=pk) @login_required @require_POST def stock_movement_batch_toggle_loan_return(request, pk): from django.http import HttpResponseRedirect from stock.permissions import get_user_accessible_warehouses batch = get_object_or_404(StockMovementBatch, pk=pk) # Vérification des droits 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_manage = request.user.is_superuser or manageable_warehouses.filter(id__in=batch_warehouse_ids).exists() if not can_manage: messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) return redirect('stock:stock_movement_batch_detail', pk=pk) returned = request.POST.get('returned') == 'true' batch.loan_returned = returned batch.save(update_fields=['loan_returned']) # Synchroniser le bon de préparation lié s'il existe if batch.preparation_order: batch.preparation_order.loan_returned = returned batch.preparation_order.save(update_fields=['loan_returned']) messages.success(request, _("Statut de retour mis à jour.")) referer = request.META.get('HTTP_REFERER') if referer: return HttpResponseRedirect(referer) return redirect('stock:stock_movement_batch_detail', pk=pk) @login_required @require_POST def preparation_order_update_loan_info(request, pk): from django.http import HttpResponseRedirect from common.models import UserConfig try: user_config = request.user.config can_manage = ( request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() ) except UserConfig.DoesNotExist: can_manage = False if not can_manage: messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) return redirect('stock:preparation_order_detail', pk=pk) preparation_order = get_object_or_404(PreparationOrder, pk=pk) is_loan = request.POST.get('is_loan') == 'on' estimated_return_date_str = request.POST.get('estimated_return_date', '').strip() estimated_return_date = None if estimated_return_date_str: try: from django.utils.dateparse import parse_date estimated_return_date = parse_date(estimated_return_date_str) except ValueError: pass preparation_order.is_loan = is_loan preparation_order.estimated_return_date = estimated_return_date preparation_order.save(update_fields=['is_loan', 'estimated_return_date']) # Synchroniser les lots sortants liés preparation_order.stock_batches.filter(movement_type=StockMovementBatch.TYPE_OUT).update( is_loan=is_loan, estimated_return_date=estimated_return_date ) messages.success(request, _("Informations de prêt enregistrées.")) referer = request.META.get('HTTP_REFERER') if referer: return HttpResponseRedirect(referer) return redirect('stock:preparation_order_detail', pk=pk) @login_required @require_POST def stock_movement_batch_update_loan_info(request, pk): from django.http import HttpResponseRedirect from stock.permissions import get_user_accessible_warehouses batch = get_object_or_404(StockMovementBatch, pk=pk) # Vérification des droits 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_manage = request.user.is_superuser or manageable_warehouses.filter(id__in=batch_warehouse_ids).exists() if not can_manage: messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) return redirect('stock:stock_movement_batch_detail', pk=pk) is_loan = request.POST.get('is_loan') == 'on' estimated_return_date_str = request.POST.get('estimated_return_date', '').strip() estimated_return_date = None if estimated_return_date_str: try: from django.utils.dateparse import parse_date estimated_return_date = parse_date(estimated_return_date_str) except ValueError: pass batch.is_loan = is_loan batch.estimated_return_date = estimated_return_date batch.save(update_fields=['is_loan', 'estimated_return_date']) # Synchroniser le bon de préparation lié s'il existe if batch.preparation_order: batch.preparation_order.is_loan = is_loan batch.preparation_order.estimated_return_date = estimated_return_date batch.preparation_order.save(update_fields=['is_loan', 'estimated_return_date']) messages.success(request, _("Informations de prêt enregistrées.")) referer = request.META.get('HTTP_REFERER') if referer: return HttpResponseRedirect(referer) return redirect('stock:stock_movement_batch_detail', pk=pk)