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