loko/loko/stock/views.py

6959 lines
289 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from decimal import Decimal
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.urls import reverse
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,
notify_supervisors_pending_product,
)
from .pdf_parser import normalize_unit
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 statut (ex: pending_validation)
status_filter = request.GET.get("status")
if status_filter:
products = products.filter(status=status_filter)
# 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,
"status": p.status,
"status_display": p.get_status_display(),
"is_pending_validation": p.is_pending_validation,
"supplier_reference": p.supplier_reference or "",
}
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 ou par intervention (code/numéro/titre).
Utilisé lors de la création d'une entrée de stock (retour).
"""
q = request.GET.get('q', '').strip()
qs = PreparationOrder.objects.select_related('intervention').all()
if q:
q_filter = (
models.Q(order_number__icontains=q) |
models.Q(intervention__code__icontains=q) |
models.Q(intervention__title__icontains=q)
)
if q.isdigit():
q_filter |= models.Q(intervention__id=int(q))
qs = qs.filter(q_filter)
qs = qs.order_by('-date')[:20]
results = []
for p in qs:
interv_info = ""
if p.intervention:
interv_code = p.intervention.code or f"#{p.intervention.id}"
interv_info = f" (Int. {interv_code})"
results.append({
'id': p.pk,
'text': f"{p.order_number}{interv_info}",
'status': p.get_status_display()
})
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.
Si le bon est lié à une intervention, les quantités à retourner sont calculées
sur base du matériel utilisé : le matériel marqué comme utilisé (is_used=True)
n'est pas réintégré (quantité 0), tandis que le matériel non utilisé (is_used=False)
est proposé au retour avec sa quantité restante.
"""
po = get_object_or_404(PreparationOrder, pk=pk)
has_intervention = bool(po.intervention_id)
executed_summary = po.get_executed_output_summary()
has_executed = any(s['out'] > 0 for s in executed_summary.values())
items = []
for item in po.preparationorderitem_set.select_related('product').all():
p_id = item.product_id
if has_executed:
prod_summary = executed_summary.get(p_id, {'out': 0, 'in': 0, 'net': 0})
base_qty = max(0, prod_summary['net'])
else:
base_qty = item.quantity
is_used = bool(item.is_used)
if has_intervention and is_used:
return_qty = 0
else:
return_qty = base_qty
items.append({
'product_id': item.product.pk,
'product_name': item.product.name,
'product_code': item.product.code or '',
'quantity': return_qty,
'initial_quantity': base_qty,
'is_used': is_used,
'unit': item.product.unit or '',
})
default_loc_id = po.get_default_return_location_id()
return JsonResponse({
'items': items,
'order_number': po.order_number,
'has_intervention': has_intervention,
'default_location_id': default_loc_id,
})
@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),
'is_default': loc.is_default,
}
for loc in locations
]
# Mettre en premier les emplacements disposant de stock pour ce produit,
# puis les emplacements par défaut, puis par nom d'entrepôt et de lieu
data.sort(key=lambda x: (-x['quantity'], not x['is_default'], x['warehouse_name'], x['name']))
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):
tab = request.GET.get('tab', 'preparation-orders').strip()
# Filtres Bons de préparation
selected_status = request.GET.get('prep_status', '').strip() or (request.GET.get('status', '').strip() if tab != 'production-orders' else '')
selected_priority = request.GET.get('priority', '').strip()
search_q = request.GET.get('prep_q', '').strip() or (request.GET.get('q', '').strip() if tab != 'production-orders' else '')
prep_order = request.GET.get('prep_order', '').strip() or (request.GET.get('order', '').strip() if tab != 'production-orders' else '')
prep_counts = PreparationOrder.objects.aggregate(
total=models.Count('id'),
to_process=models.Count('id', filter=models.Q(status='to_process')),
pending=models.Count('id', filter=models.Q(status='pending')),
processed=models.Count('id', filter=models.Q(status='processed')),
cancelled=models.Count('id', filter=models.Q(status='cancelled')),
)
prep_counts['active'] = prep_counts['to_process'] + prep_counts['pending']
preparation_orders = PreparationOrder.objects.select_related('intervention').all()
if selected_status:
if selected_status == 'active':
preparation_orders = preparation_orders.filter(status__in=['to_process', 'pending'])
else:
preparation_orders = preparation_orders.filter(status=selected_status)
if selected_priority:
preparation_orders = preparation_orders.filter(priority=selected_priority)
if search_q:
q_filter = (
models.Q(order_number__icontains=search_q) |
models.Q(intervention__code__icontains=search_q) |
models.Q(intervention__title__icontains=search_q) |
models.Q(requester__icontains=search_q)
)
if search_q.isdigit():
q_filter |= models.Q(intervention__id=int(search_q))
preparation_orders = preparation_orders.filter(q_filter)
if prep_order == 'asc':
preparation_orders = preparation_orders.order_by("date")[:1000]
else:
prep_order = 'desc'
preparation_orders = preparation_orders.order_by("-date")[:1000]
# Filtrer selon les entrepôts accessibles
accessible_warehouses = get_user_accessible_warehouses(request.user)
# Filtres Bons de confection
prod_status = request.GET.get('prod_status', '').strip() or (request.GET.get('status', '').strip() if tab == 'production-orders' else '')
prod_search_q = request.GET.get('prod_q', '').strip() or (request.GET.get('q', '').strip() if tab == 'production-orders' else '')
prod_warehouse = request.GET.get('prod_warehouse', '').strip() or (request.GET.get('warehouse', '').strip() if tab == 'production-orders' else '')
prod_destination = request.GET.get('prod_destination', '').strip() or (request.GET.get('destination', '').strip() if tab == 'production-orders' else '')
prod_order = request.GET.get('prod_order', '').strip() or (request.GET.get('order', '').strip() if tab == 'production-orders' else '')
prod_base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
# Compteurs par statut (pour les badges / filtres rapides)
prod_counts = prod_base_qs.aggregate(
total=models.Count('id'),
to_process=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_TO_PROCESS)),
in_production=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
completed=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_COMPLETED)),
draft=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_DRAFT)),
cancelled=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_CANCELLED)),
)
prod_counts['active'] = prod_counts['to_process'] + prod_counts['in_production']
production_orders = prod_base_qs.select_related(
'warehouse', 'preparation_order', 'output_product', 'output_location',
'preparation_order__intervention', 'preparation_order_other_item',
).prefetch_related(
'order_components__product',
'documents',
)
if prod_status:
if prod_status == 'active':
production_orders = production_orders.filter(
status__in=[ProductionOrder.STATUS_TO_PROCESS, ProductionOrder.STATUS_IN_PRODUCTION]
)
else:
production_orders = production_orders.filter(status=prod_status)
if prod_warehouse:
production_orders = production_orders.filter(warehouse_id=prod_warehouse)
if prod_destination:
production_orders = production_orders.filter(destination=prod_destination)
if prod_search_q:
prod_q_filter = (
models.Q(code__icontains=prod_search_q) |
models.Q(output_product__name__icontains=prod_search_q) |
models.Q(output_product_name__icontains=prod_search_q) |
models.Q(preparation_order__order_number__icontains=prod_search_q) |
models.Q(preparation_order__intervention__code__icontains=prod_search_q) |
models.Q(preparation_order__intervention__title__icontains=prod_search_q) |
models.Q(preparation_order_other_item__description__icontains=prod_search_q) |
models.Q(notes__icontains=prod_search_q) |
models.Q(order_components__product__name__icontains=prod_search_q)
)
if prod_search_q.isdigit():
prod_q_filter |= models.Q(id=int(prod_search_q)) | models.Q(preparation_order__intervention__id=int(prod_search_q))
production_orders = production_orders.filter(prod_q_filter).distinct()
if prod_order == 'asc':
production_orders = production_orders.order_by("created_at")[:1000]
else:
prod_order = 'desc'
production_orders = production_orders.order_by("-created_at")[:1000]
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,
"active_tab": tab,
"selected_status": selected_status,
"selected_priority": selected_priority,
"search_q": search_q,
"prep_order": prep_order,
"prod_status": prod_status,
"prod_search_q": prod_search_q,
"prod_warehouse": prod_warehouse,
"prod_destination": prod_destination,
"prod_order": prod_order,
"prod_status_choices": [
('active', _("À traiter & En confection")),
] + list(ProductionOrder.STATUS_CHOICES),
"prod_destination_choices": ProductionOrder.DESTINATION_CHOICES,
"prod_counts": prod_counts,
"prep_counts": prep_counts,
"accessible_warehouses": accessible_warehouses,
"preparation_order_status_choices": [
('active', _("À traiter & En attente")),
('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(
filter_locations_by_permission(
WarehouseLocation.objects.select_related("warehouse"),
request.user,
can_manage=True
).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
posted_src_ids = request.POST.getlist('source_location_id')
posted_dst_ids = request.POST.getlist('destination_location_id')
# Si des emplacements par ligne sont soumis, on les utilise en priorité
if movement_type == 'transfer':
source_ids = posted_src_ids if any(posted_src_ids) else [global_src_id] * len(product_ids)
dest_ids = posted_dst_ids if any(posted_dst_ids) else [global_dst_id] * len(product_ids)
elif movement_type == 'out':
source_ids = posted_src_ids if any(posted_src_ids) else [global_src_id] * len(product_ids)
dest_ids = [None] * len(product_ids)
elif movement_type == 'in':
source_ids = [None] * len(product_ids)
dest_ids = posted_dst_ids if any(posted_dst_ids) else [global_dst_id] * len(product_ids)
else:
source_ids = posted_src_ids
dest_ids = posted_dst_ids
serialized_ids_list = request.POST.getlist('serialized_item_ids')
serialized_new_serials_list = request.POST.getlist('serialized_new_serials')
if not movement_subtype and movement_type != 'transfer':
messages.error(request, _("Le sous-type est obligatoire."))
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':
if not src or not dst:
errors.append(_("Ligne %(n)s : emplacements source et destination requis pour un transfert.") % {'n': i + 1})
continue
if src.pk == dst.pk:
errors.append(_("Ligne %(n)s : les emplacements source et destination doivent être différents.") % {'n': i + 1})
continue
from stock.permissions import user_can_manage_warehouse, user_can_administer_warehouse
if src and not user_can_manage_warehouse(request.user, src.warehouse):
errors.append(_("Ligne %(n)s : vous n'avez pas la permission de gérer l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': src.warehouse.name})
continue
if dst and not user_can_manage_warehouse(request.user, dst.warehouse):
errors.append(_("Ligne %(n)s : vous n'avez pas la permission de gérer l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': dst.warehouse.name})
continue
if movement_subtype == 'correction':
check_loc = dst if movement_type == 'in' else src
if check_loc and not user_can_administer_warehouse(request.user, check_loc.warehouse):
errors.append(_("Ligne %(n)s : le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': check_loc.warehouse.name})
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,
'line_index': i,
})
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,
)
line_idx = line.get('line_index', 0)
# Articles tracés (numéros de série)
if product.is_serialized:
ids_str = serialized_ids_list[line_idx] if line_idx < 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[line_idx] if line_idx < 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)
preselected_po_id = request.GET.get('preparation_order')
preselected_po = None
if preselected_po_id and str(preselected_po_id).isdigit():
preselected_po = PreparationOrder.objects.filter(pk=int(preselected_po_id)).select_related('intervention').first()
type_labels = {
'in': _("Nouvelle entrée de stock"),
'out': _("Nouvelle sortie de stock"),
'transfer': _("Nouveau transfert de stock"),
}
can_administer_any = any(getattr(loc, 'can_administer', False) for loc in locations)
return render(request, template_name, {
"movement_type": movement_type,
"movement_type_label": type_labels.get(movement_type, ""),
"products": products,
"locations": locations,
"can_administer_any": can_administer_any,
"categories": categories,
"company_members": company_members,
"companies": companies,
"contracts": contracts,
"cancel_url_name": cancel_url_name,
"preselected_po": preselected_po,
})
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},
)
@login_required
def purchase_order_from_pdf(request):
"""
Création d'un bon de commande à partir d'une offre ou devis au format PDF.
Étape 1 : Téléversement du document PDF et choix de l'entrepôt.
Étape 2 : Revue interactive des données extraites (fournisseur, articles, prix).
Pour les produits non trouvés, le magasinier confirme s'il s'agit d'un
nouveau produit (qui sera créé avec le statut particulier 'pending_validation'
pour approbation ultérieure par un superviseur) ou l'associe à un produit existant.
"""
import os
import tempfile
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)
warehouses = Warehouse.objects.filter(id__in=accessible_warehouses).order_by('name')
if not warehouses.exists():
messages.error(request, _("Aucun entrepôt accessible pour créer une commande."))
return redirect('stock:stock_purchases')
step = 'upload'
parse_result = None
warehouse = None
temp_pdf_path = ''
orig_pdf_name = ''
if request.method == 'POST':
action = request.POST.get('action', '')
if action == 'parse_pdf':
pdf_file = request.FILES.get('pdf_file')
warehouse_id = request.POST.get('warehouse')
if not pdf_file:
messages.error(request, _("Veuillez sélectionner un fichier PDF."))
elif not pdf_file.name.lower().endswith('.pdf'):
messages.error(request, _("Le fichier doit obligatoirement être au format PDF."))
else:
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
if not warehouse:
warehouse = warehouses.first()
# Sauvegarder dans un fichier temporaire pour pouvoir l'attacher après validation
fd, tmp_path = tempfile.mkstemp(prefix='loko_quote_', suffix='.pdf')
with os.fdopen(fd, 'wb') as f:
for chunk in pdf_file.chunks():
f.write(chunk)
temp_pdf_path = tmp_path
orig_pdf_name = pdf_file.name
try:
from .pdf_parser import PdfQuoteParser
parser = PdfQuoteParser(temp_pdf_path)
parse_result = parser.parse()
step = 'review'
if parse_result.get('is_ocr'):
messages.info(request, _("Le document a été analysé via le moteur OCR local (document scanné ou image)."))
else:
messages.success(request, _("Le devis a été analysé avec succès. Veuillez vérifier et valider les articles ci-dessous."))
except Exception as e:
import logging
logging.getLogger(__name__).exception("Erreur lors de l'analyse du devis PDF")
messages.error(request, _("Erreur lors de l'analyse du PDF : %(err)s") % {'err': str(e)})
if temp_pdf_path and os.path.exists(temp_pdf_path):
try:
os.remove(temp_pdf_path)
except OSError:
pass
temp_pdf_path = ''
step = 'upload'
elif action == 'confirm_order':
warehouse_id = request.POST.get('warehouse_id')
warehouse = Warehouse.objects.filter(id__in=accessible_warehouses, pk=warehouse_id).first()
if not warehouse:
messages.error(request, _("Entrepôt invalide ou non autorisé."))
return redirect('stock:stock_purchases')
temp_pdf_path = request.POST.get('temp_pdf_path', '')
orig_pdf_name = request.POST.get('orig_pdf_name', '')
# Résolution ou création du Fournisseur
supplier_choice = request.POST.get('supplier_choice', 'existing')
supplier = None
if supplier_choice == 'existing':
supplier_id = request.POST.get('supplier_id')
supplier = Supplier.objects.filter(pk=supplier_id, is_active=True).first()
if not supplier:
new_sup_name = request.POST.get('new_supplier_name', '').strip()
if not new_sup_name:
new_sup_name = _("Fournisseur sans nom")
supplier = Supplier.objects.create(
name=new_sup_name,
enterprise_number=request.POST.get('new_supplier_vat', '').strip() or None,
contact_email=request.POST.get('new_supplier_email', '').strip() or None,
phone_number=request.POST.get('new_supplier_phone', '').strip() or None,
address=request.POST.get('new_supplier_address', '').strip() or None,
is_active=True,
)
messages.info(request, _("Nouveau fournisseur créé : %(name)s") % {'name': supplier.name})
try:
item_count = int(request.POST.get('item_count', 0))
except (ValueError, TypeError):
item_count = 0
with transaction.atomic():
order_status = request.POST.get('order_status', 'pending')
order = PurchaseOrder.objects.create(
supplier=supplier,
warehouse=warehouse,
status=order_status,
)
new_products_created = []
created_items_count = 0
base_product_num = _next_product_number()
new_prod_offset = 0
for i in range(item_count):
# Vérifier si la ligne est sélectionnée
if not request.POST.get(f'item_{i}_include'):
continue
ref = request.POST.get(f'item_{i}_ref', '').strip()
name = request.POST.get(f'item_{i}_name', '').strip()
if not name:
name = ref or _("Article sans nom")
try:
qty = int(float(request.POST.get(f'item_{i}_qty', 1)))
except (ValueError, TypeError):
qty = 1
if qty <= 0:
qty = 1
raw_unit = request.POST.get(f'item_{i}_unit', Product.UNIT_PC)
unit = normalize_unit(raw_unit)
try:
unit_price = Decimal(str(request.POST.get(f'item_{i}_price', '0')).replace(',', '.'))
except Exception:
unit_price = Decimal('0.00')
try:
vat_rate = Decimal(str(request.POST.get(f'item_{i}_vat', '21')).replace(',', '.'))
except Exception:
vat_rate = Decimal('21.00')
location_id = request.POST.get(f'item_{i}_location')
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
if not location:
location = warehouse.locations.filter(is_default=True).first() or warehouse.locations.first()
product_action = request.POST.get(f'item_{i}_product_action', 'existing')
product = None
if product_action == 'existing':
prod_id = request.POST.get(f'item_{i}_product_id')
product = Product.objects.filter(pk=prod_id, is_active=True).first()
if not product:
# Le magasinier a validé la création d'un nouveau produit
# Le produit est créé avec le statut particulier STATUS_PENDING_VALIDATION
curr_num = base_product_num + new_prod_offset
new_prod_offset += 1
code = f"PROD_{curr_num}"
sku = str(curr_num)
category_id = request.POST.get(f'item_{i}_category') or None
product = Product.objects.create(
code=code,
sku=sku,
name=name,
category_id=category_id,
price=unit_price if unit_price > 0 else None,
unit=unit,
threshold=10,
supplier_reference=ref or None,
status=Product.STATUS_PENDING_VALIDATION, # Statut particulier !
created_by=request.user,
is_active=True,
)
new_products_created.append(product)
# Notifier les superviseurs
notify_supervisors_pending_product(product, created_by=request.user)
# Créer la ligne de commande
PurchaseOrderItem.objects.create(
purchase_order=order,
product=product,
location=location,
quantity=qty,
unit_price=unit_price,
vat_rate=vat_rate,
)
created_items_count += 1
# Attacher le fichier PDF original comme document justificatif
if temp_pdf_path and os.path.exists(temp_pdf_path):
try:
from django.core.files.base import ContentFile
with open(temp_pdf_path, 'rb') as f:
doc_title = orig_pdf_name or f"Offre_{order.code}.pdf"
po_doc = PurchaseOrderDocument(
purchase_order=order,
name=doc_title,
)
po_doc.file.save(doc_title, ContentFile(f.read()), save=True)
os.remove(temp_pdf_path)
except Exception:
import logging
logging.getLogger(__name__).exception("Erreur lors de l'attachement du PDF au bon de commande")
messages.success(
request,
_("Le bon de commande %(code)s a été créé avec succès (%(items)d article(s)).")
% {'code': order.code, 'items': created_items_count}
)
if new_products_created:
messages.warning(
request,
_("%(count)d nouveau(x) produit(s) ont été créé(s) avec le statut 'En attente de validation superviseur' : %(prods)s. Les superviseurs ont été notifiés.")
% {
'count': len(new_products_created),
'prods': ', '.join(p.name for p in new_products_created[:3]) + ('...' if len(new_products_created) > 3 else '')
}
)
return redirect('stock:purchase_order_detail', pk=order.pk)
suppliers = Supplier.objects.filter(is_active=True).order_by('name')
categories = ProductCategory.objects.order_by('name_fr')
products = Product.objects.filter(is_active=True).order_by('name')
locations = []
default_location = None
if warehouse:
locations = warehouse.locations.all().order_by('name')
default_location = warehouse.locations.filter(is_default=True).first() or locations.first()
return render(request, 'stock/purchase_order_from_pdf.html', {
'step': step,
'warehouses': warehouses,
'selected_warehouse': warehouse,
'locations': locations,
'default_location': default_location,
'suppliers': suppliers,
'categories': categories,
'products': products,
'parse_result': parse_result,
'temp_pdf_path': temp_pdf_path,
'orig_pdf_name': orig_pdf_name,
'cancel_url': reverse('stock:stock_purchases'),
})
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(commit=False)
inventory._current_user = request.user
inventory.save()
form.save_m2m()
formset.instance = inventory
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(commit=False)
inventory._current_user = request.user
inventory.save()
form.save_m2m()
formset.instance = inventory
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._current_user = request.user
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(commit=False)
if not preparation_order.order_number:
preparation_order.order_number = PreparationOrder.generate_order_number(intervention=None)
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,
)
preparation_order.log_history(
action="created",
description=_("Création du bon de préparation"),
user=request.user,
details={'order_number': preparation_order.order_number},
)
messages.success(request, _("Commande de préparation enregistrée."))
return redirect("stock:stock_movements")
else:
order_number = PreparationOrder.generate_order_number(intervention=None)
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
variant = request.GET.get('v') or request.POST.get('v')
redirect_target = "mobile:intervention_detail_mobile" if variant == 'mobile' else "interventions:intervention_detail"
# Vérifier les permissions
try:
user_config = request.user.config
except UserConfig.DoesNotExist:
messages.error(request, _("Configuration utilisateur non trouvée."))
return redirect(redirect_target, intervention_id=intervention_id)
intervention = get_object_or_404(Intervention, pk=intervention_id)
if intervention.status == 'canceled':
messages.error(request, _("Impossible de créer un bon de préparation pour une intervention annulée."))
return redirect(redirect_target, intervention_id=intervention_id)
can_create_from_intervention = bool(
request.user.is_superuser
or 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(redirect_target, 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
if not preparation_order.order_number:
preparation_order.order_number = PreparationOrder.generate_order_number(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,
)
interv_ref = intervention.code or f"#{intervention.id}"
preparation_order.log_history(
action="created",
description=_("Création du bon de préparation depuis l'intervention #{intervention_id}").format(
intervention_id=interv_ref
),
user=request.user,
details={'order_number': preparation_order.order_number, 'intervention_id': intervention.id, 'intervention_code': intervention.code},
)
messages.success(request, _("Bon de préparation créé avec succès."))
return redirect(redirect_target, intervention_id=intervention_id)
else:
# Générer un numéro de bon automatique
order_number = PreparationOrder.generate_order_number(intervention=intervention)
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,
"variant": variant,
},
)
@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,
"can_validate": is_stock_manager and product.is_pending_validation,
}
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'):
doc = PreparationOrderDocument.objects.create(
preparation_order=preparation_order,
file=f,
name=f.name,
)
preparation_order.log_history(
action="document_added",
description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()),
user=request.user,
details={'document_id': doc.pk, 'filename': doc.filename()},
)
messages.success(request, _("Document(s) ajouté(s) avec succès."))
return redirect('stock:preparation_order_detail', pk=pk)
# Calculer les informations sur les produits
executed_summary = preparation_order.get_executed_output_summary()
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()}
has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values())
is_reopened = preparation_order.status == 'to_process' and has_executed_outputs
# S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon
for p_id, loc_dict in executed_by_loc.items():
executed_qty = sum(loc_dict.values())
if executed_qty > 0:
PreparationOrderItem.objects.get_or_create(
preparation_order=preparation_order,
product_id=p_id,
defaults={'quantity': 0}
)
items_with_stock = []
for item in preparation_order.preparationorderitem_set.filter(quantity__gt=0):
product = item.product
raw_available = product.total_stock
reserved_stock = product.reserved_quantity
available_stock = raw_available
prod_order = item.production_orders.first()
prod_summary = executed_summary.get(product.id, {'out': 0, 'in': 0, 'net': 0})
executed_qty = prod_summary['net']
gross_out = prod_summary['out']
gross_in = prod_summary['in']
delta = item.quantity - executed_qty
items_with_stock.append({
'item': item,
'available_stock': available_stock,
'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,
'executed_qty': executed_qty,
'gross_out': gross_out,
'gross_in': gross_in,
'delta': delta,
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse')
})
# Produits supprimés du bon qui avaient été sortis précédemment (quantity == 0)
deleted_items_to_return = []
for item in preparation_order.preparationorderitem_set.filter(quantity=0):
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > 0:
product = item.product
loc_dict = executed_by_loc.get(product.id, {})
loc_names = []
for l_id, q in loc_dict.items():
loc_obj = WarehouseLocation.objects.filter(pk=l_id).select_related('warehouse').first()
if loc_obj:
loc_names.append(f"{loc_obj.name} ({loc_obj.warehouse.name}): {q}")
deleted_items_to_return.append({
'item': item,
'product': product,
'executed_qty': executed_qty,
'location_info': ", ".join(loc_names),
})
# Vérifier les permissions
from stock.permissions import user_can_manage_preparation_orders, user_can_edit_preparation_order
from common.models import UserConfig
try:
user_config = request.user.config
can_manage = user_can_manage_preparation_orders(request.user)
can_edit = user_can_edit_preparation_order(request.user, preparation_order)
can_cancel = can_manage and preparation_order.status != 'cancelled'
can_manage_purchase = user_config.can_manage_purchase_orders_any()
can_manage_stock = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.has_role('stock_manager')
or user_config.get_accessible_warehouses(can_manage=True).exists()
)
except UserConfig.DoesNotExist:
can_manage = False
can_edit = False
can_cancel = False
can_manage_purchase = False
can_manage_stock = False
history_entries = preparation_order.history_entries.select_related('user').order_by('-created_at')
has_pending_stock_adjustments = any(it['delta'] != 0 for it in items_with_stock) or bool(deleted_items_to_return)
# 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')
# Discussion liée à ce bon de préparation
discussion = None
discussion_messages = []
can_post_discussion_message = False
try:
from notifications.models import Discussion
from notifications.services import mark_discussion_seen, can_post_preparation_order_message
from django.contrib.contenttypes.models import ContentType as CT
_ct = CT.objects.get_for_model(preparation_order)
discussion = Discussion.objects.filter(
content_type=_ct, object_id=preparation_order.pk
).first()
if discussion:
discussion_messages = list(
discussion.messages.filter(is_deleted=False)
.select_related('author')
.prefetch_related('attachments')
.order_by('created_at')
)
mark_discussion_seen(discussion, user=request.user)
can_post_discussion_message = can_post_preparation_order_message(request.user, preparation_order)
except Exception:
pass
context = {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
'other_items': other_items_with_info,
'can_manage': can_manage,
'can_edit': can_edit,
'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase,
'can_manage_stock': can_manage_stock,
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
'exit_batches': exit_batches,
'return_batches': return_batches,
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
'history_entries': history_entries,
'has_pending_stock_adjustments': has_pending_stock_adjustments,
'discussion': discussion,
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,
}
return render(request, 'stock/preparation_order_detail.html', context)
@require_POST
def preparation_order_add_message(request, pk):
"""
Poste un message dans la discussion liée à ce bon de préparation.
"""
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
from notifications.services import (
can_post_preparation_order_message,
get_or_create_preparation_order_discussion,
post_message,
)
if not can_post_preparation_order_message(request.user, preparation_order):
messages.error(request, _("Vous n'avez pas la permission de poster un message sur ce bon de préparation."))
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
return redirect(next_url)
content = (request.POST.get('content') or '').strip()
if not content:
messages.warning(request, _("Le message ne peut pas être vide."))
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
if '#discussion' not in next_url:
next_url += '#discussion'
return redirect(next_url)
discussion, _ = get_or_create_preparation_order_discussion(preparation_order)
files = request.FILES.getlist('attachments') or None
post_message(discussion=discussion, author=request.user, content=content, files=files)
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
if '#discussion' not in next_url:
next_url += '#discussion'
return redirect(next_url)
@require_POST
def preparation_order_cancel(request, pk):
"""
Annule un bon de préparation et libère les quantités réservées.
Si le bon avait déjà fait l'objet de sorties de stock, réintègre l'ensemble des produits sortis.
"""
from stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
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', 'processed']:
with transaction.atomic():
# Restituer le stock si des sorties avaient été exécutées
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
if executed_by_loc:
batch = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_IN,
movement_subtype='return',
reason=_("Restitution suite à l'annulation du bon de préparation #{}").format(preparation_order.order_number),
preparation_order=preparation_order,
created_by=request.user,
)
for prod_id, loc_dict in executed_by_loc.items():
product = Product.objects.get(pk=prod_id)
for loc_id, qty in loc_dict.items():
if qty > 0:
loc = WarehouseLocation.objects.get(pk=loc_id)
stock, _created = Stock.objects.select_for_update().get_or_create(
product=product,
location=loc,
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=product,
source_location=None,
destination_location=loc,
quantity=qty,
reason=_("Restitution suite à l'annulation du bon #{}").format(preparation_order.order_number),
batch=batch,
)
stock.quantity += qty
stock.save()
preparation_order.cancel()
preparation_order.log_history(
action="cancelled",
description=_("Annulation du bon de préparation (quantités réservées libérées, stock restitué le cas échéant)"),
user=request.user,
details={'status': 'cancelled'},
)
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é."))
is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '')
detail_url_name = 'mobile:stock_preparation_order_detail_mobile' if is_mobile else 'stock:preparation_order_detail'
next_url = request.POST.get('next')
if next_url:
return redirect(next_url)
return redirect(detail_url_name, pk=pk)
@require_POST
def preparation_order_reopen(request, pk):
"""
Réouvre un bon de préparation traité pour permettre sa modification (statut : to_process).
"""
is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '')
detail_url_name = 'mobile:stock_preparation_order_detail_mobile' if is_mobile else 'stock:preparation_order_detail'
from stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
messages.error(request, _("Vous n'avez pas la permission de réouvrir des bons de préparation."))
return redirect(detail_url_name, pk=pk)
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if preparation_order.status == 'processed':
preparation_order.reopen()
preparation_order.log_history(
action="reopened",
description=_("Réouverture du bon de préparation (statut : À traiter)"),
user=request.user,
details={'status': 'to_process'},
)
messages.success(request, _("Le bon de préparation #{} a été réouvert (statut : À traiter).").format(preparation_order.order_number))
return redirect(f"{reverse(detail_url_name, args=[pk])}?open_edit=1")
else:
messages.error(request, _("Ce bon ne peut être réouvert que s'il est au statut Traité."))
return redirect(detail_url_name, 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 (y compris magasiniers).
Possible aux statuts 'to_process', 'pending' ou 'processed'.
"""
from stock.permissions import user_can_edit_preparation_order
from django.http import JsonResponse
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if not user_can_edit_preparation_order(request.user, preparation_order):
return JsonResponse({'error': _("Droits insuffisants ou bon non modifiable.")}, status=403)
if preparation_order.status not in ['to_process', 'pending', 'processed']:
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()
preparation_order.log_history(
action="item_added",
description=_("Ajout de {qty} × {product} (nouvelle quantité : {total})").format(
qty=quantity, product=product.name, total=item.quantity
),
user=request.user,
details={'product_id': product.pk, 'product_name': product.name, 'quantity_added': quantity, 'new_quantity': item.quantity},
)
else:
preparation_order.log_history(
action="item_added",
description=_("Ajout du produit {product} (quantité : {qty})").format(
product=product.name, qty=quantity
),
user=request.user,
details={'product_id': product.pk, 'product_name': product.name, 'quantity': quantity},
)
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'])
if quantity != old_quantity:
preparation_order.log_history(
action="quantity_changed",
description=_("Quantité de {product} modifiée de {old_qty} à {new_qty}").format(
product=item.product.name, old_qty=old_quantity, new_qty=quantity
),
user=request.user,
details={
'item_id': item.pk,
'product_id': item.product.pk,
'product_name': item.product.name,
'old_quantity': old_quantity,
'new_quantity': quantity,
},
)
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)
prod_name = item.product.name
prod_id = item.product.pk
removed_qty = item.quantity
# 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()
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_qty = sum(executed_by_loc.get(item.product_id, {}).values())
if executed_qty > 0:
item.quantity = 0
item.is_prepared = False
item.save()
else:
item.delete()
preparation_order.log_history(
action="item_removed",
description=_("Suppression du produit {product} (quantité initiale : {qty})").format(
product=prod_name, qty=removed_qty
),
user=request.user,
details={'product_id': prod_id, 'product_name': prod_name, 'quantity': removed_qty},
)
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,
)
preparation_order.log_history(
action="other_item_added",
description=_("Ajout d'article personnalisé : {desc} (quantité : {qty})").format(
desc=description, qty=quantity
),
user=request.user,
details={'description': description, 'quantity': quantity},
)
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)
old_description = oi.description
old_qty = oi.quantity
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'])
if old_description != oi.description or old_qty != oi.quantity:
preparation_order.log_history(
action="other_item_updated",
description=_("Modification d'article personnalisé : {desc} (quantité : {qty})").format(
desc=oi.description, qty=oi.quantity
),
user=request.user,
details={
'item_id': oi.pk,
'old_description': old_description,
'new_description': oi.description,
'old_quantity': old_qty,
'new_quantity': oi.quantity,
},
)
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)
oi_desc = oi.description
oi_qty = oi.quantity
# 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()
preparation_order.log_history(
action="other_item_removed",
description=_("Suppression de l'article personnalisé : {desc} (quantité : {qty})").format(
desc=oi_desc, qty=oi_qty
),
user=request.user,
details={'description': oi_desc, 'quantity': oi_qty},
)
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.
Accessible également au statut 'processed' pour régulariser les stocks après modification.
"""
# Vérifier les permissions
from stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
messages.error(request, _("Vous n'avez pas la permission de traiter des bons de préparation."))
return redirect('stock:stock_movements')
user_config = getattr(request.user, 'config', None)
# Récupérer les entrepôts autorisés pour l'utilisateur
if request.user.is_superuser or (user_config and user_config.has_role('admin')):
allowed_locations = WarehouseLocation.objects.select_related('warehouse').all()
elif user_config:
user_warehouses = user_config.userwarehouses.filter(can_manage_preparation_orders=True).values_list('warehouse_id', flat=True)
if user_config.has_role('stock_manager') and not user_warehouses.exists():
allowed_locations = WarehouseLocation.objects.select_related('warehouse').all()
else:
allowed_locations = WarehouseLocation.objects.filter(
warehouse_id__in=user_warehouses
).select_related('warehouse')
else:
allowed_locations = WarehouseLocation.objects.none()
preparation_order = get_object_or_404(
PreparationOrder.objects.prefetch_related('preparationorderitem_set__product'),
pk=pk
)
if preparation_order.status not in ['to_process', 'pending', 'processed']:
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:
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()}
# Si products_to_move est vide (ex: confirmation directe depuis la page de détail),
# déterminer automatiquement les quantités et emplacements pour chaque produit demandé
if not products_to_move:
for item in preparation_order.preparationorderitem_set.filter(quantity__gt=0):
executed_qty = executed_totals.get(item.product_id, 0)
needed_out = item.quantity - executed_qty
if needed_out > 0:
stock_loc = Stock.objects.filter(
product=item.product,
location__in=allowed_locations,
quantity__gt=0
).select_related('location').order_by('-quantity').first()
if not stock_loc:
stock_loc = Stock.objects.filter(
product=item.product,
location__in=allowed_locations
).select_related('location').first()
location = stock_loc.location if stock_loc else allowed_locations.first()
if location:
products_to_move.append({
'item': item,
'quantity': needed_out,
'location': location,
})
with transaction.atomic():
# 1. Sorties complémentaires (si products_to_move contient des mouvements)
if products_to_move:
batch_out = 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_out,
)
stock.quantity -= move['quantity']
stock.save()
# 2. Restitutions automatiques (baisse de quantité ou suppression d'article)
returns_to_execute = []
current_items = preparation_order.preparationorderitem_set.all()
current_product_ids = set(current_items.values_list('product_id', flat=True))
for item in current_items:
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > item.quantity:
needed_return = executed_qty - item.quantity
loc_dict = executed_by_loc.get(item.product_id, {})
for loc_id, past_qty in loc_dict.items():
if needed_return <= 0:
break
to_return = min(needed_return, past_qty)
returns_to_execute.append({
'product_id': item.product_id,
'location_id': loc_id,
'quantity': to_return,
})
needed_return -= to_return
for p_id, loc_dict in executed_by_loc.items():
if p_id not in current_product_ids:
for loc_id, past_qty in loc_dict.items():
if past_qty > 0:
returns_to_execute.append({
'product_id': p_id,
'location_id': loc_id,
'quantity': past_qty,
})
if returns_to_execute:
batch_in = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_IN,
movement_subtype='return',
reason=_("Restitution suite à modification du bon de préparation #{}").format(preparation_order.order_number),
preparation_order=preparation_order,
created_by=request.user,
)
for ret in returns_to_execute:
product = Product.objects.get(pk=ret['product_id'])
location = WarehouseLocation.objects.get(pk=ret['location_id'])
stock, _created = Stock.objects.select_for_update().get_or_create(
product=product,
location=location,
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=product,
source_location=None,
destination_location=location,
quantity=ret['quantity'],
reason=_("Restitution suite à modification du bon #{}").format(preparation_order.order_number),
batch=batch_in,
)
stock.quantity += ret['quantity']
stock.save()
# Marquer comme traité
preparation_order.mark_as_processed()
preparation_order.log_history(
action="processed",
description=_("Traitement du bon de préparation (mouvements de stock appliqués)"),
user=request.user,
details={'status': 'processed'},
)
messages.success(request, _("Le bon de préparation #{} a été traité avec succès.").format(
preparation_order.order_number
))
next_url = request.POST.get('next') or request.META.get('HTTP_REFERER', '')
if 'mobile' in str(next_url):
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
return redirect('stock:preparation_order_detail', pk=pk)
# Préparer les données pour le formulaire
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()}
# S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon
for p_id, loc_dict in executed_by_loc.items():
executed_qty = sum(loc_dict.values())
if executed_qty > 0:
PreparationOrderItem.objects.get_or_create(
preparation_order=preparation_order,
product_id=p_id,
defaults={'quantity': 0}
)
items_with_locations = []
current_items = preparation_order.preparationorderitem_set.filter(quantity__gt=0)
for item in current_items:
# 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()
executed_qty = executed_totals.get(item.product_id, 0)
delta = item.quantity - executed_qty
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,
'executed_qty': executed_qty,
'delta': delta,
'suggested_quantity': max(0, delta),
})
# Produits supprimés du bon qui avaient été sortis précédemment (quantity == 0)
deleted_items_to_return = []
for item in preparation_order.preparationorderitem_set.filter(quantity=0):
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > 0:
product = item.product
loc_dict = executed_by_loc.get(product.id, {})
loc_names = []
for l_id, q in loc_dict.items():
loc_obj = WarehouseLocation.objects.filter(pk=l_id).select_related('warehouse').first()
if loc_obj:
loc_names.append(f"{loc_obj.name} ({loc_obj.warehouse.name}): {q}")
deleted_items_to_return.append({
'item': item,
'product': product,
'executed_qty': executed_qty,
'location_info': ", ".join(loc_names),
})
# 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,
'deleted_items_to_return': deleted_items_to_return,
}
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 stock.permissions import user_can_edit_preparation_order
from common.private_files.helpers import build_private_url
from django.urls import reverse
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if not user_can_edit_preparation_order(request.user, preparation_order):
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 = PreparationOrderDocument.objects.create(
preparation_order=preparation_order,
file=f,
name=f.name,
)
preparation_order.log_history(
action="document_added",
description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()),
user=request.user,
details={'document_id': doc.pk, 'filename': doc.filename()},
)
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 stock.permissions import user_can_edit_preparation_order
doc = get_object_or_404(PreparationOrderDocument, pk=doc_id)
po = doc.preparation_order
if not user_can_edit_preparation_order(request.user, po):
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
doc_name = doc.name or doc.filename()
doc.delete()
po.log_history(
action="document_removed",
description=_("Suppression du document joint : {name}").format(name=doc_name),
user=request.user,
details={'document_id': doc_id, 'filename': doc_name},
)
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,
)
production_order.log_history(
action="document_added",
description=_("Ajout de document : {}").format(doc.filename()),
user=request.user,
)
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_filename = doc.filename()
order = doc.production_order
doc.delete()
order.log_history(
action="document_removed",
description=_("Suppression du document : {}").format(doc_filename),
user=request.user,
)
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')
@login_required
@require_POST
def product_validate(request, product_id):
"""
Valide un produit créé par un magasinier (en attente de validation superviseur).
Réservé aux superviseurs et gestionnaires de stock.
"""
if not user_can_manage_products(request.user):
messages.error(request, _("Vous n'avez pas la permission de valider ce produit."))
return redirect('stock:product_detail', product_id=product_id)
product = get_object_or_404(Product, id=product_id)
comment = request.POST.get('comment', '').strip()
product.validate_product(request.user, comment=comment)
messages.success(request, _("Le produit \"%(name)s\" a été validé avec succès.") % {'name': product.name})
return redirect('stock:product_detail', product_id=product.pk)
@login_required
@require_POST
def product_reject(request, product_id):
"""
Rejette un produit créé par un magasinier (en attente de validation superviseur).
Réservé aux superviseurs et gestionnaires de stock.
"""
if not user_can_manage_products(request.user):
messages.error(request, _("Vous n'avez pas la permission de rejeter ce produit."))
return redirect('stock:product_detail', product_id=product_id)
product = get_object_or_404(Product, id=product_id)
comment = request.POST.get('comment', '').strip()
product.reject_product(request.user, comment=comment)
messages.warning(request, _("Le produit \"%(name)s\" a été rejeté et désactivé.") % {'name': product.name})
return redirect('stock:product_detail', product_id=product.pk)
# =============================================
# 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
url = reverse('stock:stock_movements') + '?tab=production-orders'
params = request.GET.copy()
if 'tab' in params:
del params['tab']
if params:
url += '&' + params.urlencode()
return redirect(url)
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()
order.log_history(
action="created",
description=_("Création du bon de confection (quantité : {})").format(order.quantity),
user=request.user,
details={'quantity': order.quantity, 'warehouse': order.warehouse.name},
)
for f in request.FILES.getlist('new_documents'):
doc = ProductionOrderDocument.objects.create(
production_order=order,
file=f,
name=f.name,
)
order.log_history(
action="document_added",
description=_("Ajout de document : {}").format(doc.filename()),
user=request.user,
)
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,
'history_entries': order.history_entries.select_related('user').order_by('-created_at'),
'is_reopened': order.is_reopened,
}
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 order.status == ProductionOrder.STATUS_COMPLETED:
messages.warning(request, _("Ce bon est terminé. Vous devez le réouvrir avant de pouvoir le modifier."))
return redirect('stock:production_order_detail', pk=pk)
if request.method == 'POST':
old_quantity = order.quantity
form = ProductionOrderForm(request.POST, request.FILES, instance=order)
formset = ProductionOrderComponentFormSet(request.POST, instance=order, prefix='components')
if form.is_valid() and formset.is_valid():
saved_order = form.save()
formset.save()
for f in request.FILES.getlist('new_documents'):
doc = ProductionOrderDocument.objects.create(
production_order=order,
file=f,
name=f.name,
)
order.log_history(
action="document_added",
description=_("Ajout de document : {}").format(doc.filename()),
user=request.user,
)
# Tracer les modifications dans l'historique
changes = []
if old_quantity != saved_order.quantity:
changes.append(_("Quantité modifiée : {old} -> {new}").format(old=old_quantity, new=saved_order.quantity))
if formset.new_objects:
for obj in formset.new_objects:
changes.append(_("Composant ajouté : {qty} × {name}").format(qty=obj.quantity, name=obj.product.name))
if formset.changed_objects:
for obj, changed_fields in formset.changed_objects:
changes.append(_("Composant modifié : {name} (qté : {qty})").format(name=obj.product.name, qty=obj.quantity))
if formset.deleted_objects:
for obj in formset.deleted_objects:
changes.append(_("Composant supprimé : {name}").format(name=obj.product.name))
if changes:
order.log_history(
action="component_updated" if any("Composant" in c for c in changes) else "quantity_changed",
description=" ; ".join(changes),
user=request.user,
)
else:
order.log_history(
action="other",
description=_("Modification du bon de confection"),
user=request.user,
)
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'])
order.log_history(
action="status_changed",
description=_("Démarrage de la confection (statut : En confection)"),
user=request.user,
details={'status': ProductionOrder.STATUS_IN_PRODUCTION},
)
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(user=request.user)
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_TO_PROCESS, ProductionOrder.STATUS_IN_PRODUCTION]:
order.cancel()
order.log_history(
action="cancelled",
description=_("Annulation du bon de confection"),
user=request.user,
details={'status': ProductionOrder.STATUS_CANCELLED},
)
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)
@require_POST
def production_order_reopen(request, pk):
"""
Réouvre un bon de confection terminé pour permettre sa modification (statut : in_production).
Restitue les composants consommés et annule l'entrée du produit fini.
"""
is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '')
detail_url_name = 'mobile:stock_production_order_detail_mobile' if is_mobile else 'stock:production_order_detail'
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 de réouvrir ce bon de confection."))
return redirect(detail_url_name, pk=pk)
if order.status == ProductionOrder.STATUS_COMPLETED:
order.reopen(user=request.user)
messages.success(
request,
_("Le bon de confection {} a été réouvert (statut : En confection). Les stocks ont été régularisés pour permettre la correction.").format(order.code)
)
else:
messages.error(request, _("Ce bon ne peut être réouvert que s'il est au statut Terminé."))
next_url = request.POST.get('next')
if next_url:
return redirect(next_url)
return redirect(detail_url_name, 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 stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
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)
status_label = _("Retourné") if returned else _("Non retourné")
preparation_order.log_history(
action="loan_updated",
description=_("Statut de retour du prêt modifié : {status}").format(status=status_label),
user=request.user,
details={'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'])
batch.preparation_order.log_history(
action="loan_updated",
description=_("Statut de retour du prêt synchronisé : {status}").format(
status=_("Retourné") if returned else _("Non retourné")
),
user=request.user,
details={'loan_returned': returned, 'batch_id': batch.pk},
)
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 stock.permissions import user_can_manage_preparation_orders
if not user_can_manage_preparation_orders(request.user):
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
)
loan_desc = _("Prêt activé (retour prévu : {date})").format(date=estimated_return_date_str or _("non défini")) if is_loan else _("Prêt désactivé")
preparation_order.log_history(
action="loan_updated",
description=_("Informations de prêt modifiées : {desc}").format(desc=loan_desc),
user=request.user,
details={'is_loan': is_loan, 'estimated_return_date': estimated_return_date_str},
)
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)