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 %}
+
+
+
+
+
{% translate "Bon réouvert pour modification" %}
+
{% translate "Les stocks ont été régularisés. Vous pouvez modifier et terminer à nouveau la confection." %}
+
+
+
+{% 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' %}
{% endif %}
+ {% if order.status == 'completed' %}
+
+ {% endif %}
{% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
{% translate "Annuler le bon" %}
@@ -244,6 +278,38 @@
{% 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 %}
+
+
+
+
+
{% translate "Bon de confection réouvert pour modification" %}
+
{% translate "Ce bon a été réouvert pour correction. Les composants ont été restitués au stock et l'entrée du produit fini a été annulée. Vous pouvez modifier les composants ou la quantité, puis terminer à nouveau la confection." %}
+
+
+
+ {% 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' %}
+
+ {% endif %}
{% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
+
+
+
+
+
+ {% if history_entries %}
+
+
+
+
+ {% translate "Date / Heure" %}
+ {% translate "Utilisateur" %}
+ {% translate "Action" %}
+ {% translate "Détails" %}
+
+
+
+ {% for entry in history_entries %}
+
+
+ {{ entry.created_at|date:"d/m/Y H:i:s" }}
+
+
+
+ {{ entry.author_short_name }}
+
+
+
+
+ {{ entry.get_action_display }}
+
+
+
+ {{ entry.description }}
+
+
+ {% endfor %}
+
+
+
+ {% 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': []})