diff --git a/loko/common/models.py b/loko/common/models.py index 75f2078..572d77b 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -411,7 +411,7 @@ class UserConfig(models.Model): def can_manage_preparation_orders_any(self): """Vérifie si l'utilisateur peut gérer des bons de préparation sur au moins un entrepôt.""" - if self.user.is_superuser or self.has_role('admin'): + if self.user.is_superuser or self.has_role('admin') or self.has_role('stock_manager'): return True if not self.userwarehouses.exists(): if self.userlocations.exists(): @@ -421,7 +421,7 @@ class UserConfig(models.Model): def can_manage_preparation_orders_for(self, warehouse): """Vérifie si l'utilisateur peut gérer des bons de préparation pour un entrepôt spécifique.""" - if self.user.is_superuser or self.has_role('admin'): + if self.user.is_superuser or self.has_role('admin') or self.has_role('stock_manager'): return True if not self.userwarehouses.exists(): if self.userlocations.exists(): diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 7f0f5ab..e1a0360 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -988,11 +988,17 @@ def stock_preparation_order_detail_mobile(request, pk): if request.method == 'POST' and request.FILES.getlist('documents'): from stock.models import PreparationOrderDocument for f in request.FILES.getlist('documents'): - PreparationOrderDocument.objects.create( + doc = PreparationOrderDocument.objects.create( preparation_order=preparation_order, file=f, name=f.name, ) + preparation_order.log_history( + action="document_added", + description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()), + user=request.user, + details={'document_id': doc.pk, 'filename': doc.filename()}, + ) messages.success(request, _('Document(s) ajouté(s) avec succès.')) return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk) @@ -1056,10 +1062,12 @@ def stock_preparation_order_detail_mobile(request, pk): 'location_info': ", ".join(loc_names), }) + from stock.permissions import user_can_manage_preparation_orders, user_can_edit_preparation_order 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_preparation_orders_any() - can_cancel = user_config.can_manage_preparation_orders_any() + can_manage = user_can_manage_preparation_orders(request.user) + can_edit = user_can_edit_preparation_order(request.user, preparation_order) + can_cancel = can_manage and preparation_order.status != 'cancelled' can_manage_purchase = user_config.can_manage_purchase_orders_any() can_manage_stock = ( request.user.is_superuser @@ -1069,10 +1077,14 @@ def stock_preparation_order_detail_mobile(request, pk): ) except UserConfig.DoesNotExist: can_manage = False + can_edit = False can_cancel = False can_manage_purchase = False can_manage_stock = False + history_entries = preparation_order.history_entries.select_related('user').order_by('-created_at') + has_pending_stock_adjustments = any(it['delta'] != 0 for it in items_with_stock) or bool(deleted_items_to_return) + other_items_with_info = [] for oi in preparation_order.other_items.all(): other_items_with_info.append({ @@ -1109,6 +1121,7 @@ def stock_preparation_order_detail_mobile(request, pk): 'items_with_stock': items_with_stock, 'other_items': other_items_with_info, 'can_manage': can_manage, + 'can_edit': can_edit, 'can_cancel': can_cancel, 'can_manage_purchase': can_manage_purchase, 'can_manage_stock': can_manage_stock, @@ -1117,6 +1130,8 @@ def stock_preparation_order_detail_mobile(request, pk): 'has_executed_outputs': has_executed_outputs, 'is_reopened': is_reopened, 'deleted_items_to_return': deleted_items_to_return, + 'history_entries': history_entries, + 'has_pending_stock_adjustments': has_pending_stock_adjustments, 'discussion': _discussion, 'discussion_messages': discussion_messages_mobile, 'can_post_discussion_message': can_post_discussion_message_mobile, diff --git a/loko/stock/admin.py b/loko/stock/admin.py index d4b9d0b..f97f6e9 100644 --- a/loko/stock/admin.py +++ b/loko/stock/admin.py @@ -4,7 +4,7 @@ from django.utils.html import format_html from .models import ( Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement, PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem, - PreparationOrderOtherItem, + PreparationOrderOtherItem, PreparationOrderHistory, ProductionOrder, SerializedItem, SerializedItemEvent, ) @@ -91,12 +91,23 @@ class PreparationOrderOtherItemInline(admin.TabularInline): extra = 1 +class PreparationOrderHistoryInline(admin.TabularInline): + model = PreparationOrderHistory + extra = 0 + readonly_fields = ('created_at', 'author_short_name', 'action', 'description', 'details') + can_delete = False + + def author_short_name(self, obj): + return obj.author_short_name + author_short_name.short_description = 'Auteur' + + @admin.register(PreparationOrder) class PreparationOrderAdmin(admin.ModelAdmin): list_display = ('order_number', 'date', 'status', 'priority') search_fields = ('order_number',) list_filter = ('status', 'date', 'priority') - inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline] + inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline, PreparationOrderHistoryInline] def get_readonly_fields(self, request, obj=None): if obj: # editing an existing object @@ -104,6 +115,14 @@ class PreparationOrderAdmin(admin.ModelAdmin): return self.readonly_fields +@admin.register(PreparationOrderHistory) +class PreparationOrderHistoryAdmin(admin.ModelAdmin): + list_display = ('preparation_order', 'created_at', 'author_short_name', 'action', 'description') + list_filter = ('action', 'created_at') + search_fields = ('preparation_order__order_number', 'user_short_name', 'description') + readonly_fields = ('preparation_order', 'user', 'user_short_name', 'created_at', 'action', 'description', 'details') + + @admin.register(PreparationOrderItem) class PreparationOrderItemAdmin(admin.ModelAdmin): list_display = ('preparation_order', 'product', 'quantity', 'is_prepared', 'is_used') diff --git a/loko/stock/migrations/0003_preparationorderhistory.py b/loko/stock/migrations/0003_preparationorderhistory.py new file mode 100644 index 0000000..dc1e655 --- /dev/null +++ b/loko/stock/migrations/0003_preparationorderhistory.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.7 on 2026-09-29 11:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0002_preparationorderitem_is_used_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PreparationOrderHistory', + 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'), ('item_added', 'Ajout de produit'), ('quantity_changed', 'Modification de quantité'), ('item_removed', 'Suppression de produit'), ('other_item_added', "Ajout d'article personnalisé"), ('other_item_updated', "Modification d'article personnalisé"), ('other_item_removed', "Suppression d'article personnalisé"), ('status_changed', 'Changement de statut'), ('reopened', 'Réouverture'), ('processed', 'Traitement'), ('cancelled', 'Annulation'), ('loan_updated', 'Modification du prêt'), ('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')), + ('preparation_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history_entries', to='stock.preparationorder', verbose_name='Bon de préparation')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preparation_order_history_entries', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')), + ], + options={ + 'verbose_name': 'Historique de bon de préparation', + 'verbose_name_plural': 'Historiques de bons de préparation', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/loko/stock/models.py b/loko/stock/models.py index 5b95a06..97b99a8 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -1,4 +1,5 @@ from django.contrib.gis.db import models +from django.conf import settings from django.db import transaction from django.db.models import Sum, F from django.utils import timezone @@ -1673,6 +1674,21 @@ class PreparationOrder(models.Model): def total_items_count(self): return self.preparationorderitem_set.count() + self.other_items.count() + def log_history(self, action, description, user=None, details=None): + """Enregistre un événement dans l'historique du bon de préparation.""" + 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 {}, + ) + + class PreparationOrderItem(models.Model): @@ -1866,6 +1882,134 @@ class PreparationOrderOtherItem(models.Model): ) +class PreparationOrderHistory(models.Model): + """ + Historique des modifications apportées à un bon de préparation. + Trace les ajouts, modifications de quantité, suppressions et changements de statut avec utilisateur (shortname) et date. + """ + ACTION_CHOICES = [ + ("created", _("Création")), + ("item_added", _("Ajout de produit")), + ("quantity_changed", _("Modification de quantité")), + ("item_removed", _("Suppression de produit")), + ("other_item_added", _("Ajout d'article personnalisé")), + ("other_item_updated", _("Modification d'article personnalisé")), + ("other_item_removed", _("Suppression d'article personnalisé")), + ("status_changed", _("Changement de statut")), + ("reopened", _("Réouverture")), + ("processed", _("Traitement")), + ("cancelled", _("Annulation")), + ("loan_updated", _("Modification du prêt")), + ("document_added", _("Ajout de document")), + ("document_removed", _("Suppression de document")), + ("other", _("Autre modification")), + ] + + preparation_order = models.ForeignKey( + PreparationOrder, + on_delete=models.CASCADE, + related_name="history_entries", + verbose_name=_("Bon de préparation"), + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="preparation_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 préparation") + verbose_name_plural = _("Historiques de bons de préparation") + 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", + "item_added": "bg-success", + "quantity_changed": "bg-warning text-dark", + "item_removed": "bg-danger", + "other_item_added": "bg-info text-dark", + "other_item_updated": "bg-warning text-dark", + "other_item_removed": "bg-danger", + "status_changed": "bg-info text-dark", + "reopened": "bg-warning text-dark", + "processed": "bg-success", + "cancelled": "bg-secondary", + "loan_updated": "bg-primary", + "document_added": "bg-secondary", + "document_removed": "bg-secondary", + } + return badges.get(self.action, "bg-secondary") + + @property + def icon_class(self): + icons = { + "created": "bi-plus-circle-fill", + "item_added": "bi-box-seam-fill", + "quantity_changed": "bi-pencil-fill", + "item_removed": "bi-trash-fill", + "other_item_added": "bi-tag-fill", + "other_item_updated": "bi-pencil-square", + "other_item_removed": "bi-trash-fill", + "status_changed": "bi-arrow-repeat", + "reopened": "bi-arrow-counterclockwise", + "processed": "bi-check2-circle", + "cancelled": "bi-x-circle-fill", + "loan_updated": "bi-calendar-event-fill", + "document_added": "bi-paperclip", + "document_removed": "bi-trash", + } + return icons.get(self.action, "bi-clock-history") + + + + + # class ProductDesign(models.Model): # """ # Conception de produit : une fiche/recette décrivant comment assembler diff --git a/loko/stock/permissions.py b/loko/stock/permissions.py index db2a5bf..a260c2f 100644 --- a/loko/stock/permissions.py +++ b/loko/stock/permissions.py @@ -303,3 +303,36 @@ def user_can_manage_products(user): config.has_role('admin') or config.can_administer_any_warehouse() ) + + +def user_can_manage_preparation_orders(user, warehouse=None): + """ + Vérifie si l'utilisateur peut gérer des bons de préparation (création, traitement, annulation). + Autorisé pour les superutilisateurs, le rôle admin, le rôle stock_manager (magasinier), + ou les utilisateurs ayant la permission can_manage_preparation_orders. + """ + if not user.is_authenticated: + return False + if user.is_superuser: + return True + config = get_user_config(user) + if not config: + return False + if config.has_role('admin') or config.has_role('stock_manager'): + return True + if warehouse: + return config.can_manage_preparation_orders_for(warehouse) + return config.can_manage_preparation_orders_any() + + +def user_can_edit_preparation_order(user, preparation_order=None): + """ + Vérifie si l'utilisateur peut modifier un bon de préparation. + La modification est possible pour les gestionnaires et magasiniers, + même lorsque le bon est au statut 'traité' (processed). + Seul le statut 'annulé' (cancelled) est non modifiable. + """ + if preparation_order and preparation_order.status == 'cancelled': + return False + return user_can_manage_preparation_orders(user) + diff --git a/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html b/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html index 32ea3ca..3856451 100644 --- a/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html +++ b/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html @@ -116,6 +116,21 @@ {% endif %} +{% if preparation_order.status == 'processed' and has_pending_stock_adjustments %} +
{% translate "Ce bon a été modifié après son traitement. Une régularisation du stock est nécessaire." %}
+ {% if can_manage %} + + {% translate "Régulariser les stocks maintenant" %} + + {% endif %} +{% translate "Aucun historique disponible." %}
+ {% endif %} +