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