feat: add stock management module including views, models, templates, and tests

This commit is contained in:
Karl Determe 2026-09-29 17:07:29 +02:00
parent 001bee8ca9
commit e00f9cf784
6 changed files with 653 additions and 92 deletions

View file

@ -485,13 +485,67 @@ 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
from django.db import models
search_q = request.GET.get('q', '').strip() or request.GET.get('prod_q', '').strip()
selected_status = request.GET.get('status', '').strip() or request.GET.get('prod_status', '').strip()
selected_warehouse = request.GET.get('warehouse', '').strip() or request.GET.get('prod_warehouse', '').strip()
selected_destination = request.GET.get('destination', '').strip() or request.GET.get('prod_destination', '').strip()
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]
base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
# Compteurs par statut (pour les filtres rapides mobiles)
prod_counts = base_qs.aggregate(
total=models.Count('id'),
to_process=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_TO_PROCESS)),
in_production=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
completed=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_COMPLETED)),
draft=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_DRAFT)),
cancelled=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_CANCELLED)),
)
production_orders = base_qs.select_related(
'warehouse', 'preparation_order', 'output_product', 'output_location',
'preparation_order__intervention', 'preparation_order_other_item',
).prefetch_related(
'order_components__product',
)
if selected_status:
production_orders = production_orders.filter(status=selected_status)
if selected_warehouse:
production_orders = production_orders.filter(warehouse_id=selected_warehouse)
if selected_destination:
production_orders = production_orders.filter(destination=selected_destination)
if search_q:
q_filter = (
models.Q(code__icontains=search_q) |
models.Q(output_product__name__icontains=search_q) |
models.Q(output_product_name__icontains=search_q) |
models.Q(preparation_order__order_number__icontains=search_q) |
models.Q(preparation_order__intervention__code__icontains=search_q) |
models.Q(preparation_order__intervention__title__icontains=search_q) |
models.Q(preparation_order_other_item__description__icontains=search_q) |
models.Q(notes__icontains=search_q) |
models.Q(order_components__product__name__icontains=search_q)
)
if search_q.isdigit():
q_filter |= models.Q(id=int(search_q)) | models.Q(preparation_order__intervention__id=int(search_q))
production_orders = production_orders.filter(q_filter).distinct()
production_orders = production_orders.order_by('-created_at')[:200]
return render(request, "stock/mobile/stock_production_orders_mobile.html", {
'production_orders': production_orders,
'search_q': search_q,
'selected_status': selected_status,
'selected_warehouse': selected_warehouse,
'selected_destination': selected_destination,
'status_choices': ProductionOrder.STATUS_CHOICES,
'destination_choices': ProductionOrder.DESTINATION_CHOICES,
'warehouses': accessible_warehouses,
'prod_counts': prod_counts,
})

View file

@ -2237,6 +2237,19 @@ class ProductionOrder(models.Model):
class Meta:
ordering = ("-created_at",)
@property
def display_output_product_name(self):
"""Retourne le nom d'affichage du produit fini / confectionné."""
if self.output_product:
return self.output_product.name
if self.output_product_name:
return self.output_product_name
if self.preparation_order_other_item and self.preparation_order_other_item.description:
return self.preparation_order_other_item.description
if self.notes and 'Issu de' in self.notes and ': ' in self.notes:
return self.notes.split(': ', 1)[1].strip()
return ""
def __str__(self):
return _("Confection {code} x{qty}").format(
code=self.code or f"#{self.pk}",

View file

@ -1,4 +1,4 @@
{% extends 'mobile/mobile_base.html' %}
{% extends 'mobile/mobile_base.html' %}
{% load static %}
{% load i18n %}
@ -31,44 +31,180 @@
}
.list-card-title { font-weight: 600; font-size: 1rem; }
.list-card-meta { font-size: 0.83rem; color: #6c757d; }
.status-pills-bar {
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
</style>
{% endblock extra_head %}
{% block content %}
<div class="d-flex justify-content-end mb-3">
<!-- En-tête avec bouton Nouveau bon -->
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold fs-6 text-dark">
<i class="bi bi-tools text-primary me-1"></i>{% translate "Bons de confection" %}
</span>
<a href="{% url 'mobile:production_order_create_mobile' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouveau bon" %}
</a>
</div>
<!-- Filtres mobiles -->
<div class="card mb-3 shadow-sm bg-light border-0">
<div class="card-body p-2">
<form method="get" class="row g-2">
<div class="col-12">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" name="q" value="{{ search_q }}" placeholder="{% translate 'N° bon, produit, bon de prép...' %}">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
</div>
</div>
<div class="col-6">
<select class="form-select form-select-sm" name="status" onchange="this.form.submit()">
<option value="">-- {% translate "Tous statuts" %} --</option>
{% for val, label in status_choices %}
<option value="{{ val }}" {% if selected_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-6">
{% if warehouses|length > 1 %}
<select class="form-select form-select-sm" name="warehouse" onchange="this.form.submit()">
<option value="">-- {% translate "Tous entrepôts" %} --</option>
{% for wh in warehouses %}
<option value="{{ wh.pk }}" {% if selected_warehouse == wh.pk|stringformat:"s" %}selected{% endif %}>{{ wh.name }}</option>
{% endfor %}
</select>
{% else %}
<select class="form-select form-select-sm" name="destination" onchange="this.form.submit()">
<option value="">-- {% translate "Toutes destinations" %} --</option>
{% for val, label in destination_choices %}
<option value="{{ val }}" {% if selected_destination == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% endif %}
</div>
{% if selected_status or selected_warehouse or selected_destination or search_q %}
<div class="col-12 text-center mt-1">
<a href="{% url 'mobile:stock_production_orders_mobile' %}" class="btn btn-sm btn-outline-secondary py-0" style="font-size:0.75rem;">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
</a>
</div>
{% endif %}
</form>
</div>
</div>
<!-- Filtres rapides par statut (Pills défilantes) -->
<div class="d-flex gap-1 overflow-auto pb-2 mb-2 status-pills-bar" style="white-space: nowrap;">
<a href="?{% if search_q %}q={{ search_q }}&{% endif %}{% if selected_warehouse %}warehouse={{ selected_warehouse }}&{% endif %}{% if selected_destination %}destination={{ selected_destination }}&{% endif %}"
class="badge {% if not selected_status %}bg-primary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
{% translate "Tous" %} ({{ prod_counts.total|default:0 }})
</a>
<a href="?status=to_process{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
class="badge {% if selected_status == 'to_process' %}bg-danger text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
<i class="bi bi-hourglass-split me-1"></i>{% translate "À traiter" %} ({{ prod_counts.to_process|default:0 }})
</a>
<a href="?status=in_production{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
class="badge {% if selected_status == 'in_production' %}bg-primary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En confection" %} ({{ prod_counts.in_production|default:0 }})
</a>
<a href="?status=completed{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
class="badge {% if selected_status == 'completed' %}bg-success text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé" %} ({{ prod_counts.completed|default:0 }})
</a>
{% if prod_counts.draft %}
<a href="?status=draft{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
class="badge {% if selected_status == 'draft' %}bg-secondary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
<i class="bi bi-pencil me-1"></i>{% translate "Brouillon" %} ({{ prod_counts.draft|default:0 }})
</a>
{% endif %}
{% if prod_counts.cancelled %}
<a href="?status=cancelled{% if search_q %}&q={{ search_q }}{% endif %}{% if selected_warehouse %}&warehouse={{ selected_warehouse }}{% endif %}{% if selected_destination %}&destination={{ selected_destination }}{% endif %}"
class="badge {% if selected_status == 'cancelled' %}bg-secondary text-white{% else %}bg-light text-dark border{% endif %} text-decoration-none py-1 px-2">
<i class="bi bi-x-circle me-1"></i>{% translate "Annulé" %} ({{ prod_counts.cancelled|default:0 }})
</a>
{% endif %}
</div>
<!-- Liste des bons -->
{% if production_orders %}
{% for order in production_orders %}
<a href="{% url 'mobile:stock_production_order_detail_mobile' order.pk %}" class="list-card">
<a href="{% url 'mobile:stock_production_order_detail_mobile' order.pk %}" class="list-card"
style="border-left: 4px solid {% if order.status == 'to_process' %}#dc3545{% elif order.status == 'in_production' %}#0d6efd{% elif order.status == 'completed' %}#198754{% elif order.status == 'cancelled' %}#6c757d{% else %}#6c757d{% endif %} !important;">
<div class="d-flex justify-content-between align-items-start">
<div class="list-card-title">
{% if order.code %}{{ order.code }}{% else %}#{{ order.id }}{% endif %}
<div class="list-card-title text-primary">
{{ order.code|default:order.id }}
</div>
<div class="d-flex flex-column align-items-end gap-1">
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
{% if order.destination == 'internal' %}
<span class="badge bg-light text-secondary border" style="font-size: 0.7rem;">
<i class="bi bi-house me-1"></i>{% translate "Interne" %}
</span>
{% else %}
<span class="badge bg-light text-info border" style="font-size: 0.7rem;">
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Externe" %}
</span>
{% endif %}
</div>
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
</div>
{% if order.product_design %}
<div class="list-card-meta mt-1">
<i class="bi bi-diagram-3 me-1"></i>{{ order.product_design.name }}
<!-- Produit confectionné -->
<div class="mt-2 fw-semibold text-dark" style="font-size: 0.95rem;">
<i class="bi bi-box-seam me-1 text-primary"></i>
{% if order.output_product %}
{{ order.output_product.name }}
{% elif order.output_product_name %}
{{ order.output_product_name }}
<span class="badge bg-warning bg-opacity-25 text-dark border border-warning ms-1" style="font-size:0.7rem;">{% translate "Nouveau" %}</span>
{% elif order.preparation_order_other_item %}
{{ order.preparation_order_other_item.description }}
{% else %}
<span class="text-muted fst-italic">{% translate "Produit non spécifié" %}</span>
{% endif %}
</div>
<!-- Bon de préparation lié si existant -->
{% if order.preparation_order %}
<div class="list-card-meta mt-1 text-primary">
<i class="bi bi-clipboard-check me-1"></i>{% translate "Bon de prép." %} : <strong>#{{ order.preparation_order.order_number }}</strong>
{% if order.preparation_order.intervention %}
<span class="text-muted">({{ order.preparation_order.intervention.code|default:order.preparation_order.intervention.id }})</span>
{% endif %}
</div>
{% endif %}
<div class="list-card-meta mt-1">
<span class="me-3"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
<span><i class="bi bi-building me-1"></i>{{ order.warehouse.name }}</span>
</div>
<div class="list-card-meta mt-1">
<i class="bi bi-layers me-1"></i>{% translate "Qté" %} : {{ order.quantity }}
<!-- Métadonnées : Entrepôt, Quantité, Composants, Date -->
<div class="list-card-meta mt-2 d-flex justify-content-between align-items-center flex-wrap gap-1">
<div>
<span class="me-2"><i class="bi bi-building me-1"></i>{{ order.warehouse.name }}</span>
<span class="badge bg-light text-dark border"><i class="bi bi-layers me-1"></i>{% translate "Qté" %} : <strong>{{ order.quantity }}</strong></span>
</div>
<div>
{% if order.order_components.count %}
<span class="badge bg-light text-muted border me-1">
<i class="bi bi-boxes me-1"></i>{{ order.order_components.count }}
</span>
{% endif %}
<span class="small text-muted"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
</div>
</div>
</a>
{% endfor %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-tools fs-1 d-block mb-2"></i>
{% translate "Aucun bon de confection." %}
<div class="text-center text-muted py-5 card shadow-sm border-0">
<i class="bi bi-tools fs-1 d-block mb-2 opacity-50"></i>
<h6>{% translate "Aucun bon de confection." %}</h6>
{% if selected_status or selected_warehouse or selected_destination or search_q %}
<p class="small text-muted mb-2">{% translate "Aucun résultat pour cette recherche." %}</p>
<a href="{% url 'mobile:stock_production_orders_mobile' %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
</a>
{% endif %}
</div>
{% endif %}
{% endblock content %}

View file

@ -27,20 +27,25 @@
<div class="container mt-2">
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="preparation-orders-tab" data-bs-toggle="tab" data-bs-target="#preparation-orders-content" type="button" role="tab" aria-controls="preparation-orders" aria-selected="true"><i class="bi bi-clipboard-check"></i> {% translate "Bons de préparation" %}</button>
<button class="nav-link {% if active_tab == 'preparation-orders' or not active_tab %}active{% endif %}" id="preparation-orders-tab" data-bs-toggle="tab" data-bs-target="#preparation-orders-content" type="button" role="tab" aria-controls="preparation-orders" aria-selected="{% if active_tab == 'preparation-orders' or not active_tab %}true{% else %}false{% endif %}"><i class="bi bi-clipboard-check"></i> {% translate "Bons de préparation" %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="production-orders-tab" data-bs-toggle="tab" data-bs-target="#production-orders-content" type="button" role="tab" aria-controls="production-orders" aria-selected="false"><i class="bi bi-pencil-square"></i> {% translate "Bons de confection" %}</button>
<button class="nav-link {% if active_tab == 'production-orders' %}active{% endif %}" id="production-orders-tab" data-bs-toggle="tab" data-bs-target="#production-orders-content" type="button" role="tab" aria-controls="production-orders" aria-selected="{% if active_tab == 'production-orders' %}true{% else %}false{% endif %}">
<i class="bi bi-tools"></i> {% translate "Bons de confection" %}
{% if prod_counts.to_process %}
<span class="badge bg-danger rounded-pill ms-1" title="{% translate 'À traiter' %}">{{ prod_counts.to_process }}</span>
{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="movements-tab" data-bs-toggle="tab" data-bs-target="#movements-content" type="button" role="tab" aria-controls="movements-table" aria-selected="false"><i class="bi bi-arrow-left-right"></i> {% translate "Mouvements de Stock" %}</button>
<button class="nav-link {% if active_tab == 'movements' %}active{% endif %}" id="movements-tab" data-bs-toggle="tab" data-bs-target="#movements-content" type="button" role="tab" aria-controls="movements-table" aria-selected="{% if active_tab == 'movements' %}true{% else %}false{% endif %}"><i class="bi bi-arrow-left-right"></i> {% translate "Mouvements de Stock" %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="inventories-tab" data-bs-toggle="tab" data-bs-target="#inventories-content" type="button" role="tab" aria-controls="inventories-content" aria-selected="false"><i class="bi bi-journal-check"></i> {% translate "Inventaires" %}</button>
<button class="nav-link {% if active_tab == 'inventories' %}active{% endif %}" id="inventories-tab" data-bs-toggle="tab" data-bs-target="#inventories-content" type="button" role="tab" aria-controls="inventories-content" aria-selected="{% if active_tab == 'inventories' %}true{% else %}false{% endif %}"><i class="bi bi-journal-check"></i> {% translate "Inventaires" %}</button>
</li>
</ul>
<div class="tab-content my-2" id="tablesTabContent">
<div class="tab-pane fade show active" id="preparation-orders-content" role="tabpanel" aria-labelledby="preparation-orders-tab">
<div class="tab-pane fade {% if active_tab == 'preparation-orders' or not active_tab %}show active{% endif %}" id="preparation-orders-content" role="tabpanel" aria-labelledby="preparation-orders-tab">
<div id="preparation-orders-container">
<!-- Filtres -->
<div class="card mb-3 shadow-sm bg-light">
@ -190,94 +195,283 @@
{% endfor %}
</div>
</div>
<div class="tab-pane fade" id="production-orders-content" role="tabpanel" aria-labelledby="production-orders-tab">
<div class="tab-pane fade {% if active_tab == 'production-orders' %}show active{% endif %}" id="production-orders-content" role="tabpanel" aria-labelledby="production-orders-tab">
<div id="production-orders-container">
{% if can_create_production_order %}
<div class="d-flex justify-content-end align-items-center m-2">
<a href="{% url 'stock:production_order_create' %}" class="btn btn-light">
<i class="bi bi-plus-circle"></i> {% translate "Nouveau bon de confection" %}
</a>
<!-- Filtres Bons de confection -->
<div class="card mb-3 shadow-sm bg-light">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<!-- Préserver l'onglet actif -->
<input type="hidden" name="tab" value="production-orders">
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" name="prod_q" value="{{ prod_search_q }}" placeholder="{% translate 'Code, produit, bon prép, composant...' %}">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
</div>
</div>
<div class="col-md-2">
<div class="input-group input-group-sm">
<label class="input-group-text" for="filter-prod-status"><i class="bi bi-funnel me-1"></i>{% translate "Statut" %}</label>
<select class="form-select" id="filter-prod-status" name="prod_status" onchange="this.form.submit()">
<option value="">-- {% translate "Tous les statuts" %} --</option>
{% for val, label in prod_status_choices %}
<option value="{{ val }}" {% if prod_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="input-group input-group-sm">
<label class="input-group-text" for="filter-prod-warehouse"><i class="bi bi-building me-1"></i>{% translate "Entrepôt" %}</label>
<select class="form-select" id="filter-prod-warehouse" name="prod_warehouse" onchange="this.form.submit()">
<option value="">-- {% translate "Tous entrepôts" %} --</option>
{% for wh in accessible_warehouses %}
<option value="{{ wh.pk }}" {% if prod_warehouse == wh.pk|stringformat:"s" %}selected{% endif %}>{{ wh.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="input-group input-group-sm">
<label class="input-group-text" for="filter-prod-destination"><i class="bi bi-geo-alt me-1"></i>{% translate "Destination" %}</label>
<select class="form-select" id="filter-prod-destination" name="prod_destination" onchange="this.form.submit()">
<option value="">-- {% translate "Toutes" %} --</option>
{% for val, label in prod_destination_choices %}
<option value="{{ val }}" {% if prod_destination == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-3 d-flex justify-content-end align-items-center gap-1">
{% if prod_status or prod_warehouse or prod_destination or prod_search_q %}
<a href="?tab=production-orders" class="btn btn-sm btn-outline-secondary" title="{% translate 'Effacer les filtres' %}">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer" %}
</a>
{% endif %}
{% if can_create_production_order %}
<a href="{% url 'stock:production_order_create' %}" class="btn btn-sm btn-primary text-nowrap">
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouveau bon" %}
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endif %}
<!-- Filtres rapides par statut (Pills) -->
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<span class="small text-muted fw-semibold me-1"><i class="bi bi-funnel me-1"></i>{% translate "Vue rapide :" %}</span>
<a href="?tab=production-orders{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if not prod_status %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill">
{% translate "Tous" %} <span class="badge {% if not prod_status %}bg-white text-primary{% else %}bg-secondary{% endif %} ms-1">{{ prod_counts.total|default:0 }}</span>
</a>
<a href="?tab=production-orders&prod_status=to_process{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if prod_status == 'to_process' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill">
<i class="bi bi-hourglass-split me-1"></i>{% translate "À traiter" %} <span class="badge {% if prod_status == 'to_process' %}bg-white text-danger{% else %}bg-danger text-white{% endif %} ms-1">{{ prod_counts.to_process|default:0 }}</span>
</a>
<a href="?tab=production-orders&prod_status=in_production{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if prod_status == 'in_production' %}btn-primary{% else %}btn-outline-primary{% endif %} rounded-pill">
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En confection" %} <span class="badge {% if prod_status == 'in_production' %}bg-white text-primary{% else %}bg-primary text-white{% endif %} ms-1">{{ prod_counts.in_production|default:0 }}</span>
</a>
<a href="?tab=production-orders&prod_status=completed{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if prod_status == 'completed' %}btn-success{% else %}btn-outline-success{% endif %} rounded-pill">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé" %} <span class="badge {% if prod_status == 'completed' %}bg-white text-success{% else %}bg-success text-white{% endif %} ms-1">{{ prod_counts.completed|default:0 }}</span>
</a>
{% if prod_counts.draft %}
<a href="?tab=production-orders&prod_status=draft{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if prod_status == 'draft' %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
<i class="bi bi-pencil me-1"></i>{% translate "Brouillon" %} <span class="badge {% if prod_status == 'draft' %}bg-white text-secondary{% else %}bg-secondary text-white{% endif %} ms-1">{{ prod_counts.draft|default:0 }}</span>
</a>
{% endif %}
{% if prod_counts.cancelled %}
<a href="?tab=production-orders&prod_status=cancelled{% if prod_search_q %}&prod_q={{ prod_search_q }}{% endif %}{% if prod_warehouse %}&prod_warehouse={{ prod_warehouse }}{% endif %}{% if prod_destination %}&prod_destination={{ prod_destination }}{% endif %}"
class="btn btn-sm py-1 px-2 {% if prod_status == 'cancelled' %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
<i class="bi bi-x-circle me-1"></i>{% translate "Annulé" %} <span class="badge {% if prod_status == 'cancelled' %}bg-white text-secondary{% else %}bg-secondary text-white{% endif %} ms-1">{{ prod_counts.cancelled|default:0 }}</span>
</a>
{% endif %}
</div>
{% for order in production_orders %}
<div class="card mb-3 shadow-sm
{% if order.status == 'draft' %}border-secondary
{% if order.status == 'to_process' %}border-danger
{% elif order.status == 'in_production' %}border-primary
{% elif order.status == 'completed' %}border-success
{% elif order.status == 'cancelled' %}border-danger
{% elif order.status == 'cancelled' %}border-secondary
{% else %}border-secondary{% endif %}"
style="border-left-width: 4px !important;">
<!-- En-tête -->
<div class="card-header d-flex align-items-center gap-2 flex-wrap py-2
{% if order.status == 'draft' %}bg-secondary bg-opacity-10
{% if order.status == 'to_process' %}bg-danger bg-opacity-10
{% elif order.status == 'in_production' %}bg-primary bg-opacity-10
{% elif order.status == 'completed' %}bg-success bg-opacity-10
{% elif order.status == 'cancelled' %}bg-danger bg-opacity-10
{% elif order.status == 'cancelled' %}bg-secondary bg-opacity-10
{% else %}bg-secondary bg-opacity-10{% endif %}">
<a href="{% url 'stock:production_order_detail' order.pk %}" class="fw-bold text-decoration-none me-1">
{{ order.code }}
<a href="{% url 'stock:production_order_detail' order.pk %}" class="fw-bold text-decoration-none me-1 fs-6">
{{ order.code|default:order.pk }}
</a>
{% if order.status == 'draft' %}
<span class="badge bg-secondary">{{ order.get_status_display }}</span>
{% elif order.status == 'in_production' %}
<span class="badge bg-primary"><i class="bi bi-gear me-1"></i>{{ order.get_status_display }}</span>
{% elif order.status == 'completed' %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{{ order.get_status_display }}</span>
{% elif order.status == 'cancelled' %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{{ order.get_status_display }}</span>
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
{% if order.destination == 'internal' %}
<span class="badge bg-light text-secondary border"><i class="bi bi-house me-1"></i>{% translate "Interne" %}</span>
{% else %}
<span class="badge bg-light text-info border"><i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Externe" %}</span>
{% endif %}
<span class="text-muted small ms-auto"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y" }}</span>
{% if order.preparation_order %}
<a href="{% url 'stock:preparation_order_detail' order.preparation_order.pk %}" class="badge bg-light text-dark border text-decoration-none" title="{% translate 'Bon de préparation lié' %}">
<i class="bi bi-clipboard-check me-1 text-primary"></i>#{{ order.preparation_order.order_number }}
</a>
{% if order.preparation_order.intervention %}
<a href="{% url 'interventions:intervention_detail' order.preparation_order.intervention.id %}" class="badge bg-light text-dark border text-decoration-none" title="{% translate 'Intervention liée' %}">
<i class="bi bi-wrench me-1 text-primary"></i>{{ order.preparation_order.intervention.code|default:order.preparation_order.intervention.id }}{% if order.preparation_order.intervention.title %} — {{ order.preparation_order.intervention.title|truncatechars:30 }}{% endif %}
</a>
{% endif %}
{% endif %}
<span class="badge bg-light text-dark border">
<i class="bi bi-building me-1 text-muted"></i>{{ order.warehouse.name }}
</span>
{% if order.documents.count %}
<span class="badge bg-light text-muted border" title="{% translate 'Documents attachés' %}">
<i class="bi bi-paperclip me-1"></i>{{ order.documents.count }}
</span>
{% endif %}
<span class="text-muted small ms-auto"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y H:i" }}</span>
<a href="{% url 'stock:production_order_detail' order.pk %}" class="btn btn-sm btn-outline-primary ms-1" title="{% translate 'Voir le détail' %}">
<i class="bi bi-eye"></i>
</a>
</div>
<!-- Corps -->
<div class="card-body p-0">
<table class="table table-sm mb-0" style="table-layout:fixed;width:100%;">
<colgroup>
<col style="width:40%">
<col style="width:20%">
<col style="width:40%">
</colgroup>
<thead>
<tr style="border-bottom:1px solid rgba(0,0,0,0.1);">
<th class="ps-3 text-muted small fw-semibold text-uppercase">{% translate "Entrepôt" %}</th>
<th class="text-muted small fw-semibold text-uppercase">{% translate "Quantité" %}</th>
<th class="text-muted small fw-semibold text-uppercase">{% translate "Bon de prép." %}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="ps-3 py-2">{{ order.warehouse.name }}</td>
<td class="py-2 fw-semibold">{{ order.quantity }}</td>
<td class="py-2">
{% if order.preparation_order %}
<a href="{% url 'stock:preparation_order_detail' order.preparation_order.pk %}">
#{{ order.preparation_order.order_number }}
</a>
{% else %}
<span class="text-muted">-</span>
<!-- Corps du bon de confection -->
<div class="card-body p-3">
<div class="row align-items-center g-3">
<!-- Produit fini -->
<div class="col-md-5">
<div class="small text-muted text-uppercase fw-semibold mb-1">
<i class="bi bi-box-seam me-1"></i>{% translate "Produit confectionné" %}
</div>
<div class="fs-6 fw-bold text-dark">
{% if order.output_product %}
<a href="{% url 'stock:product_detail' order.output_product.pk %}" class="text-decoration-none text-dark">
{{ order.output_product.name }}
</a>
{% if order.output_product.code %}
<span class="badge bg-light text-muted border fw-normal ms-1">{{ order.output_product.code }}</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% elif order.output_product_name %}
<span>{{ order.output_product_name }}</span>
<span class="badge bg-warning bg-opacity-25 text-dark border border-warning fw-normal ms-1">{% translate "Nouveau produit" %}</span>
{% elif order.preparation_order_other_item %}
<span>{{ order.preparation_order_other_item.description }}</span>
<span class="badge bg-light text-muted border fw-normal ms-1">{% translate "Article sur mesure" %}</span>
{% else %}
<span class="text-muted fst-italic">{% translate "Non spécifié" %}</span>
{% endif %}
</div>
{% if order.output_location %}
<div class="small text-muted mt-1">
<i class="bi bi-geo-alt me-1"></i>{% translate "Emplacement cible :" %} {{ order.output_location.name }}
</div>
{% endif %}
</div>
<!-- Quantité & Destination -->
<div class="col-md-3">
<div class="d-flex flex-column gap-1">
<div>
<span class="text-muted small">{% translate "Quantité à concevoir :" %}</span>
<span class="badge bg-primary fs-6 ms-1">{{ order.quantity }}</span>
</div>
<div>
<span class="text-muted small">{% translate "Entrepôt :" %}</span>
<strong>{{ order.warehouse.name }}</strong>
</div>
</div>
</div>
<!-- Statut & Dates -->
<div class="col-md-4">
<div class="d-flex flex-column gap-1 small">
{% if order.completed_at %}
<div class="text-success fw-semibold">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminé le" %} {{ order.completed_at|date:"d/m/Y H:i" }}
</div>
{% elif order.status == 'in_production' %}
<div class="text-primary fw-semibold">
<i class="bi bi-gear-wide-connected me-1"></i>{% translate "En cours de confection" %}
</div>
{% elif order.status == 'to_process' %}
<div class="text-danger fw-semibold">
<i class="bi bi-hourglass-split me-1"></i>{% translate "En attente de confection" %}
</div>
{% endif %}
{% if order.notes %}
<div class="text-muted fst-italic">
<i class="bi bi-chat-left-text me-1"></i>{{ order.notes|truncatechars:90 }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Composants associés -->
{% if order.order_components.all %}
<div class="mt-3 pt-2 border-top">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small text-muted fw-semibold text-uppercase">
<i class="bi bi-boxes me-1"></i>{% translate "Composants requis" %} ({{ order.order_components.count }}) :
</span>
</div>
<div class="d-flex flex-wrap gap-2">
{% for comp in order.order_components.all %}
<span class="badge bg-light text-dark border p-2 d-inline-flex align-items-center">
<i class="bi bi-box me-1 text-muted"></i>
{{ comp.product.name }}
<span class="badge bg-secondary ms-2" title="{% translate 'Quantité requise' %}">
x{{ comp.quantity }}
</span>
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="text-center text-muted py-5">
<i class="bi bi-pencil-square fs-2 d-block mb-2 opacity-50"></i>
{% translate "Aucun bon de confection." %}
<div class="text-center text-muted py-5 card shadow-sm">
<i class="bi bi-tools fs-1 d-block mb-2 text-secondary opacity-50"></i>
<h5>{% translate "Aucun bon de confection trouvé." %}</h5>
{% if prod_status or prod_warehouse or prod_destination or prod_search_q %}
<p class="small text-muted">{% translate "Aucun résultat ne correspond aux filtres appliqués." %}</p>
<a href="?tab=production-orders" class="btn btn-sm btn-outline-secondary mt-1">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
</a>
{% else %}
<p class="small text-muted">{% translate "Aucun bon de confection enregistré." %}</p>
{% if can_create_production_order %}
<a href="{% url 'stock:production_order_create' %}" class="btn btn-sm btn-primary mt-1">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer un bon de confection" %}
</a>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="tab-pane fade" id="movements-content" role="tabpanel" aria-labelledby="movements-tab">
<div class="tab-pane fade {% if active_tab == 'movements' %}show active{% endif %}" id="movements-content" role="tabpanel" aria-labelledby="movements-tab">
<div id="movements-container">
{% if can_create_movement %}
<div class="d-flex justify-content-end align-items-center gap-2 m-2 flex-wrap">
@ -398,7 +592,7 @@
{% endfor %}
</div>
</div>
<div class="tab-pane fade" id="inventories-content" role="tabpanel" aria-labelledby="inventories-tab">
<div class="tab-pane fade {% if active_tab == 'inventories' %}show active{% endif %}" id="inventories-content" role="tabpanel" aria-labelledby="inventories-tab">
<div id="inventories-container">
{% if can_create_inventory %}
<div class="d-flex justify-content-end align-items-center m-2">

View file

@ -452,6 +452,104 @@ class PreparationOrderPriorityTests(TestCase):
self.assertIn(po2, list(response.context["preparation_orders"]))
self.assertNotIn(po1, list(response.context["preparation_orders"]))
def test_stock_movements_production_order_filters(self):
from .models import ProductionOrder, Warehouse, Product
wh1 = Warehouse.objects.create(name="Entrepôt Nord", code="WH-N-TEST")
wh2 = Warehouse.objects.create(name="Entrepôt Sud", code="WH-S-TEST")
prod = Product.objects.create(name="Armoire sur mesure", code="CONF-01-TEST", sku="SKU-CONF-01-TEST")
order1 = ProductionOrder.objects.create(
code="CONF-2026-0001",
warehouse=wh1,
status="to_process",
destination="internal",
output_product=prod,
quantity=2,
notes="Confection spéciale pour bureau",
)
order2 = ProductionOrder.objects.create(
code="CONF-2026-0002",
warehouse=wh2,
status="completed",
destination="external",
output_product_name="Table extérieure",
quantity=1,
notes="Chantier voirie",
)
# 1. Test status filter
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_status=to_process")
self.assertEqual(response.status_code, 200)
self.assertIn(order1, list(response.context["production_orders"]))
self.assertNotIn(order2, list(response.context["production_orders"]))
# 2. Test search filter
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_q=extérieure")
self.assertEqual(response.status_code, 200)
self.assertIn(order2, list(response.context["production_orders"]))
self.assertNotIn(order1, list(response.context["production_orders"]))
# 3. Test destination filter
response = self.client.get(reverse("stock:stock_movements") + "?tab=production-orders&prod_destination=internal")
self.assertEqual(response.status_code, 200)
self.assertIn(order1, list(response.context["production_orders"]))
self.assertNotIn(order2, list(response.context["production_orders"]))
# 4. Test warehouse filter
response = self.client.get(reverse("stock:stock_movements") + f"?tab=production-orders&prod_warehouse={wh2.id}")
self.assertEqual(response.status_code, 200)
self.assertIn(order2, list(response.context["production_orders"]))
self.assertNotIn(order1, list(response.context["production_orders"]))
# 5. Check prod_counts in context
self.assertIn("prod_counts", response.context)
self.assertGreaterEqual(response.context["prod_counts"]["to_process"], 1)
self.assertGreaterEqual(response.context["prod_counts"]["completed"], 1)
def test_mobile_production_orders_filters(self):
from .models import ProductionOrder, Warehouse
wh = Warehouse.objects.create(name="Atelier Mobile", code="WH-MOB-TEST")
order1 = ProductionOrder.objects.create(
code="CONF-MOB-01",
warehouse=wh,
status="to_process",
destination="internal",
output_product_name="Banc public",
)
order2 = ProductionOrder.objects.create(
code="CONF-MOB-02",
warehouse=wh,
status="in_production",
destination="external",
output_product_name="Potelet spécial",
)
# Status filter
response = self.client.get(reverse("mobile:stock_production_orders_mobile") + "?status=to_process")
self.assertEqual(response.status_code, 200)
self.assertIn(order1, list(response.context["production_orders"]))
self.assertNotIn(order2, list(response.context["production_orders"]))
# Search filter
response = self.client.get(reverse("mobile:stock_production_orders_mobile") + "?q=Potelet")
self.assertEqual(response.status_code, 200)
self.assertIn(order2, list(response.context["production_orders"]))
self.assertNotIn(order1, list(response.context["production_orders"]))
def test_production_order_display_output_product_name(self):
from .models import ProductionOrder, Warehouse, Product
wh = Warehouse.objects.create(name="WH-Disp", code="WH-D-TEST")
p = Product.objects.create(name="Produit Existant", code="PR-D-TEST", sku="SKU-D-TEST")
o1 = ProductionOrder.objects.create(code="CONF-D1", warehouse=wh, output_product=p)
self.assertEqual(o1.display_output_product_name, "Produit Existant")
o2 = ProductionOrder.objects.create(code="CONF-D2", warehouse=wh, output_product_name="Nouveau Produit")
self.assertEqual(o2.display_output_product_name, "Nouveau Produit")
o3 = ProductionOrder.objects.create(code="CONF-D3", warehouse=wh, notes="Confection de : Panneau bois\nAutre note")
self.assertEqual(o3.display_output_product_name, "Panneau bois")
def test_preparation_order_detail_shows_intervention_manager(self):
from django.contrib.auth.models import User
from interventions.models import Intervention

View file

@ -588,9 +588,12 @@ def sign_panel_types_api(request):
def stock_movements(request):
selected_status = request.GET.get('status', '').strip()
tab = request.GET.get('tab', 'preparation-orders').strip()
# Filtres Bons de préparation
selected_status = request.GET.get('prep_status', '').strip() or (request.GET.get('status', '').strip() if tab != 'production-orders' else '')
selected_priority = request.GET.get('priority', '').strip()
search_q = request.GET.get('q', '').strip()
search_q = request.GET.get('prep_q', '').strip() or (request.GET.get('q', '').strip() if tab != 'production-orders' else '')
preparation_orders = PreparationOrder.objects.select_related('intervention').all()
if selected_status:
@ -609,11 +612,59 @@ def stock_movements(request):
preparation_orders = preparation_orders.filter(q_filter)
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
# Filtrer selon les entrepôts accessibles
accessible_warehouses = get_user_accessible_warehouses(request.user)
# Filtres Bons de confection
prod_status = request.GET.get('prod_status', '').strip() or (request.GET.get('status', '').strip() if tab == 'production-orders' else '')
prod_search_q = request.GET.get('prod_q', '').strip() or (request.GET.get('q', '').strip() if tab == 'production-orders' else '')
prod_warehouse = request.GET.get('prod_warehouse', '').strip() or (request.GET.get('warehouse', '').strip() if tab == 'production-orders' else '')
prod_destination = request.GET.get('prod_destination', '').strip() or (request.GET.get('destination', '').strip() if tab == 'production-orders' else '')
prod_base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
# Compteurs par statut (pour les badges / filtres rapides)
prod_counts = prod_base_qs.aggregate(
total=models.Count('id'),
to_process=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_TO_PROCESS)),
in_production=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
completed=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_COMPLETED)),
draft=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_DRAFT)),
cancelled=models.Count('id', filter=models.Q(status=ProductionOrder.STATUS_CANCELLED)),
)
production_orders = prod_base_qs.select_related(
'warehouse', 'preparation_order', 'output_product', 'output_location',
'preparation_order__intervention', 'preparation_order_other_item',
).prefetch_related(
'order_components__product',
'documents',
)
if prod_status:
production_orders = production_orders.filter(status=prod_status)
if prod_warehouse:
production_orders = production_orders.filter(warehouse_id=prod_warehouse)
if prod_destination:
production_orders = production_orders.filter(destination=prod_destination)
if prod_search_q:
prod_q_filter = (
models.Q(code__icontains=prod_search_q) |
models.Q(output_product__name__icontains=prod_search_q) |
models.Q(output_product_name__icontains=prod_search_q) |
models.Q(preparation_order__order_number__icontains=prod_search_q) |
models.Q(preparation_order__intervention__code__icontains=prod_search_q) |
models.Q(preparation_order__intervention__title__icontains=prod_search_q) |
models.Q(preparation_order_other_item__description__icontains=prod_search_q) |
models.Q(notes__icontains=prod_search_q) |
models.Q(order_components__product__name__icontains=prod_search_q)
)
if prod_search_q.isdigit():
prod_q_filter |= models.Q(id=int(prod_search_q)) | models.Q(preparation_order__intervention__id=int(prod_search_q))
production_orders = production_orders.filter(prod_q_filter).distinct()
production_orders = production_orders.order_by("-created_at")[:1000]
movements = StockMovement.objects.filter(
models.Q(source_location__warehouse__id__in=accessible_warehouses) |
models.Q(destination_location__warehouse__id__in=accessible_warehouses)
@ -698,9 +749,18 @@ def stock_movements(request):
"company_members": company_members,
"companies": companies,
"contracts": contracts,
"active_tab": tab,
"selected_status": selected_status,
"selected_priority": selected_priority,
"search_q": search_q,
"prod_status": prod_status,
"prod_search_q": prod_search_q,
"prod_warehouse": prod_warehouse,
"prod_destination": prod_destination,
"prod_status_choices": ProductionOrder.STATUS_CHOICES,
"prod_destination_choices": ProductionOrder.DESTINATION_CHOICES,
"prod_counts": prod_counts,
"accessible_warehouses": accessible_warehouses,
"preparation_order_status_choices": [
('to_process', _("A traiter")),
('pending', _("En attente")),
@ -5650,7 +5710,13 @@ def product_delete(request, product_id):
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')
url = reverse('stock:stock_movements') + '?tab=production-orders'
params = request.GET.copy()
if 'tab' in params:
del params['tab']
if params:
url += '&' + params.urlencode()
return redirect(url)
def product_design_list(request):