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:
parent
c25c4a4415
commit
63a9a080de
10 changed files with 804 additions and 30 deletions
|
|
@ -488,7 +488,7 @@ def index(request):
|
||||||
preparation_order_to_process_only_count = _prep_counts['to_process']
|
preparation_order_to_process_only_count = _prep_counts['to_process']
|
||||||
preparation_order_pending_count = _prep_counts['pending']
|
preparation_order_pending_count = _prep_counts['pending']
|
||||||
_prep_qs = PreparationOrder.objects.filter(status__in=['to_process', '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
|
from stock.permissions import get_user_accessible_warehouses
|
||||||
accessible_warehouses = get_user_accessible_warehouses(request.user)
|
accessible_warehouses = get_user_accessible_warehouses(request.user)
|
||||||
|
|
@ -503,7 +503,7 @@ def index(request):
|
||||||
production_order_in_production_count = _prod_counts['in_production']
|
production_order_in_production_count = _prod_counts['in_production']
|
||||||
_prod_qs = _prod_base_qs.filter(status__in=['to_process', 'in_production'])
|
_prod_qs = _prod_base_qs.filter(status__in=['to_process', 'in_production'])
|
||||||
production_order_to_process_list = list(
|
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(
|
_products_with_active_po = PurchaseOrderItem.objects.filter(
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,7 @@ def stock_preparation_orders_mobile(request):
|
||||||
|
|
||||||
preparation_orders = preparation_orders.prefetch_related(
|
preparation_orders = preparation_orders.prefetch_related(
|
||||||
'preparationorderitem_set__product'
|
'preparationorderitem_set__product'
|
||||||
).order_by('-date')[:200]
|
).order_by('date')[:200]
|
||||||
return render(request, "stock/mobile/stock_preparation_orders_mobile.html", {
|
return render(request, "stock/mobile/stock_preparation_orders_mobile.html", {
|
||||||
'preparation_orders': preparation_orders,
|
'preparation_orders': preparation_orders,
|
||||||
'selected_status': selected_status,
|
'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))
|
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.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", {
|
return render(request, "stock/mobile/stock_production_orders_mobile.html", {
|
||||||
'production_orders': production_orders,
|
'production_orders': production_orders,
|
||||||
|
|
@ -602,6 +602,8 @@ def stock_production_order_detail_mobile(request, pk):
|
||||||
'can_start': can_start,
|
'can_start': can_start,
|
||||||
'can_manage': can_manage,
|
'can_manage': can_manage,
|
||||||
'other_item_description': other_item_description,
|
'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()
|
form.save_m2m()
|
||||||
formset.instance = order
|
formset.instance = order
|
||||||
formset.save()
|
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
|
from stock.models import ProductionOrderDocument
|
||||||
for f in request.FILES.getlist('new_documents'):
|
for f in request.FILES.getlist('new_documents'):
|
||||||
ProductionOrderDocument.objects.create(
|
doc = ProductionOrderDocument.objects.create(
|
||||||
production_order=order,
|
production_order=order,
|
||||||
file=f,
|
file=f,
|
||||||
name=f.name,
|
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."))
|
messages.success(request, _("Bon de confection créé avec succès."))
|
||||||
return redirect('mobile:stock_production_order_detail_mobile', pk=order.pk)
|
return redirect('mobile:stock_production_order_detail_mobile', pk=order.pk)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -407,6 +407,14 @@ class StockMovementBatch(models.Model):
|
||||||
related_name="stock_batches",
|
related_name="stock_batches",
|
||||||
verbose_name=_("Commande d'achat"),
|
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(
|
requester = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -1442,6 +1450,11 @@ class PreparationOrder(models.Model):
|
||||||
'4': 'bg-secondary',
|
'4': 'bg-secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("date",)
|
||||||
|
verbose_name = _("Bon de préparation")
|
||||||
|
verbose_name_plural = _("Bons de préparation")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Preparation Order #{self.order_number}"
|
return f"Preparation Order #{self.order_number}"
|
||||||
|
|
||||||
|
|
@ -2235,7 +2248,16 @@ class ProductionOrder(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
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
|
@property
|
||||||
def display_output_product_name(self):
|
def display_output_product_name(self):
|
||||||
|
|
@ -2381,11 +2403,27 @@ class ProductionOrder(models.Model):
|
||||||
return self.output_location
|
return self.output_location
|
||||||
return self.warehouse.locations.first()
|
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é :
|
Marque le bon de confection comme terminé :
|
||||||
- Consomme le stock des composants
|
- Consomme le stock des composants
|
||||||
- Ajoute le produit fini au stock
|
- Ajoute le produit fini au stock
|
||||||
|
- Enregistre les mouvements dans des lots associés
|
||||||
|
- Trace l'historique
|
||||||
"""
|
"""
|
||||||
if self.status == self.STATUS_COMPLETED:
|
if self.status == self.STATUS_COMPLETED:
|
||||||
return
|
return
|
||||||
|
|
@ -2395,6 +2433,13 @@ class ProductionOrder(models.Model):
|
||||||
# 1. Consommer les composants
|
# 1. Consommer les composants
|
||||||
active_components = self._get_active_components()
|
active_components = self._get_active_components()
|
||||||
if 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:
|
for comp in active_components:
|
||||||
needed = comp.quantity * self.quantity
|
needed = comp.quantity * self.quantity
|
||||||
# Prendre le stock depuis les emplacements de l'entrepôt
|
# Prendre le stock depuis les emplacements de l'entrepôt
|
||||||
|
|
@ -2418,6 +2463,7 @@ class ProductionOrder(models.Model):
|
||||||
reason=_("Bon de confection {code}").format(
|
reason=_("Bon de confection {code}").format(
|
||||||
code=self.code,
|
code=self.code,
|
||||||
),
|
),
|
||||||
|
batch=batch_out,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Ajouter le produit fini au stock
|
# 2. Ajouter le produit fini au stock
|
||||||
|
|
@ -2432,6 +2478,15 @@ class ProductionOrder(models.Model):
|
||||||
)
|
)
|
||||||
stock_entry.quantity += self.quantity
|
stock_entry.quantity += self.quantity
|
||||||
stock_entry.save()
|
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(
|
StockMovement.objects.create(
|
||||||
product=finished_product,
|
product=finished_product,
|
||||||
destination_location=output_loc,
|
destination_location=output_loc,
|
||||||
|
|
@ -2439,12 +2494,131 @@ class ProductionOrder(models.Model):
|
||||||
reason=_("Bon de confection {code} terminé").format(
|
reason=_("Bon de confection {code} terminé").format(
|
||||||
code=self.code,
|
code=self.code,
|
||||||
),
|
),
|
||||||
|
batch=batch_in,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.status = self.STATUS_COMPLETED
|
self.status = self.STATUS_COMPLETED
|
||||||
self.completed_at = now
|
self.completed_at = now
|
||||||
self.save(update_fields=["status", "completed_at", "updated_at"])
|
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):
|
def cancel(self):
|
||||||
"""Annule le bon de confection"""
|
"""Annule le bon de confection"""
|
||||||
if self.status in [self.STATUS_TO_PROCESS, self.STATUS_DRAFT, self.STATUS_IN_PRODUCTION]:
|
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,
|
product=self.product.name,
|
||||||
order=self.production_order.code,
|
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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,29 @@
|
||||||
<h5 class="mb-0 fw-bold">{{ order.code }}</h5>
|
<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>
|
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ order.created_at|date:"d/m/Y H:i" }}</small>
|
||||||
</div>
|
</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">
|
<span class="badge {{ order.status_badge_class }} fs-6">
|
||||||
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 -->
|
<!-- Informations générales -->
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
|
|
@ -187,6 +206,11 @@
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<div class="d-grid gap-2 mt-3">
|
<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' %}
|
{% if order.status == 'to_process' or order.status == 'draft' %}
|
||||||
<form method="post" action="{% url 'stock:production_order_start' order.pk %}">
|
<form method="post" action="{% url 'stock:production_order_start' order.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
@ -207,6 +231,16 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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' %}
|
{% 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">
|
<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" %}
|
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler le bon" %}
|
||||||
|
|
@ -244,6 +278,38 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,37 @@
|
||||||
<i class="bi bi-calendar"></i> {{ order.created_at|date:"d/m/Y H:i" }}
|
<i class="bi bi-calendar"></i> {{ order.created_at|date:"d/m/Y H:i" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<span class="badge {{ order.status_badge_class }} fs-5">
|
||||||
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Boutons d'action -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<a href="{% url 'stock:stock_movements' %}?tab=production-orders" class="btn btn-secondary">
|
<a href="{% url 'stock:stock_movements' %}?tab=production-orders" class="btn btn-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> {% translate "Retour à la liste" %}
|
<i class="bi bi-arrow-left"></i> {% translate "Retour à la liste" %}
|
||||||
</a>
|
</a>
|
||||||
{% if can_manage %}
|
{% 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">
|
<a href="{% url 'stock:production_order_edit' order.pk %}" class="btn btn-outline-primary">
|
||||||
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -54,6 +71,15 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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' %}
|
{% 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">
|
<form method="post" action="{% url 'stock:production_order_cancel' order.pk %}" class="d-inline">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
@ -312,6 +338,60 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,162 @@ class PreparationOrderPriorityTests(TestCase):
|
||||||
o3 = ProductionOrder.objects.create(code="CONF-D3", warehouse=wh, notes="Confection de : Panneau bois\nAutre note")
|
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")
|
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):
|
def test_preparation_order_detail_shows_intervention_manager(self):
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from interventions.models import Intervention
|
from interventions.models import Intervention
|
||||||
|
|
@ -2262,11 +2418,11 @@ class StockMovementMultiLocationBatchTests(TestCase):
|
||||||
self.assertEqual(len(movements), 2)
|
self.assertEqual(len(movements), 2)
|
||||||
|
|
||||||
self.assertEqual(movements[0].product, self.prod1)
|
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[0].quantity, 10)
|
||||||
|
|
||||||
self.assertEqual(movements[1].product, self.prod2)
|
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(movements[1].quantity, 5)
|
||||||
|
|
||||||
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 10)
|
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(len(movements), 2)
|
||||||
|
|
||||||
self.assertEqual(movements[0].product, self.prod1)
|
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[0].quantity, 4)
|
||||||
|
|
||||||
self.assertEqual(movements[1].product, self.prod2)
|
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(movements[1].quantity, 3)
|
||||||
|
|
||||||
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 11)
|
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(len(movements), 2)
|
||||||
|
|
||||||
self.assertEqual(movements[0].product, self.prod1)
|
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].destination, self.loc2)
|
self.assertEqual(movements[0].destination_location, self.loc2)
|
||||||
self.assertEqual(movements[0].quantity, 5)
|
self.assertEqual(movements[0].quantity, 5)
|
||||||
|
|
||||||
self.assertEqual(movements[1].product, self.prod2)
|
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].destination, self.loc3)
|
self.assertEqual(movements[1].destination_location, self.loc3)
|
||||||
self.assertEqual(movements[1].quantity, 2)
|
self.assertEqual(movements[1].quantity, 2)
|
||||||
|
|
||||||
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 15)
|
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)
|
Stock.objects.create(product=self.prod1, location=self.loc2, quantity=12)
|
||||||
self.prod1.default_location = self.loc1
|
self.prod1.default_location = self.loc1
|
||||||
self.prod1.save()
|
self.prod1.save()
|
||||||
|
self.loc1.is_default = True
|
||||||
|
self.loc1.save()
|
||||||
|
|
||||||
response = self.client.get(reverse("stock:product_locations_api", args=[self.prod1.id]))
|
response = self.client.get(reverse("stock:product_locations_api", args=[self.prod1.id]))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertTrue(data["success"])
|
self.assertIn("locations", data)
|
||||||
locations = data["locations"]
|
locations = data["locations"]
|
||||||
|
|
||||||
# Loc2 has stock=12, so it should be prioritized first
|
# Loc2 has stock=12, so it should be prioritized first
|
||||||
|
|
|
||||||
|
|
@ -442,8 +442,7 @@ class WarehouseViewsPermissionsTestCase(TestCase):
|
||||||
# Let's call the view
|
# Let's call the view
|
||||||
response = new_stock_entry(request)
|
response = new_stock_entry(request)
|
||||||
# It should show error message and stay on the form page (not redirect to success)
|
# 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" in str(m).lower() for m in messages))
|
||||||
self.assertTrue(any(_("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.") in str(m) for m in messages))
|
|
||||||
|
|
||||||
# Scenario 2: User has can_administer=True
|
# Scenario 2: User has can_administer=True
|
||||||
access.can_administer = True
|
access.can_administer = True
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ urlpatterns = [
|
||||||
path("production/<int:pk>/start/", views.production_order_start, name="production_order_start"),
|
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>/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>/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/<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"),
|
path("production-documents/<int:doc_id>/delete/", views.production_order_document_delete, name="production_order_document_delete"),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -622,7 +622,7 @@ def stock_movements(request):
|
||||||
if search_q.isdigit():
|
if search_q.isdigit():
|
||||||
q_filter |= models.Q(intervention__id=int(search_q))
|
q_filter |= models.Q(intervention__id=int(search_q))
|
||||||
preparation_orders = preparation_orders.filter(q_filter)
|
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
|
# Filtrer selon les entrepôts accessibles
|
||||||
accessible_warehouses = get_user_accessible_warehouses(request.user)
|
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))
|
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.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(
|
movements = StockMovement.objects.filter(
|
||||||
models.Q(source_location__warehouse__id__in=accessible_warehouses) |
|
models.Q(source_location__warehouse__id__in=accessible_warehouses) |
|
||||||
|
|
@ -5230,6 +5230,11 @@ def production_order_document_upload(request, pk):
|
||||||
file=f,
|
file=f,
|
||||||
name=f.name,
|
name=f.name,
|
||||||
)
|
)
|
||||||
|
production_order.log_history(
|
||||||
|
action="document_added",
|
||||||
|
description=_("Ajout de document : {}").format(doc.filename()),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
created.append({
|
created.append({
|
||||||
'id': doc.pk,
|
'id': doc.pk,
|
||||||
'filename': doc.filename(),
|
'filename': doc.filename(),
|
||||||
|
|
@ -5263,7 +5268,14 @@ def production_order_document_delete(request, doc_id):
|
||||||
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
|
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
|
||||||
|
|
||||||
doc = get_object_or_404(ProductionOrderDocument, pk=doc_id)
|
doc = get_object_or_404(ProductionOrderDocument, pk=doc_id)
|
||||||
|
doc_filename = doc.filename()
|
||||||
|
order = doc.production_order
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
order.log_history(
|
||||||
|
action="document_removed",
|
||||||
|
description=_("Suppression du document : {}").format(doc_filename),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
return JsonResponse({'deleted': doc_id})
|
return JsonResponse({'deleted': doc_id})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5869,12 +5881,23 @@ def production_order_create(request):
|
||||||
form.save_m2m()
|
form.save_m2m()
|
||||||
formset.instance = order
|
formset.instance = order
|
||||||
formset.save()
|
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'):
|
for f in request.FILES.getlist('new_documents'):
|
||||||
ProductionOrderDocument.objects.create(
|
doc = ProductionOrderDocument.objects.create(
|
||||||
production_order=order,
|
production_order=order,
|
||||||
file=f,
|
file=f,
|
||||||
name=f.name,
|
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."))
|
messages.success(request, _("Bon de confection créé avec succès."))
|
||||||
return redirect('stock:production_order_detail', pk=order.pk)
|
return redirect('stock:production_order_detail', pk=order.pk)
|
||||||
else:
|
else:
|
||||||
|
|
@ -5938,6 +5961,8 @@ def production_order_detail(request, pk):
|
||||||
'can_start': can_start,
|
'can_start': can_start,
|
||||||
'can_manage': can_manage,
|
'can_manage': can_manage,
|
||||||
'other_item_description': other_item_description,
|
'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)
|
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."))
|
messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page."))
|
||||||
return redirect('stock:production_order_detail', pk=pk)
|
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':
|
if request.method == 'POST':
|
||||||
|
old_quantity = order.quantity
|
||||||
form = ProductionOrderForm(request.POST, request.FILES, instance=order)
|
form = ProductionOrderForm(request.POST, request.FILES, instance=order)
|
||||||
formset = ProductionOrderComponentFormSet(request.POST, instance=order, prefix='components')
|
formset = ProductionOrderComponentFormSet(request.POST, instance=order, prefix='components')
|
||||||
if form.is_valid() and formset.is_valid():
|
if form.is_valid() and formset.is_valid():
|
||||||
form.save()
|
saved_order = form.save()
|
||||||
formset.save()
|
formset.save()
|
||||||
for f in request.FILES.getlist('new_documents'):
|
for f in request.FILES.getlist('new_documents'):
|
||||||
ProductionOrderDocument.objects.create(
|
doc = ProductionOrderDocument.objects.create(
|
||||||
production_order=order,
|
production_order=order,
|
||||||
file=f,
|
file=f,
|
||||||
name=f.name,
|
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."))
|
messages.success(request, _("Bon de confection mis à jour."))
|
||||||
return redirect('stock:production_order_detail', pk=order.pk)
|
return redirect('stock:production_order_detail', pk=order.pk)
|
||||||
else:
|
else:
|
||||||
|
|
@ -6009,6 +6072,12 @@ def production_order_start(request, pk):
|
||||||
if order.can_start():
|
if order.can_start():
|
||||||
order.status = ProductionOrder.STATUS_IN_PRODUCTION
|
order.status = ProductionOrder.STATUS_IN_PRODUCTION
|
||||||
order.save(update_fields=['status', 'updated_at'])
|
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."))
|
messages.success(request, _("Production démarrée."))
|
||||||
else:
|
else:
|
||||||
messages.error(request, _("Stock insuffisant pour démarrer la production."))
|
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)
|
return redirect('stock:production_order_detail', pk=pk)
|
||||||
if order.status == ProductionOrder.STATUS_IN_PRODUCTION:
|
if order.status == ProductionOrder.STATUS_IN_PRODUCTION:
|
||||||
if order.can_start(): # Re-vérifier le stock
|
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."))
|
messages.success(request, _("Production terminée. Stock mis à jour."))
|
||||||
else:
|
else:
|
||||||
messages.error(request, _("Stock insuffisant pour terminer la production."))
|
messages.error(request, _("Stock insuffisant pour terminer la production."))
|
||||||
|
|
@ -6072,8 +6141,14 @@ def production_order_cancel(request, pk):
|
||||||
if not can_manage:
|
if not can_manage:
|
||||||
messages.error(request, _("Permission refusée."))
|
messages.error(request, _("Permission refusée."))
|
||||||
return redirect('stock:production_order_detail', pk=pk)
|
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.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é."))
|
messages.success(request, _("Bon de production annulé."))
|
||||||
else:
|
else:
|
||||||
messages.warning(request, _("Cet ordre ne peut pas être annulé."))
|
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)
|
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):
|
def product_design_api(request):
|
||||||
"""Stub — ProductDesign désactivé."""
|
"""Stub — ProductDesign désactivé."""
|
||||||
return JsonResponse({'components': []})
|
return JsonResponse({'components': []})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue