From e00f9cf78427fc6016268d4c8c060c3ade994d89 Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Tue, 29 Sep 2026 17:07:29 +0200 Subject: [PATCH] feat: add stock management module including views, models, templates, and tests --- loko/mobile/views.py | 60 +++- loko/stock/models.py | 13 + .../stock_production_orders_mobile.html | 176 ++++++++-- .../templates/stock/stock_movements.html | 318 ++++++++++++++---- loko/stock/tests.py | 98 ++++++ loko/stock/views.py | 80 ++++- 6 files changed, 653 insertions(+), 92 deletions(-) diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 1b067a2..9fb4ae2 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -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, }) diff --git a/loko/stock/models.py b/loko/stock/models.py index 97b99a8..e9b2f66 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -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}", diff --git a/loko/stock/templates/stock/mobile/stock_production_orders_mobile.html b/loko/stock/templates/stock/mobile/stock_production_orders_mobile.html index a28c366..72d7e69 100644 --- a/loko/stock/templates/stock/mobile/stock_production_orders_mobile.html +++ b/loko/stock/templates/stock/mobile/stock_production_orders_mobile.html @@ -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; + } {% endblock extra_head %} {% block content %} -
+ +
+ + {% translate "Bons de confection" %} + {% translate "Nouveau bon" %}
+ + +
+
+
+
+
+ + + +
+
+
+ +
+
+ {% if warehouses|length > 1 %} + + {% else %} + + {% endif %} +
+ {% if selected_status or selected_warehouse or selected_destination or search_q %} + + {% endif %} +
+
+
+ + + + + {% if production_orders %} {% for order in production_orders %} - +
-
- {% if order.code %}{{ order.code }}{% else %}#{{ order.id }}{% endif %} +
+ {{ order.code|default:order.id }} +
+
+ + {{ order.get_status_display }} + + {% if order.destination == 'internal' %} + + {% translate "Interne" %} + + {% else %} + + {% translate "Externe" %} + + {% endif %}
- - {{ order.get_status_display }} -
- {% if order.product_design %} -
- {{ order.product_design.name }} + + +
+ + {% if order.output_product %} + {{ order.output_product.name }} + {% elif order.output_product_name %} + {{ order.output_product_name }} + {% translate "Nouveau" %} + {% elif order.preparation_order_other_item %} + {{ order.preparation_order_other_item.description }} + {% else %} + {% translate "Produit non spécifié" %} + {% endif %} +
+ + + {% if order.preparation_order %} +
+ {% translate "Bon de prép." %} : #{{ order.preparation_order.order_number }} + {% if order.preparation_order.intervention %} + ({{ order.preparation_order.intervention.code|default:order.preparation_order.intervention.id }}) + {% endif %}
{% endif %} -
- {{ order.created_at|date:"d/m/Y" }} - {{ order.warehouse.name }} -
-
- {% translate "Qté" %} : {{ order.quantity }} + + +
+
+ {{ order.warehouse.name }} + {% translate "Qté" %} : {{ order.quantity }} +
+
+ {% if order.order_components.count %} + + {{ order.order_components.count }} + + {% endif %} + {{ order.created_at|date:"d/m/Y" }} +
{% endfor %} {% else %} -
- - {% translate "Aucun bon de confection." %} +
+ +
{% translate "Aucun bon de confection." %}
+ {% if selected_status or selected_warehouse or selected_destination or search_q %} +

{% translate "Aucun résultat pour cette recherche." %}

+ + {% translate "Effacer les filtres" %} + + {% endif %}
{% endif %} {% endblock content %} diff --git a/loko/stock/templates/stock/stock_movements.html b/loko/stock/templates/stock/stock_movements.html index 696a5ea..7228f3f 100644 --- a/loko/stock/templates/stock/stock_movements.html +++ b/loko/stock/templates/stock/stock_movements.html @@ -27,20 +27,25 @@
-
+
@@ -190,94 +195,283 @@ {% endfor %}
-
+
- {% if can_create_production_order %} -
- - {% translate "Nouveau bon de confection" %} - + +
+
+
+ + + +
+
+ + + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ {% if prod_status or prod_warehouse or prod_destination or prod_search_q %} + + {% translate "Effacer" %} + + {% endif %} + {% if can_create_production_order %} + + {% translate "Nouveau bon" %} + + {% endif %} +
+
+
- {% endif %} + + + + {% for order in production_orders %}
- - {{ order.code }} + + {{ order.code|default:order.pk }} - {% if order.status == 'draft' %} - {{ order.get_status_display }} - {% elif order.status == 'in_production' %} - {{ order.get_status_display }} - {% elif order.status == 'completed' %} - {{ order.get_status_display }} - {% elif order.status == 'cancelled' %} - {{ order.get_status_display }} + + {{ order.get_status_display }} + + + {% if order.destination == 'internal' %} + {% translate "Interne" %} + {% else %} + {% translate "Externe" %} {% endif %} - {{ order.created_at|date:"d/m/Y" }} + {% if order.preparation_order %} + + #{{ order.preparation_order.order_number }} + + {% if order.preparation_order.intervention %} + + {{ 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 %} + + {% endif %} + {% endif %} + + + {{ order.warehouse.name }} + + + {% if order.documents.count %} + + {{ order.documents.count }} + + {% endif %} + + {{ order.created_at|date:"d/m/Y H:i" }}
- -
- - - - - - - - - - - - - - - - - - - - -
{% translate "Entrepôt" %}{% translate "Quantité" %}{% translate "Bon de prép." %}
{{ order.warehouse.name }}{{ order.quantity }} - {% if order.preparation_order %} - - #{{ order.preparation_order.order_number }} - - {% else %} - - + +
+
+ +
+
+ {% translate "Produit confectionné" %} +
+
+ {% if order.output_product %} + + {{ order.output_product.name }} + + {% if order.output_product.code %} + {{ order.output_product.code }} {% endif %} -
+ {% elif order.output_product_name %} + {{ order.output_product_name }} + {% translate "Nouveau produit" %} + {% elif order.preparation_order_other_item %} + {{ order.preparation_order_other_item.description }} + {% translate "Article sur mesure" %} + {% else %} + {% translate "Non spécifié" %} + {% endif %} +
+ {% if order.output_location %} +
+ {% translate "Emplacement cible :" %} {{ order.output_location.name }} +
+ {% endif %} +
+ + +
+
+
+ {% translate "Quantité à concevoir :" %} + {{ order.quantity }} +
+
+ {% translate "Entrepôt :" %} + {{ order.warehouse.name }} +
+
+
+ + +
+
+ {% if order.completed_at %} +
+ {% translate "Terminé le" %} {{ order.completed_at|date:"d/m/Y H:i" }} +
+ {% elif order.status == 'in_production' %} +
+ {% translate "En cours de confection" %} +
+ {% elif order.status == 'to_process' %} +
+ {% translate "En attente de confection" %} +
+ {% endif %} + {% if order.notes %} +
+ {{ order.notes|truncatechars:90 }} +
+ {% endif %} +
+
+
+ + + {% if order.order_components.all %} +
+
+ + {% translate "Composants requis" %} ({{ order.order_components.count }}) : + +
+
+ {% for comp in order.order_components.all %} + + + {{ comp.product.name }} + + x{{ comp.quantity }} + + + {% endfor %} +
+
+ {% endif %}
{% empty %} -
- - {% translate "Aucun bon de confection." %} +
+ +
{% translate "Aucun bon de confection trouvé." %}
+ {% if prod_status or prod_warehouse or prod_destination or prod_search_q %} +

{% translate "Aucun résultat ne correspond aux filtres appliqués." %}

+ + {% translate "Effacer les filtres" %} + + {% else %} +

{% translate "Aucun bon de confection enregistré." %}

+ {% if can_create_production_order %} + + {% translate "Créer un bon de confection" %} + + {% endif %} + {% endif %}
{% endfor %}
-
+
{% if can_create_movement %}
@@ -398,7 +592,7 @@ {% endfor %}
-
+
{% if can_create_inventory %}
diff --git a/loko/stock/tests.py b/loko/stock/tests.py index 8f8052a..cf95b18 100644 --- a/loko/stock/tests.py +++ b/loko/stock/tests.py @@ -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 diff --git a/loko/stock/views.py b/loko/stock/views.py index 6f26da5..f18a572 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -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):