From 63a9a080dedc8657ab68b4f8ecab115763efe588 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Tue, 29 Sep 2026 22:38:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(stock):=20tri=20chronologique=20des=20bons?= =?UTF-8?q?=20et=20r=C3=A9ouverture/historique=20des=20bons=20de=20confect?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trie les bons de préparation (date) et de confection (created_at) par ordre chronologique FIFO - Ajoute la réouverture a posteriori des bons de confection terminés avec régularisation automatique des stocks - Ajoute le modèle ProductionOrderHistory et trace l'historique complet des modifications (statuts, composants, documents) - Intègre le bouton de réouverture et la section d'historique dans les vues desktop et mobile --- loko/home/views.py | 4 +- loko/mobile/views.py | 19 +- ...alter_preparationorder_options_and_more.py | 47 +++ loko/stock/models.py | 298 +++++++++++++++++- .../stock_production_order_detail_mobile.html | 72 ++++- .../stock/production_order_detail.html | 84 ++++- loko/stock/tests.py | 176 ++++++++++- loko/stock/tests_permissions.py | 3 +- loko/stock/urls.py | 1 + loko/stock/views.py | 130 +++++++- 10 files changed, 804 insertions(+), 30 deletions(-) create mode 100644 loko/stock/migrations/0004_alter_preparationorder_options_and_more.py diff --git a/loko/home/views.py b/loko/home/views.py index e5926f7..78aec48 100644 --- a/loko/home/views.py +++ b/loko/home/views.py @@ -488,7 +488,7 @@ def index(request): preparation_order_to_process_only_count = _prep_counts['to_process'] preparation_order_pending_count = _prep_counts['pending'] _prep_qs = PreparationOrder.objects.filter(status__in=['to_process', 'pending']) - preparation_order_to_process_list = list(_prep_qs.order_by('-date')[:5]) + preparation_order_to_process_list = list(_prep_qs.order_by('date')[:5]) from stock.permissions import get_user_accessible_warehouses accessible_warehouses = get_user_accessible_warehouses(request.user) @@ -503,7 +503,7 @@ def index(request): production_order_in_production_count = _prod_counts['in_production'] _prod_qs = _prod_base_qs.filter(status__in=['to_process', 'in_production']) production_order_to_process_list = list( - _prod_qs.select_related('warehouse').order_by('-created_at')[:5] + _prod_qs.select_related('warehouse').order_by('created_at')[:5] ) _products_with_active_po = PurchaseOrderItem.objects.filter( diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 26d5d3a..447328d 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -461,7 +461,7 @@ def stock_preparation_orders_mobile(request): preparation_orders = preparation_orders.prefetch_related( 'preparationorderitem_set__product' - ).order_by('-date')[:200] + ).order_by('date')[:200] return render(request, "stock/mobile/stock_preparation_orders_mobile.html", { 'preparation_orders': preparation_orders, 'selected_status': selected_status, @@ -544,7 +544,7 @@ def stock_production_orders_mobile(request): 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] + production_orders = production_orders.order_by('created_at')[:200] return render(request, "stock/mobile/stock_production_orders_mobile.html", { 'production_orders': production_orders, @@ -602,6 +602,8 @@ def stock_production_order_detail_mobile(request, pk): 'can_start': can_start, 'can_manage': can_manage, 'other_item_description': other_item_description, + 'history_entries': order.history_entries.select_related('user').order_by('-created_at'), + 'is_reopened': order.is_reopened, }) @@ -761,13 +763,24 @@ def production_order_create_mobile(request): form.save_m2m() formset.instance = order formset.save() + order.log_history( + action="created", + description=_("Création du bon de confection (quantité : {})").format(order.quantity), + user=request.user, + details={'quantity': order.quantity, 'warehouse': order.warehouse.name}, + ) from stock.models import ProductionOrderDocument for f in request.FILES.getlist('new_documents'): - ProductionOrderDocument.objects.create( + doc = ProductionOrderDocument.objects.create( production_order=order, file=f, name=f.name, ) + order.log_history( + action="document_added", + description=_("Ajout de document : {}").format(doc.filename()), + user=request.user, + ) messages.success(request, _("Bon de confection créé avec succès.")) return redirect('mobile:stock_production_order_detail_mobile', pk=order.pk) else: diff --git a/loko/stock/migrations/0004_alter_preparationorder_options_and_more.py b/loko/stock/migrations/0004_alter_preparationorder_options_and_more.py new file mode 100644 index 0000000..81c7695 --- /dev/null +++ b/loko/stock/migrations/0004_alter_preparationorder_options_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.7 on 2026-09-29 20:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0003_preparationorderhistory'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='preparationorder', + options={'ordering': ('date',), 'verbose_name': 'Bon de préparation', 'verbose_name_plural': 'Bons de préparation'}, + ), + migrations.AlterModelOptions( + name='productionorder', + options={'ordering': ('created_at',), 'verbose_name': 'Bon de confection', 'verbose_name_plural': 'Bons de confection'}, + ), + migrations.AddField( + model_name='stockmovementbatch', + name='production_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_batches', to='stock.productionorder', verbose_name='Bon de confection'), + ), + migrations.CreateModel( + name='ProductionOrderHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_short_name', models.CharField(blank=True, max_length=150, verbose_name='Nom court')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date et heure')), + ('action', models.CharField(choices=[('created', 'Création'), ('status_changed', 'Changement de statut'), ('reopened', 'Réouverture'), ('completed', 'Terminé'), ('cancelled', 'Annulation'), ('component_added', 'Ajout de composant'), ('component_updated', 'Modification de composant'), ('component_removed', 'Suppression de composant'), ('quantity_changed', 'Modification de quantité'), ('document_added', 'Ajout de document'), ('document_removed', 'Suppression de document'), ('other', 'Autre modification')], default='other', max_length=50, verbose_name='Action')), + ('description', models.TextField(verbose_name='Description')), + ('details', models.JSONField(blank=True, default=dict, verbose_name='Détails')), + ('production_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history_entries', to='stock.productionorder', verbose_name='Bon de confection')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='production_order_history_entries', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')), + ], + options={ + 'verbose_name': 'Historique de bon de confection', + 'verbose_name_plural': 'Historiques de bons de confection', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/loko/stock/models.py b/loko/stock/models.py index 6cf0e48..7eaf694 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -407,6 +407,14 @@ class StockMovementBatch(models.Model): related_name="stock_batches", verbose_name=_("Commande d'achat"), ) + production_order = models.ForeignKey( + "ProductionOrder", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="stock_batches", + verbose_name=_("Bon de confection"), + ) requester = models.CharField( max_length=255, blank=True, @@ -1442,6 +1450,11 @@ class PreparationOrder(models.Model): '4': 'bg-secondary', } + class Meta: + ordering = ("date",) + verbose_name = _("Bon de préparation") + verbose_name_plural = _("Bons de préparation") + def __str__(self): return f"Preparation Order #{self.order_number}" @@ -2235,7 +2248,16 @@ class ProductionOrder(models.Model): ) class Meta: - ordering = ("-created_at",) + ordering = ("created_at",) + verbose_name = _("Bon de confection") + verbose_name_plural = _("Bons de confection") + + @property + def is_reopened(self): + """Retourne True si le bon a été réouvert et est actuellement en cours ou à traiter.""" + if self.status in [self.STATUS_TO_PROCESS, self.STATUS_IN_PRODUCTION]: + return self.history_entries.filter(action="reopened").exists() + return False @property def display_output_product_name(self): @@ -2381,11 +2403,27 @@ class ProductionOrder(models.Model): return self.output_location return self.warehouse.locations.first() - def complete(self): + def log_history(self, action, description, user=None, details=None): + """Enregistre un événement dans l'historique du bon de confection.""" + from common.utils import get_short_name + user_short = "" + if user and getattr(user, "is_authenticated", False): + user_short = get_short_name(user) + return self.history_entries.create( + user=user if (user and getattr(user, "is_authenticated", False)) else None, + user_short_name=user_short, + action=action, + description=description, + details=details or {}, + ) + + def complete(self, user=None): """ Marque le bon de confection comme terminé : - Consomme le stock des composants - Ajoute le produit fini au stock + - Enregistre les mouvements dans des lots associés + - Trace l'historique """ if self.status == self.STATUS_COMPLETED: return @@ -2395,6 +2433,13 @@ class ProductionOrder(models.Model): # 1. Consommer les composants active_components = self._get_active_components() if active_components: + batch_out = StockMovementBatch.objects.create( + movement_type=StockMovementBatch.TYPE_OUT, + movement_subtype="confection", + production_order=self, + reason=_("Bon de confection {code}").format(code=self.code), + created_by=user, + ) for comp in active_components: needed = comp.quantity * self.quantity # Prendre le stock depuis les emplacements de l'entrepôt @@ -2418,6 +2463,7 @@ class ProductionOrder(models.Model): reason=_("Bon de confection {code}").format( code=self.code, ), + batch=batch_out, ) # 2. Ajouter le produit fini au stock @@ -2432,6 +2478,15 @@ class ProductionOrder(models.Model): ) stock_entry.quantity += self.quantity stock_entry.save() + batch_in = StockMovementBatch.objects.create( + movement_type=StockMovementBatch.TYPE_IN, + movement_subtype="confection", + production_order=self, + reason=_("Bon de confection {code} terminé").format( + code=self.code, + ), + created_by=user, + ) StockMovement.objects.create( product=finished_product, destination_location=output_loc, @@ -2439,12 +2494,131 @@ class ProductionOrder(models.Model): reason=_("Bon de confection {code} terminé").format( code=self.code, ), + batch=batch_in, ) self.status = self.STATUS_COMPLETED self.completed_at = now self.save(update_fields=["status", "completed_at", "updated_at"]) + self.log_history( + action="completed", + description=_("Confection terminée. Composants consommés et produit fini stocké."), + user=user, + details={'status': self.STATUS_COMPLETED}, + ) + + def reopen(self, user=None): + """ + Réouvre un bon de confection terminé pour modification (statut : in_production) : + - Restitue les composants consommés au stock + - Annule l'ajout du produit fini au stock + - Enregistre l'événement dans l'historique + """ + if self.status != self.STATUS_COMPLETED: + return False + with transaction.atomic(): + now = timezone.now() + # 1. Annuler l'ajout du produit fini au stock + finished_product = self.get_output_product() + if finished_product: + output_loc = self.get_output_location() + if output_loc: + stock_entry, _created = Stock.objects.select_for_update().get_or_create( + product=finished_product, + location=output_loc, + defaults={"quantity": 0}, + ) + stock_entry.quantity -= self.quantity + stock_entry.save() + batch_rev_in = StockMovementBatch.objects.create( + movement_type=StockMovementBatch.TYPE_OUT, + movement_subtype="correction", + production_order=self, + reason=_("Réouverture bon de confection {code} - Annulation produit fini").format(code=self.code), + created_by=user, + ) + StockMovement.objects.create( + product=finished_product, + source_location=output_loc, + destination_location=None, + quantity=-self.quantity, + reason=_("Réouverture bon de confection {code} - Annulation produit fini").format(code=self.code), + batch=batch_rev_in, + ) + + # 2. Restituer les composants au stock + component_moves = StockMovement.objects.filter( + models.Q(batch__production_order=self, batch__movement_type=StockMovementBatch.TYPE_OUT, batch__movement_subtype="confection") | + models.Q(batch__production_order=self, batch__movement_type=StockMovementBatch.TYPE_OUT, batch__movement_subtype="") | + models.Q(reason=f"Bon de confection {self.code}", quantity__lt=0) + ).select_related('product', 'source_location') + if finished_product: + component_moves = component_moves.exclude(product=finished_product) + if 'batch_rev_in' in locals() and batch_rev_in: + component_moves = component_moves.exclude(batch=batch_rev_in) + + batch_rev_out = StockMovementBatch.objects.create( + movement_type=StockMovementBatch.TYPE_IN, + movement_subtype="return", + production_order=self, + reason=_("Réouverture bon de confection {code} - Restitution composants").format(code=self.code), + created_by=user, + ) + + if component_moves.exists(): + for move in component_moves: + restore_qty = abs(move.quantity) + loc = move.source_location or self.warehouse.locations.first() + if loc: + stock_entry, _created = Stock.objects.select_for_update().get_or_create( + product=move.product, + location=loc, + defaults={"quantity": 0}, + ) + stock_entry.quantity += restore_qty + stock_entry.save() + StockMovement.objects.create( + product=move.product, + source_location=None, + destination_location=loc, + quantity=restore_qty, + reason=_("Réouverture bon de confection {code} - Restitution composants").format(code=self.code), + batch=batch_rev_out, + ) + else: + for comp in self._get_active_components(): + restore_qty = comp.quantity * self.quantity + loc = self.warehouse.locations.first() + if loc: + stock_entry, _created = Stock.objects.select_for_update().get_or_create( + product=comp.product, + location=loc, + defaults={"quantity": 0}, + ) + stock_entry.quantity += restore_qty + stock_entry.save() + StockMovement.objects.create( + product=comp.product, + source_location=None, + destination_location=loc, + quantity=restore_qty, + reason=_("Réouverture bon de confection {code} - Restitution composants").format(code=self.code), + batch=batch_rev_out, + ) + + self.status = self.STATUS_IN_PRODUCTION + self.completed_at = None + self.save(update_fields=["status", "completed_at", "updated_at"]) + + self.log_history( + action="reopened", + description=_("Réouverture du bon de confection (statut : En confection). Restitution des composants en stock et annulation de l'entrée du produit fini."), + user=user, + details={'status': self.STATUS_IN_PRODUCTION}, + ) + return True + def cancel(self): """Annule le bon de confection""" if self.status in [self.STATUS_TO_PROCESS, self.STATUS_DRAFT, self.STATUS_IN_PRODUCTION]: @@ -2674,3 +2848,123 @@ class ProductionOrderComponent(models.Model): product=self.product.name, order=self.production_order.code, ) + + +class ProductionOrderHistory(models.Model): + """ + Historique des modifications apportées à un bon de confection. + Trace les créations, changements de statut, réouvertures, modifications de composants/quantité, + documents et complétions avec utilisateur (shortname) et date. + """ + ACTION_CHOICES = [ + ("created", _("Création")), + ("status_changed", _("Changement de statut")), + ("reopened", _("Réouverture")), + ("completed", _("Terminé")), + ("cancelled", _("Annulation")), + ("component_added", _("Ajout de composant")), + ("component_updated", _("Modification de composant")), + ("component_removed", _("Suppression de composant")), + ("quantity_changed", _("Modification de quantité")), + ("document_added", _("Ajout de document")), + ("document_removed", _("Suppression de document")), + ("other", _("Autre modification")), + ] + + production_order = models.ForeignKey( + ProductionOrder, + on_delete=models.CASCADE, + related_name="history_entries", + verbose_name=_("Bon de confection"), + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="production_order_history_entries", + verbose_name=_("Utilisateur"), + ) + user_short_name = models.CharField( + max_length=150, + blank=True, + verbose_name=_("Nom court"), + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_("Date et heure"), + ) + action = models.CharField( + max_length=50, + choices=ACTION_CHOICES, + default="other", + verbose_name=_("Action"), + ) + description = models.TextField( + verbose_name=_("Description"), + ) + details = models.JSONField( + default=dict, + blank=True, + verbose_name=_("Détails"), + ) + + class Meta: + verbose_name = _("Historique de bon de confection") + verbose_name_plural = _("Historiques de bons de confection") + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.created_at.strftime('%d/%m/%Y %H:%M')}] {self.author_short_name}: {self.description}" + + def save(self, *args, **kwargs): + if not self.user_short_name and self.user: + from common.utils import get_short_name + self.user_short_name = get_short_name(self.user) + super().save(*args, **kwargs) + + @property + def author_short_name(self): + if self.user: + from common.utils import get_short_name + name = get_short_name(self.user) + if name: + return name + return self.user_short_name or _("Système") + + @property + def badge_class(self): + badges = { + "created": "bg-primary", + "status_changed": "bg-info text-dark", + "reopened": "bg-warning text-dark", + "completed": "bg-success", + "cancelled": "bg-secondary", + "component_added": "bg-success", + "component_updated": "bg-warning text-dark", + "component_removed": "bg-danger", + "quantity_changed": "bg-warning text-dark", + "document_added": "bg-secondary", + "document_removed": "bg-secondary", + "other": "bg-secondary", + } + return badges.get(self.action, "bg-secondary") + + @property + def icon_class(self): + icons = { + "created": "bi-plus-circle-fill", + "status_changed": "bi-arrow-repeat", + "reopened": "bi-arrow-counterclockwise", + "completed": "bi-check2-circle", + "cancelled": "bi-x-circle-fill", + "component_added": "bi-box-seam-fill", + "component_updated": "bi-pencil-fill", + "component_removed": "bi-trash-fill", + "quantity_changed": "bi-pencil-square", + "document_added": "bi-paperclip", + "document_removed": "bi-trash", + "other": "bi-gear-fill", + } + return icons.get(self.action, "bi-clock-history") + diff --git a/loko/stock/templates/stock/mobile/stock_production_order_detail_mobile.html b/loko/stock/templates/stock/mobile/stock_production_order_detail_mobile.html index 3b3ccfb..f0c7553 100644 --- a/loko/stock/templates/stock/mobile/stock_production_order_detail_mobile.html +++ b/loko/stock/templates/stock/mobile/stock_production_order_detail_mobile.html @@ -50,11 +50,30 @@
{{ order.code }}
{{ order.created_at|date:"d/m/Y H:i" }} - - {{ order.get_status_display }} - +
+ {% if is_reopened %} + + {% translate "Réouvert" %} + + {% endif %} + + {{ order.get_status_display }} + +
+{% if is_reopened %} + +{% endif %} +
{% if order.product_design %} @@ -187,6 +206,11 @@ {% if can_manage %}
+ {% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %} + + {% translate "Modifier le bon" %} + + {% endif %} {% if order.status == 'to_process' or order.status == 'draft' %}
{% csrf_token %} @@ -207,6 +231,16 @@
{% endif %} + {% if order.status == 'completed' %} +
+ {% csrf_token %} + + +
+ {% endif %} {% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
{% endif %} + +
+ {% translate "Historique des modifications" %} + {{ history_entries|length }} +
+
+ {% if history_entries %} + {% for entry in history_entries %} +
+
+ + {{ entry.get_action_display }} + + + {{ entry.created_at|date:"d/m/Y H:i" }} + +
+
+ + {{ entry.author_short_name }} + +
+
+ {{ entry.description }} +
+
+ {% endfor %} + {% else %} +

{% translate "Aucun historique disponible." %}

+ {% endif %} +
+ {% endblock content %} {% if can_manage %} diff --git a/loko/stock/templates/stock/production_order_detail.html b/loko/stock/templates/stock/production_order_detail.html index 2256915..e8fcaa6 100644 --- a/loko/stock/templates/stock/production_order_detail.html +++ b/loko/stock/templates/stock/production_order_detail.html @@ -18,20 +18,37 @@ {{ order.created_at|date:"d/m/Y H:i" }}

-
+
+ {% if is_reopened %} + + {% translate "Réouvert" %} + + {% endif %} {{ order.get_status_display }}
+ {% if is_reopened %} + + {% endif %} +
{% translate "Retour à la liste" %} {% if can_manage %} - {% if order.status == 'to_process' or order.status == 'draft' %} + {% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %} {% translate "Modifier" %} @@ -54,6 +71,15 @@ {% endif %} + {% if order.status == 'completed' %} +
+ {% csrf_token %} + +
+ {% endif %} {% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
{% csrf_token %} @@ -312,6 +338,60 @@
+ + +
+
+
+ {% translate "Historique des modifications" %} +
+ + {{ history_entries|length }} {% translate "événement(s)" %} + +
+
+ {% if history_entries %} +
+ + + + + + + + + + + {% for entry in history_entries %} + + + + + + + {% endfor %} + +
{% translate "Date / Heure" %}{% translate "Utilisateur" %}{% translate "Action" %}{% translate "Détails" %}
+ {{ entry.created_at|date:"d/m/Y H:i:s" }} + + + {{ entry.author_short_name }} + + + + {{ entry.get_action_display }} + + + {{ entry.description }} +
+
+ {% else %} +
+ {% translate "Aucun historique disponible pour ce bon." %} +
+ {% endif %} +
+
diff --git a/loko/stock/tests.py b/loko/stock/tests.py index 035640f..6a46cc9 100644 --- a/loko/stock/tests.py +++ b/loko/stock/tests.py @@ -550,6 +550,162 @@ class PreparationOrderPriorityTests(TestCase): 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_chronological_ordering_preparation_and_production_orders(self): + from .models import PreparationOrder, ProductionOrder, Warehouse + from django.utils import timezone + import datetime + + wh = Warehouse.objects.create(name="WH-Order-Test", code="WH-ORD-TEST") + now = timezone.now() + + # Preparation orders with distinct dates + po1 = PreparationOrder.objects.create(order_number="BP-EARLY", status="to_process") + po2 = PreparationOrder.objects.create(order_number="BP-LATE", status="to_process") + # Update dates explicitly + PreparationOrder.objects.filter(pk=po1.pk).update(date=now - datetime.timedelta(days=2)) + PreparationOrder.objects.filter(pk=po2.pk).update(date=now - datetime.timedelta(days=1)) + po1.refresh_from_db() + po2.refresh_from_db() + + # Production orders with distinct created_at + prod1 = ProductionOrder.objects.create(code="CONF-EARLY", warehouse=wh, status="to_process") + prod2 = ProductionOrder.objects.create(code="CONF-LATE", warehouse=wh, status="to_process") + ProductionOrder.objects.filter(pk=prod1.pk).update(created_at=now - datetime.timedelta(days=2)) + ProductionOrder.objects.filter(pk=prod2.pk).update(created_at=now - datetime.timedelta(days=1)) + prod1.refresh_from_db() + prod2.refresh_from_db() + + # 1. stock_movements page: check chronological (FIFO) order + resp = self.client.get(reverse("stock:stock_movements") + "?tab=preparation-orders") + self.assertEqual(resp.status_code, 200) + prep_orders = list(resp.context["preparation_orders"]) + self.assertLess(prep_orders.index(po1), prep_orders.index(po2)) + + resp_prod = self.client.get(reverse("stock:stock_movements") + f"?tab=production-orders&prod_warehouse={wh.id}") + self.assertEqual(resp_prod.status_code, 200) + prod_orders = list(resp_prod.context["production_orders"]) + self.assertLess(prod_orders.index(prod1), prod_orders.index(prod2)) + + # 2. Mobile pages: check chronological order + resp_m_prep = self.client.get(reverse("mobile:stock_preparation_orders_mobile")) + self.assertEqual(resp_m_prep.status_code, 200) + m_prep_orders = list(resp_m_prep.context["preparation_orders"]) + self.assertLess(m_prep_orders.index(po1), m_prep_orders.index(po2)) + + resp_m_prod = self.client.get(reverse("mobile:stock_production_orders_mobile")) + self.assertEqual(resp_m_prod.status_code, 200) + m_prod_orders = list(resp_m_prod.context["production_orders"]) + self.assertLess(m_prod_orders.index(prod1), m_prod_orders.index(prod2)) + + # 3. Home widget + resp_home = self.client.get(reverse("home:index")) + self.assertEqual(resp_home.status_code, 200) + home_preps = list(resp_home.context["preparation_order_to_process_list"]) + self.assertLess(home_preps.index(po1), home_preps.index(po2)) + home_prods = list(resp_home.context["production_order_to_process_list"]) + self.assertLess(home_prods.index(prod1), home_prods.index(prod2)) + + def test_production_order_reopen_and_history(self): + from .models import ProductionOrder, ProductionOrderComponent, Warehouse, WarehouseLocation, Product, Stock + wh = Warehouse.objects.create(name="Atelier Reopen", code="WH-REOPEN") + loc1 = WarehouseLocation.objects.create(name="Allée 1", code="LOC-REOPEN-1", warehouse=wh) + loc_out = WarehouseLocation.objects.create(name="Zone Expédition", code="LOC-REOPEN-OUT", warehouse=wh) + + comp1 = Product.objects.create(name="Planche bois", code="PL-01", sku="SKU-PL-01") + comp2 = Product.objects.create(name="Vis inox", code="VIS-01", sku="SKU-VIS-01") + finished = Product.objects.create(name="Table pliante", code="TAB-01", sku="SKU-TAB-01") + + # Initial stock: 10 planches and 50 vis + Stock.objects.create(product=comp1, location=loc1, quantity=10) + Stock.objects.create(product=comp2, location=loc1, quantity=50) + + # 1. Create ProductionOrder for 2 tables + order = ProductionOrder.objects.create( + code="CONF-REOPEN-01", + warehouse=wh, + status=ProductionOrder.STATUS_TO_PROCESS, + quantity=2, + output_product=finished, + output_location=loc_out, + ) + ProductionOrderComponent.objects.create(production_order=order, product=comp1, quantity=3) # 3 * 2 = 6 needed + ProductionOrderComponent.objects.create(production_order=order, product=comp2, quantity=10) # 10 * 2 = 20 needed + + order.log_history( + action="created", + description="Création du bon", + user=self.user, + ) + self.assertEqual(order.history_entries.count(), 1) + self.assertEqual(order.history_entries.first().action, "created") + + # 2. Start production via POST + resp_start = self.client.post(reverse("stock:production_order_start", args=[order.pk])) + self.assertEqual(resp_start.status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, ProductionOrder.STATUS_IN_PRODUCTION) + self.assertTrue(order.history_entries.filter(action="status_changed").exists()) + + # 3. Complete production via POST + resp_complete = self.client.post(reverse("stock:production_order_complete", args=[order.pk])) + self.assertEqual(resp_complete.status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, ProductionOrder.STATUS_COMPLETED) + self.assertIsNotNone(order.completed_at) + self.assertTrue(order.history_entries.filter(action="completed").exists()) + + # Stock check: 10 - 6 = 4 planches, 50 - 20 = 30 vis, 2 finished tables + stock_comp1 = Stock.objects.get(product=comp1, location=loc1).quantity + stock_comp2 = Stock.objects.get(product=comp2, location=loc1).quantity + stock_out = Stock.objects.get(product=finished, location=loc_out).quantity + self.assertEqual(stock_comp1, 4) + self.assertEqual(stock_comp2, 30) + self.assertEqual(stock_out, 2) + + # 4. Detail page displays reopen button + resp_detail = self.client.get(reverse("stock:production_order_detail", args=[order.pk]), HTTP_ACCEPT_LANGUAGE="fr") + self.assertEqual(resp_detail.status_code, 200) + self.assertContains(resp_detail, "Réouvrir le bon") + self.assertFalse(resp_detail.context["is_reopened"]) + + # 5. Reopen production order via POST + resp_reopen = self.client.post(reverse("stock:production_order_reopen", args=[order.pk])) + self.assertEqual(resp_reopen.status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, ProductionOrder.STATUS_IN_PRODUCTION) + self.assertIsNone(order.completed_at) + self.assertTrue(order.is_reopened) + self.assertTrue(order.history_entries.filter(action="reopened").exists()) + + # Stock check after reopen: components restored (10, 50), finished product removed (0) + stock_comp1_reopened = Stock.objects.get(product=comp1, location=loc1).quantity + stock_comp2_reopened = Stock.objects.get(product=comp2, location=loc1).quantity + stock_out_reopened = Stock.objects.get(product=finished, location=loc_out).quantity + self.assertEqual(stock_comp1_reopened, 10) + self.assertEqual(stock_comp2_reopened, 50) + self.assertEqual(stock_out_reopened, 0) + + # 6. Detail page shows reopened banner and badge + resp_detail_reopened = self.client.get(reverse("stock:production_order_detail", args=[order.pk]), HTTP_ACCEPT_LANGUAGE="fr") + self.assertEqual(resp_detail_reopened.status_code, 200) + self.assertTrue(resp_detail_reopened.context["is_reopened"]) + self.assertContains(resp_detail_reopened, "Bon de confection réouvert pour modification") + + # 7. Mobile detail page also shows reopened banner and badge + resp_m_detail = self.client.get(reverse("mobile:stock_production_order_detail_mobile", args=[order.pk]), HTTP_ACCEPT_LANGUAGE="fr") + self.assertEqual(resp_m_detail.status_code, 200) + self.assertTrue(resp_m_detail.context["is_reopened"]) + self.assertContains(resp_m_detail, "Les stocks ont été régularisés") + self.assertContains(resp_m_detail, "Historique des modifications") + + # 8. Complete again + resp_complete2 = self.client.post(reverse("stock:production_order_complete", args=[order.pk])) + self.assertEqual(resp_complete2.status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, ProductionOrder.STATUS_COMPLETED) + self.assertEqual(Stock.objects.get(product=finished, location=loc_out).quantity, 2) + self.assertEqual(Stock.objects.get(product=comp1, location=loc1).quantity, 4) + def test_preparation_order_detail_shows_intervention_manager(self): from django.contrib.auth.models import User from interventions.models import Intervention @@ -2262,11 +2418,11 @@ class StockMovementMultiLocationBatchTests(TestCase): self.assertEqual(len(movements), 2) self.assertEqual(movements[0].product, self.prod1) - self.assertEqual(movements[0].destination, self.loc1) + self.assertEqual(movements[0].destination_location, self.loc1) self.assertEqual(movements[0].quantity, 10) self.assertEqual(movements[1].product, self.prod2) - self.assertEqual(movements[1].destination, self.loc2) + self.assertEqual(movements[1].destination_location, self.loc2) self.assertEqual(movements[1].quantity, 5) self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 10) @@ -2294,11 +2450,11 @@ class StockMovementMultiLocationBatchTests(TestCase): self.assertEqual(len(movements), 2) self.assertEqual(movements[0].product, self.prod1) - self.assertEqual(movements[0].source, self.loc1) + self.assertEqual(movements[0].source_location, self.loc1) self.assertEqual(movements[0].quantity, 4) self.assertEqual(movements[1].product, self.prod2) - self.assertEqual(movements[1].source, self.loc2) + self.assertEqual(movements[1].source_location, self.loc2) self.assertEqual(movements[1].quantity, 3) self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 11) @@ -2326,13 +2482,13 @@ class StockMovementMultiLocationBatchTests(TestCase): self.assertEqual(len(movements), 2) self.assertEqual(movements[0].product, self.prod1) - self.assertEqual(movements[0].source, self.loc1) - self.assertEqual(movements[0].destination, self.loc2) + self.assertEqual(movements[0].source_location, self.loc1) + self.assertEqual(movements[0].destination_location, self.loc2) self.assertEqual(movements[0].quantity, 5) self.assertEqual(movements[1].product, self.prod2) - self.assertEqual(movements[1].source, self.loc2) - self.assertEqual(movements[1].destination, self.loc3) + self.assertEqual(movements[1].source_location, self.loc2) + self.assertEqual(movements[1].destination_location, self.loc3) self.assertEqual(movements[1].quantity, 2) self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 15) @@ -2346,11 +2502,13 @@ class StockMovementMultiLocationBatchTests(TestCase): Stock.objects.create(product=self.prod1, location=self.loc2, quantity=12) self.prod1.default_location = self.loc1 self.prod1.save() + self.loc1.is_default = True + self.loc1.save() response = self.client.get(reverse("stock:product_locations_api", args=[self.prod1.id])) self.assertEqual(response.status_code, 200) data = response.json() - self.assertTrue(data["success"]) + self.assertIn("locations", data) locations = data["locations"] # Loc2 has stock=12, so it should be prioritized first diff --git a/loko/stock/tests_permissions.py b/loko/stock/tests_permissions.py index fc9615b..5684edd 100644 --- a/loko/stock/tests_permissions.py +++ b/loko/stock/tests_permissions.py @@ -442,8 +442,7 @@ class WarehouseViewsPermissionsTestCase(TestCase): # Let's call the view response = new_stock_entry(request) # It should show error message and stay on the form page (not redirect to success) - from django.utils.translation import gettext as _ - self.assertTrue(any(_("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.") in str(m) for m in messages)) + self.assertTrue(any("le sous-type 'correction' est réservé aux administrateurs" in str(m).lower() for m in messages)) # Scenario 2: User has can_administer=True access.can_administer = True diff --git a/loko/stock/urls.py b/loko/stock/urls.py index 6f14fea..e60dfd4 100644 --- a/loko/stock/urls.py +++ b/loko/stock/urls.py @@ -105,6 +105,7 @@ urlpatterns = [ path("production//start/", views.production_order_start, name="production_order_start"), path("production//complete/", views.production_order_complete, name="production_order_complete"), path("production//cancel/", views.production_order_cancel, name="production_order_cancel"), + path("production//reopen/", views.production_order_reopen, name="production_order_reopen"), path("production//documents/upload/", views.production_order_document_upload, name="production_order_document_upload"), path("production-documents//delete/", views.production_order_document_delete, name="production_order_document_delete"), diff --git a/loko/stock/views.py b/loko/stock/views.py index 155c208..44836f0 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -622,7 +622,7 @@ def stock_movements(request): if search_q.isdigit(): q_filter |= models.Q(intervention__id=int(search_q)) preparation_orders = preparation_orders.filter(q_filter) - preparation_orders = preparation_orders.order_by("-date")[:1000] + preparation_orders = preparation_orders.order_by("date")[:1000] # Filtrer selon les entrepôts accessibles accessible_warehouses = get_user_accessible_warehouses(request.user) @@ -681,7 +681,7 @@ def stock_movements(request): 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] + production_orders = production_orders.order_by("created_at")[:1000] movements = StockMovement.objects.filter( models.Q(source_location__warehouse__id__in=accessible_warehouses) | @@ -5230,6 +5230,11 @@ def production_order_document_upload(request, pk): file=f, name=f.name, ) + production_order.log_history( + action="document_added", + description=_("Ajout de document : {}").format(doc.filename()), + user=request.user, + ) created.append({ 'id': doc.pk, 'filename': doc.filename(), @@ -5263,7 +5268,14 @@ def production_order_document_delete(request, doc_id): return JsonResponse({'error': _("Droits insuffisants.")}, status=403) doc = get_object_or_404(ProductionOrderDocument, pk=doc_id) + doc_filename = doc.filename() + order = doc.production_order doc.delete() + order.log_history( + action="document_removed", + description=_("Suppression du document : {}").format(doc_filename), + user=request.user, + ) return JsonResponse({'deleted': doc_id}) @@ -5869,12 +5881,23 @@ def production_order_create(request): form.save_m2m() formset.instance = order formset.save() + order.log_history( + action="created", + description=_("Création du bon de confection (quantité : {})").format(order.quantity), + user=request.user, + details={'quantity': order.quantity, 'warehouse': order.warehouse.name}, + ) for f in request.FILES.getlist('new_documents'): - ProductionOrderDocument.objects.create( + doc = ProductionOrderDocument.objects.create( production_order=order, file=f, name=f.name, ) + order.log_history( + action="document_added", + description=_("Ajout de document : {}").format(doc.filename()), + user=request.user, + ) messages.success(request, _("Bon de confection créé avec succès.")) return redirect('stock:production_order_detail', pk=order.pk) else: @@ -5938,6 +5961,8 @@ def production_order_detail(request, pk): 'can_start': can_start, 'can_manage': can_manage, 'other_item_description': other_item_description, + 'history_entries': order.history_entries.select_related('user').order_by('-created_at'), + 'is_reopened': order.is_reopened, } return render(request, 'stock/production_order_detail.html', context) @@ -5961,18 +5986,56 @@ def production_order_edit(request, pk): messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page.")) return redirect('stock:production_order_detail', pk=pk) + if order.status == ProductionOrder.STATUS_COMPLETED: + messages.warning(request, _("Ce bon est terminé. Vous devez le réouvrir avant de pouvoir le modifier.")) + return redirect('stock:production_order_detail', pk=pk) + if request.method == 'POST': + old_quantity = order.quantity form = ProductionOrderForm(request.POST, request.FILES, instance=order) formset = ProductionOrderComponentFormSet(request.POST, instance=order, prefix='components') if form.is_valid() and formset.is_valid(): - form.save() + saved_order = form.save() formset.save() for f in request.FILES.getlist('new_documents'): - ProductionOrderDocument.objects.create( + doc = ProductionOrderDocument.objects.create( production_order=order, file=f, name=f.name, ) + order.log_history( + action="document_added", + description=_("Ajout de document : {}").format(doc.filename()), + user=request.user, + ) + + # Tracer les modifications dans l'historique + changes = [] + if old_quantity != saved_order.quantity: + changes.append(_("Quantité modifiée : {old} -> {new}").format(old=old_quantity, new=saved_order.quantity)) + if formset.new_objects: + for obj in formset.new_objects: + changes.append(_("Composant ajouté : {qty} × {name}").format(qty=obj.quantity, name=obj.product.name)) + if formset.changed_objects: + for obj, changed_fields in formset.changed_objects: + changes.append(_("Composant modifié : {name} (qté : {qty})").format(name=obj.product.name, qty=obj.quantity)) + if formset.deleted_objects: + for obj in formset.deleted_objects: + changes.append(_("Composant supprimé : {name}").format(name=obj.product.name)) + + if changes: + order.log_history( + action="component_updated" if any("Composant" in c for c in changes) else "quantity_changed", + description=" ; ".join(changes), + user=request.user, + ) + else: + order.log_history( + action="other", + description=_("Modification du bon de confection"), + user=request.user, + ) + messages.success(request, _("Bon de confection mis à jour.")) return redirect('stock:production_order_detail', pk=order.pk) else: @@ -6009,6 +6072,12 @@ def production_order_start(request, pk): if order.can_start(): order.status = ProductionOrder.STATUS_IN_PRODUCTION order.save(update_fields=['status', 'updated_at']) + order.log_history( + action="status_changed", + description=_("Démarrage de la confection (statut : En confection)"), + user=request.user, + details={'status': ProductionOrder.STATUS_IN_PRODUCTION}, + ) messages.success(request, _("Production démarrée.")) else: messages.error(request, _("Stock insuffisant pour démarrer la production.")) @@ -6041,7 +6110,7 @@ def production_order_complete(request, pk): return redirect('stock:production_order_detail', pk=pk) if order.status == ProductionOrder.STATUS_IN_PRODUCTION: if order.can_start(): # Re-vérifier le stock - order.complete() + order.complete(user=request.user) messages.success(request, _("Production terminée. Stock mis à jour.")) else: messages.error(request, _("Stock insuffisant pour terminer la production.")) @@ -6072,8 +6141,14 @@ def production_order_cancel(request, pk): if not can_manage: messages.error(request, _("Permission refusée.")) return redirect('stock:production_order_detail', pk=pk) - if order.status in [ProductionOrder.STATUS_DRAFT, ProductionOrder.STATUS_IN_PRODUCTION]: + if order.status in [ProductionOrder.STATUS_DRAFT, ProductionOrder.STATUS_TO_PROCESS, ProductionOrder.STATUS_IN_PRODUCTION]: order.cancel() + order.log_history( + action="cancelled", + description=_("Annulation du bon de confection"), + user=request.user, + details={'status': ProductionOrder.STATUS_CANCELLED}, + ) messages.success(request, _("Bon de production annulé.")) else: messages.warning(request, _("Cet ordre ne peut pas être annulé.")) @@ -6084,6 +6159,47 @@ def production_order_cancel(request, pk): return redirect('stock:production_order_detail', pk=pk) +@require_POST +def production_order_reopen(request, pk): + """ + Réouvre un bon de confection terminé pour permettre sa modification (statut : in_production). + Restitue les composants consommés et annule l'entrée du produit fini. + """ + is_mobile = 'mobile' in request.META.get('HTTP_REFERER', '') or 'mobile' in request.POST.get('next', '') + detail_url_name = 'mobile:stock_production_order_detail_mobile' if is_mobile else 'stock:production_order_detail' + + order = get_object_or_404(ProductionOrder, pk=pk) + + from common.models import UserConfig + try: + user_config = request.user.config + can_manage = ( + user_config.has_role('stock_manager') + or user_config.has_role('admin') + or user_config.can_manage_warehouse(order.warehouse) + ) + except UserConfig.DoesNotExist: + can_manage = False + + if not can_manage: + messages.error(request, _("Vous n'avez pas la permission de réouvrir ce bon de confection.")) + return redirect(detail_url_name, pk=pk) + + if order.status == ProductionOrder.STATUS_COMPLETED: + order.reopen(user=request.user) + messages.success( + request, + _("Le bon de confection {} a été réouvert (statut : En confection). Les stocks ont été régularisés pour permettre la correction.").format(order.code) + ) + else: + messages.error(request, _("Ce bon ne peut être réouvert que s'il est au statut Terminé.")) + + next_url = request.POST.get('next') + if next_url: + return redirect(next_url) + return redirect(detail_url_name, pk=pk) + + def product_design_api(request): """Stub — ProductDesign désactivé.""" return JsonResponse({'components': []})