feat(stock): tri chronologique des bons et réouverture/historique des bons de confection

- 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
This commit is contained in:
kdeterme 2026-09-29 22:38:34 +02:00
parent c25c4a4415
commit 63a9a080de
10 changed files with 804 additions and 30 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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'],
},
),
]

View file

@ -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")

View file

@ -50,10 +50,29 @@
<h5 class="mb-0 fw-bold">{{ order.code }}</h5>
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y H:i" }}</small>
</div>
<div class="d-flex align-items-center gap-1">
{% if is_reopened %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Bon de confection réouvert pour modification' %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réouvert" %}
</span>
{% endif %}
<span class="badge {{ order.status_badge_class }} fs-6">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
</div>
</div>
{% if is_reopened %}
<div class="alert alert-warning border-warning shadow-sm mb-3 py-2 px-3" role="alert" style="font-size:0.85rem;">
<div class="d-flex align-items-center">
<i class="bi bi-arrow-counterclockwise text-warning me-2 fs-4 flex-shrink-0"></i>
<div>
<strong>{% translate "Bon réouvert pour modification" %}</strong>
<div class="small">{% translate "Les stocks ont été régularisés. Vous pouvez modifier et terminer à nouveau la confection." %}</div>
</div>
</div>
</div>
{% endif %}
<!-- Informations générales -->
<div class="info-card">
@ -187,6 +206,11 @@
<!-- Actions -->
{% if can_manage %}
<div class="d-grid gap-2 mt-3">
{% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
<a href="{% url 'stock:production_order_edit' order.pk %}?next={% url 'mobile:stock_production_order_detail_mobile' order.pk %}" class="btn btn-outline-primary w-100">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier le bon" %}
</a>
{% endif %}
{% if order.status == 'to_process' or order.status == 'draft' %}
<form method="post" action="{% url 'stock:production_order_start' order.pk %}">
{% csrf_token %}
@ -207,6 +231,16 @@
</button>
</form>
{% endif %}
{% if order.status == 'completed' %}
<form method="post" action="{% url 'stock:production_order_reopen' order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir réouvrir ce bon de confection ? Les composants seront restitués au stock et l\'entrée du produit fini sera annulée pour permettre la correction." %}');">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'mobile:stock_production_order_detail_mobile' order.pk %}">
<button type="submit" class="btn btn-warning w-100">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réouvrir le bon" %}
</button>
</form>
{% endif %}
{% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler le bon" %}
@ -244,6 +278,38 @@
</div>
{% endif %}
<!-- Historique des modifications -->
<div class="section-title mt-4 mb-2 fw-bold" style="font-size:0.95rem;">
<i class="bi bi-clock-history me-1"></i>{% translate "Historique des modifications" %}
<span class="badge bg-secondary ms-1">{{ history_entries|length }}</span>
</div>
<div class="info-card">
{% if history_entries %}
{% for entry in history_entries %}
<div class="py-2 border-bottom {% if forloop.last %}border-bottom-0{% endif %}">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge {{ entry.badge_class }}" style="font-size:0.75rem;">
<i class="bi {{ entry.icon_class }} me-1"></i>{{ entry.get_action_display }}
</span>
<small class="text-muted" style="font-size:0.75rem;">
{{ entry.created_at|date:"d/m/Y H:i" }}
</small>
</div>
<div class="d-flex justify-content-between align-items-baseline">
<span class="small fw-semibold text-secondary">
<i class="bi bi-person me-1"></i>{{ entry.author_short_name }}
</span>
</div>
<div class="small text-dark mt-1" style="font-size:0.83rem;">
{{ entry.description }}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic mb-0 small text-center py-2">{% translate "Aucun historique disponible." %}</p>
{% endif %}
</div>
{% endblock content %}
{% if can_manage %}

View file

@ -18,20 +18,37 @@
<i class="bi bi-calendar"></i> {{ order.created_at|date:"d/m/Y H:i" }}
</p>
</div>
<div>
<div class="d-flex align-items-center gap-2">
{% if is_reopened %}
<span class="badge bg-warning text-dark fs-5" title="{% translate 'Bon de confection réouvert pour modification' %}">
<i class="bi bi-arrow-counterclockwise me-1"></i> {% translate "Réouvert" %}
</span>
{% endif %}
<span class="badge {{ order.status_badge_class }} fs-5">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
</div>
</div>
{% if is_reopened %}
<div class="alert alert-warning border-warning shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center">
<i class="bi bi-arrow-counterclockwise text-warning me-3 fs-2"></i>
<div>
<h5 class="alert-heading mb-1"><i class="bi bi-exclamation-circle-fill me-1"></i> {% translate "Bon de confection réouvert pour modification" %}</h5>
<p class="mb-0 small">{% 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." %}</p>
</div>
</div>
</div>
{% endif %}
<!-- Boutons d'action -->
<div class="mb-4">
<a href="{% url 'stock:stock_movements' %}?tab=production-orders" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> {% translate "Retour à la liste" %}
</a>
{% 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' %}
<a href="{% url 'stock:production_order_edit' order.pk %}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
</a>
@ -54,6 +71,15 @@
</button>
</form>
{% endif %}
{% if order.status == 'completed' %}
<form method="post" action="{% url 'stock:production_order_reopen' order.pk %}" class="d-inline"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir réouvrir ce bon de confection ? Les composants seront restitués au stock et l\'entrée du produit fini sera annulée pour permettre la correction." %}');">
{% csrf_token %}
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Réouvrir le bon" %}
</button>
</form>
{% endif %}
{% if order.status == 'to_process' or order.status == 'draft' or order.status == 'in_production' %}
<form method="post" action="{% url 'stock:production_order_cancel' order.pk %}" class="d-inline">
{% csrf_token %}
@ -312,6 +338,60 @@
</div>
</div>
</div>
<!-- Historique des modifications -->
<div class="card mb-3 shadow-sm mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-clock-history me-1"></i> {% translate "Historique des modifications" %}
</h5>
<span class="badge bg-secondary">
{{ history_entries|length }} {% translate "événement(s)" %}
</span>
</div>
<div class="card-body p-0">
{% if history_entries %}
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 170px;">{% translate "Date / Heure" %}</th>
<th style="width: 160px;">{% translate "Utilisateur" %}</th>
<th style="width: 160px;">{% translate "Action" %}</th>
<th>{% translate "Détails" %}</th>
</tr>
</thead>
<tbody>
{% for entry in history_entries %}
<tr>
<td class="text-nowrap small text-muted">
<i class="bi bi-calendar3 me-1"></i>{{ entry.created_at|date:"d/m/Y H:i:s" }}
</td>
<td>
<span class="fw-semibold">
<i class="bi bi-person me-1 text-secondary"></i>{{ entry.author_short_name }}
</span>
</td>
<td>
<span class="badge {{ entry.badge_class }}">
<i class="bi {{ entry.icon_class }} me-1"></i>{{ entry.get_action_display }}
</span>
</td>
<td>
{{ entry.description }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted text-center">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun historique disponible pour ce bon." %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>

View file

@ -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

View file

@ -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

View file

@ -105,6 +105,7 @@ urlpatterns = [
path("production/<int:pk>/start/", views.production_order_start, name="production_order_start"),
path("production/<int:pk>/complete/", views.production_order_complete, name="production_order_complete"),
path("production/<int:pk>/cancel/", views.production_order_cancel, name="production_order_cancel"),
path("production/<int:pk>/reopen/", views.production_order_reopen, name="production_order_reopen"),
path("production/<int:pk>/documents/upload/", views.production_order_document_upload, name="production_order_document_upload"),
path("production-documents/<int:doc_id>/delete/", views.production_order_document_delete, name="production_order_document_delete"),

View file

@ -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': []})