diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.mo b/loko/interventions/locale/en/LC_MESSAGES/django.mo index 1dcf27f..0b01a19 100644 Binary files a/loko/interventions/locale/en/LC_MESSAGES/django.mo and b/loko/interventions/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.po b/loko/interventions/locale/en/LC_MESSAGES/django.po index 46bdf93..60739d0 100644 --- a/loko/interventions/locale/en/LC_MESSAGES/django.po +++ b/loko/interventions/locale/en/LC_MESSAGES/django.po @@ -8372,5 +8372,40 @@ msgstr "%(count)d unlinked %(relation)ss: %(targets)s" msgid "%(relation)s déliée(s)" msgstr "Unlinked %(relation)s(s)" +msgid "Retour en stock requis : intervention %(code)s annulée" +msgstr "Return to stock required: intervention %(code)s canceled" + +msgid "" +"L'intervention %(code)s a été annulée alors que le bon de préparation %(order)s est déjà préparé. " +"Veuillez effectuer le retour en stock du matériel." +msgstr "" +"Intervention %(code)s was canceled while preparation order %(order)s is already prepared. " +"Please return the material to stock." + +msgid "Voir le bon de préparation" +msgstr "View preparation order" + +msgid "" +"ATTENTION : L'intervention %(code)s a été annulée. " +"Ce bon de préparation étant déjà préparé, un retour en stock du matériel doit être effectué par le magasinier." +msgstr "" +"WARNING: Intervention %(code)s has been canceled. " +"As this preparation order is already prepared, material must be returned to stock by the warehouse worker." + +msgid "Bon de préparation %(order)s déjà préparé : notification envoyée au magasinier pour retour en stock." +msgstr "Preparation order %(order)s already prepared: notification sent to warehouse worker for return to stock." + +msgid "Bon de préparation annulé automatiquement suite à l'annulation de l'intervention %(code)s." +msgstr "Preparation order automatically canceled following the cancellation of intervention %(code)s." + +msgid "Bon de préparation %(order)s annulé automatiquement suite à l'annulation de l'intervention." +msgstr "Preparation order %(order)s automatically canceled following the cancellation of the intervention." + +msgid "Impossible de modifier le matériel d'un bon de préparation annulé." +msgstr "Cannot modify material from a canceled preparation order." + +msgid "Bon annulé" +msgstr "Canceled order" + diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.mo b/loko/interventions/locale/fr/LC_MESSAGES/django.mo index 7d226c0..fa58b7e 100644 Binary files a/loko/interventions/locale/fr/LC_MESSAGES/django.mo and b/loko/interventions/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.po b/loko/interventions/locale/fr/LC_MESSAGES/django.po index e5c7136..de19591 100644 --- a/loko/interventions/locale/fr/LC_MESSAGES/django.po +++ b/loko/interventions/locale/fr/LC_MESSAGES/django.po @@ -8479,5 +8479,40 @@ msgstr "%(count)d %(relation)ss déliées : %(targets)s" msgid "%(relation)s déliée(s)" msgstr "%(relation)s déliée(s)" +msgid "Retour en stock requis : intervention %(code)s annulée" +msgstr "Retour en stock requis : intervention %(code)s annulée" + +msgid "" +"L'intervention %(code)s a été annulée alors que le bon de préparation %(order)s est déjà préparé. " +"Veuillez effectuer le retour en stock du matériel." +msgstr "" +"L'intervention %(code)s a été annulée alors que le bon de préparation %(order)s est déjà préparé. " +"Veuillez effectuer le retour en stock du matériel." + +msgid "Voir le bon de préparation" +msgstr "Voir le bon de préparation" + +msgid "" +"ATTENTION : L'intervention %(code)s a été annulée. " +"Ce bon de préparation étant déjà préparé, un retour en stock du matériel doit être effectué par le magasinier." +msgstr "" +"ATTENTION : L'intervention %(code)s a été annulée. " +"Ce bon de préparation étant déjà préparé, un retour en stock du matériel doit être effectué par le magasinier." + +msgid "Bon de préparation %(order)s déjà préparé : notification envoyée au magasinier pour retour en stock." +msgstr "Bon de préparation %(order)s déjà préparé : notification envoyée au magasinier pour retour en stock." + +msgid "Bon de préparation annulé automatiquement suite à l'annulation de l'intervention %(code)s." +msgstr "Bon de préparation annulé automatiquement suite à l'annulation de l'intervention %(code)s." + +msgid "Bon de préparation %(order)s annulé automatiquement suite à l'annulation de l'intervention." +msgstr "Bon de préparation %(order)s annulé automatiquement suite à l'annulation de l'intervention." + +msgid "Impossible de modifier le matériel d'un bon de préparation annulé." +msgstr "Impossible de modifier le matériel d'un bon de préparation annulé." + +msgid "Bon annulé" +msgstr "Bon annulé" + diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.mo b/loko/interventions/locale/nl/LC_MESSAGES/django.mo index 3f33f2e..e7bf0ab 100644 Binary files a/loko/interventions/locale/nl/LC_MESSAGES/django.mo and b/loko/interventions/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.po b/loko/interventions/locale/nl/LC_MESSAGES/django.po index e52720a..bb35d8d 100644 --- a/loko/interventions/locale/nl/LC_MESSAGES/django.po +++ b/loko/interventions/locale/nl/LC_MESSAGES/django.po @@ -8413,4 +8413,39 @@ msgstr "%(count)d %(relation)ss ontkoppeld: %(targets)s" msgid "%(relation)s déliée(s)" msgstr "Ontkoppelde %(relation)s(s)" +msgid "Retour en stock requis : intervention %(code)s annulée" +msgstr "Terugname naar voorraad vereist: interventie %(code)s geannuleerd" + +msgid "" +"L'intervention %(code)s a été annulée alors que le bon de préparation %(order)s est déjà préparé. " +"Veuillez effectuer le retour en stock du matériel." +msgstr "" +"Interventie %(code)s is geannuleerd terwijl voorbereidingsbon %(order)s al voorbereid is. " +"Voer de terugname naar de voorraad van het materiaal uit." + +msgid "Voir le bon de préparation" +msgstr "Voorbereidingsbon bekijken" + +msgid "" +"ATTENTION : L'intervention %(code)s a été annulée. " +"Ce bon de préparation étant déjà préparé, un retour en stock du matériel doit être effectué par le magasinier." +msgstr "" +"OPGELET: Interventie %(code)s is geannuleerd. " +"Aangezien deze voorbereidingsbon al voorbereid is, moet het materiaal door de magazijnier worden teruggebracht naar de voorraad." + +msgid "Bon de préparation %(order)s déjà préparé : notification envoyée au magasinier pour retour en stock." +msgstr "Voorbereidingsbon %(order)s reeds voorbereid: melding verzonden naar magazijnier voor terugname naar voorraad." + +msgid "Bon de préparation annulé automatiquement suite à l'annulation de l'intervention %(code)s." +msgstr "Voorbereidingsbon automatisch geannuleerd na annulering van interventie %(code)s." + +msgid "Bon de préparation %(order)s annulé automatiquement suite à l'annulation de l'intervention." +msgstr "Voorbereidingsbon %(order)s automatisch geannuleerd na annulering van de interventie." + +msgid "Impossible de modifier le matériel d'un bon de préparation annulé." +msgstr "Kan materiaal van een geannuleerde voorbereidingsbon niet wijzigen." + +msgid "Bon annulé" +msgstr "Geannuleerde bon" + diff --git a/loko/interventions/models.py b/loko/interventions/models.py index fc36bfb..61ae7ca 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -995,55 +995,57 @@ class Intervention(models.Model): def get_preparation_orders_status(self): """ - Agrège les statuts des bons de préparation liés. - Retourne: 'processed' si tous les bons sont traités/annulés, - 'not_processed' si au moins un bon est à traiter/en attente, - 'none' si aucun bon n'existe. + Agrège les statuts des bons de préparation liés (hors bons annulés). + Retourne: 'processed' si au moins un bon est traité et aucun bon actif n'est non traité, + 'not_processed' si au moins un bon actif est à traiter/en attente, + 'none' si aucun bon actif n'existe (ou si tous sont annulés). """ - prep_orders = self.preparation_orders.all().values_list('status', flat=True) - if not prep_orders: + if not hasattr(self, 'preparation_orders'): + return 'none' + active_orders = list( + self.preparation_orders.exclude(status='cancelled').values_list('status', flat=True) + ) + if not active_orders: return 'none' - # Statuts considérés comme 'traité' - treated_statuses = {'processed', 'cancelled'} - # Statuts considérés comme 'non traité' - untreated_statuses = {'to_process', 'pending'} - - statuses = set(prep_orders) - has_untreated = bool(statuses & untreated_statuses) - - if has_untreated: + if any(s in ('to_process', 'pending') for s in active_orders): return 'not_processed' - return 'processed' + + if any(s == 'processed' for s in active_orders): + return 'processed' + + return 'none' @property def has_pending_preparation(self): """Retourne True si au moins un bon de préparation est à traiter ou en attente.""" + if not hasattr(self, 'preparation_orders'): + return False return self.preparation_orders.filter( status__in=['to_process', 'pending'] ).exists() @property def has_material(self): - """Indique si l'intervention dispose de matériel mentionné dans ses bons de préparation.""" + """Indique si l'intervention dispose de matériel mentionné dans ses bons de préparation actifs.""" if not hasattr(self, 'preparation_orders'): return False - return self.preparation_orders.filter( + return self.preparation_orders.exclude(status='cancelled').filter( Q(preparationorderitem__isnull=False) | Q(other_items__isnull=False) ).distinct().exists() def get_material_stats(self): """ - Retourne le statut d'utilisation du matériel pour cette intervention : - - total : nombre total d'articles (catalogue + autres) + Retourne le statut d'utilisation du matériel pour cette intervention (hors bons annulés) : + - total : nombre total d'articles (catalogue + autres) des bons actifs - used : nombre d'articles marqués comme utilisés - all_used : True si tous les articles sont utilisés (et total > 0) - - has_material : True si au moins un article existe + - has_material : True si au moins un article existe dans les bons actifs """ if not hasattr(self, 'preparation_orders'): return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False} from stock.models import PreparationOrderItem, PreparationOrderOtherItem - order_ids = list(self.preparation_orders.values_list('id', flat=True)) + order_ids = list(self.preparation_orders.exclude(status='cancelled').values_list('id', flat=True)) if not order_ids: return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False} std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids) diff --git a/loko/interventions/services/stock_sync_service.py b/loko/interventions/services/stock_sync_service.py new file mode 100644 index 0000000..b0fbbfa --- /dev/null +++ b/loko/interventions/services/stock_sync_service.py @@ -0,0 +1,170 @@ +# interventions/services/stock_sync_service.py +import logging +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +logger = logging.getLogger(__name__) + + +def get_stock_managers_for_notification(): + """ + Retourne la liste des utilisateurs à notifier lors d'un événement stock (magasiniers) : + - Utilisateurs ayant le rôle 'stock_manager' + - Utilisateurs ayant le droit 'can_manage_preparation_orders' sur au moins un entrepôt + - En repli, superutilisateurs ou administrateurs. + """ + User = get_user_model() + managers = User.objects.filter( + Q(config__roles__name='stock_manager') | + Q(config__userwarehouses__can_manage_preparation_orders=True), + is_active=True, + ).distinct() + + if not managers.exists(): + managers = User.objects.filter( + Q(is_superuser=True) | Q(config__roles__name='admin'), + is_active=True, + ).distinct() + + return list(managers) + + +def sync_preparation_orders_on_intervention_cancel(intervention, user=None): + """ + Synchronise les bons de préparation liés lors de l'annulation d'une intervention : + 1. Si le bon n'est pas encore préparé (statut 'to_process' ou 'pending', sans sorties ni articles préparés) : + -> Le bon est automatiquement annulé et les quantités réservées libérées. + -> Un message est posté dans la discussion du bon et un événement timeline est créé. + 2. Si le bon est déjà préparé (statut 'processed' ou sorties exécutées ou articles préparés) : + -> Le bon n'est pas annulé automatiquement. + -> Une alerte est envoyée au(x) magasinier(s) pour effectuer un retour en stock. + -> Un message d'avertissement est consigné dans la discussion du bon et sur la timeline. + """ + if not intervention or not hasattr(intervention, 'preparation_orders'): + return + + orders = intervention.preparation_orders.exclude(status='cancelled') + if not orders.exists(): + return + + from notifications.services import ( + notify_users, + get_or_create_preparation_order_discussion, + post_message, + ) + from interventions.models import InterventionTimeLine + + stock_managers = None + + for order in orders: + try: + is_prepared = getattr(order, 'is_already_prepared', False) + if not is_prepared: + # Vérification complémentaire + if order.status == 'processed': + is_prepared = True + elif order.stock_batches.filter(movement_type='out').exists(): + is_prepared = True + elif order.preparationorderitem_set.filter(is_prepared=True).exists(): + is_prepared = True + elif order.other_items.filter(is_prepared=True).exists(): + is_prepared = True + + if is_prepared: + # Le bon est déjà préparé : NE PAS l'annuler automatiquement, avertir le magasinier pour retour en stock + if stock_managers is None: + stock_managers = get_stock_managers_for_notification() + + if stock_managers: + title = _("Retour en stock requis : intervention %(code)s annulée") % {'code': intervention.code} + content = _( + "L'intervention %(code)s a été annulée alors que le bon de préparation %(order)s est déjà préparé. " + "Veuillez effectuer le retour en stock du matériel." + ) % { + 'code': intervention.code, + 'order': order.order_number, + } + try: + action_url = reverse('stock:preparation_order_detail', args=[order.pk]) + except Exception: + action_url = '' + + notify_users( + recipients=stock_managers, + title=title, + content=content, + notification_type='alert', + action_url=action_url, + action_label=str(_("Voir le bon de préparation")), + source_app='stock', + sender=user if (user and user.is_authenticated) else None, + ) + + # Message dans la discussion du bon de préparation + try: + discussion, _created = get_or_create_preparation_order_discussion(order) + post_message( + discussion=discussion, + author=user if (user and user.is_authenticated) else None, + content=_( + "ATTENTION : L'intervention %(code)s a été annulée. " + "Ce bon de préparation étant déjà préparé, un retour en stock du matériel doit être effectué par le magasinier." + ) % {'code': intervention.code}, + ) + except Exception: + logger.exception("Erreur lors de l'ajout du message de discussion sur le bon #%s", order.pk) + + # Événement timeline sur l'intervention + try: + InterventionTimeLine.objects.create( + intervention=intervention, + event_user=user if (user and user.is_authenticated) else None, + event_time=timezone.now(), + event_type='note', + event_description=_( + "Bon de préparation %(order)s déjà préparé : notification envoyée au magasinier pour retour en stock." + ) % {'order': order.order_number}, + ) + except Exception: + logger.exception("Erreur lors de la création de la timeline pour le bon #%s", order.pk) + + else: + # Le bon n'est pas préparé : annulation automatique + order.cancel() + + # Message dans la discussion du bon + try: + discussion, _created = get_or_create_preparation_order_discussion(order) + post_message( + discussion=discussion, + author=user if (user and user.is_authenticated) else None, + content=_( + "Bon de préparation annulé automatiquement suite à l'annulation de l'intervention %(code)s." + ) % {'code': intervention.code}, + ) + except Exception: + logger.exception("Erreur lors de l'ajout du message de discussion sur le bon #%s", order.pk) + + # Événement timeline sur l'intervention + try: + InterventionTimeLine.objects.create( + intervention=intervention, + event_user=user if (user and user.is_authenticated) else None, + event_time=timezone.now(), + event_type='note', + event_description=_( + "Bon de préparation %(order)s annulé automatiquement suite à l'annulation de l'intervention." + ) % {'order': order.order_number}, + ) + except Exception: + logger.exception("Erreur lors de la création de la timeline pour le bon #%s", order.pk) + + except Exception: + logger.exception( + "Erreur lors de la synchronisation du bon de préparation #%s suite à l'annulation de l'intervention #%s", + order.pk, + intervention.pk, + ) diff --git a/loko/interventions/templates/interventions/_interventions_list_mobile.html b/loko/interventions/templates/interventions/_interventions_list_mobile.html index d0f53ce..014ca56 100644 --- a/loko/interventions/templates/interventions/_interventions_list_mobile.html +++ b/loko/interventions/templates/interventions/_interventions_list_mobile.html @@ -94,32 +94,34 @@ - {% if intervention.preparation_orders.all %} + {% if intervention.has_material %}
{% for order in intervention.preparation_orders.all %} - {% if order.is_loan %} - - {% translate "Prêt" %} - - {% endif %} - {% for item in order.preparationorderitem_set.all|slice:":3" %} - - {{ item.product.name }} × {{ item.quantity }} - - {% endfor %} - {% for item in order.other_items.all|slice:":2" %} - - {{ item.name }} × {{ item.quantity }} - - {% endfor %} - {% with total_count=order.preparationorderitem_set.all|length|add:order.other_items.all|length %} - {% if total_count > 3 %} - - +{{ total_count|add:"-3" }} + {% if order.status != 'cancelled' %} + {% if order.is_loan %} + + {% translate "Prêt" %} {% endif %} - {% endwith %} + {% for item in order.preparationorderitem_set.all|slice:":3" %} + + {{ item.product.name }} × {{ item.quantity }} + + {% endfor %} + {% for item in order.other_items.all|slice:":2" %} + + {{ item.name }} × {{ item.quantity }} + + {% endfor %} + {% with total_count=order.preparationorderitem_set.all|length|add:order.other_items.all|length %} + {% if total_count > 3 %} + + +{{ total_count|add:"-3" }} + + {% endif %} + {% endwith %} + {% endif %} {% endfor %}
{% endif %} diff --git a/loko/interventions/templates/interventions/detail/card_stock.html b/loko/interventions/templates/interventions/detail/card_stock.html index 4389b17..274771b 100644 --- a/loko/interventions/templates/interventions/detail/card_stock.html +++ b/loko/interventions/templates/interventions/detail/card_stock.html @@ -78,7 +78,7 @@ class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 py-1 px-2 btn-order-toggle-all" id="btn-order-toggle-all-{{ order.id }}" onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ order.id }}')" - {% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% else %}title="{% translate 'Tout cocher ou tout décocher pour ce bon' %}"{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% else %}title="{% translate 'Tout cocher ou tout décocher pour ce bon' %}"{% endif %} style="font-size: 0.75rem;"> @@ -107,14 +107,14 @@ data-is-other="false" data-order-id="{{ order.id }}" {% if item.is_used %}checked{% endif %} - {% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% endif %} onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)"> {{ item.product.name }} {% if item.product.category %} - {{ item.product.category.name_fr }} + {{ item.product.category.name_fr }} {% endif %} @@ -130,7 +130,11 @@ - {% if item.is_prepared %} + {% if order.status == 'cancelled' %} + + {% translate "Annulé" %} + + {% elif item.is_prepared or order.status == 'processed' %} {% translate "Préparé" %} @@ -164,7 +168,7 @@ data-is-other="true" data-order-id="{{ order.id }}" {% if item.is_used %}checked{% endif %} - {% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% endif %} onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)"> @@ -184,7 +188,11 @@ - {% if item.is_prepared %} + {% if order.status == 'cancelled' %} + + {% translate "Annulé" %} + + {% elif item.is_prepared or order.status == 'processed' %} {% translate "Préparé" %} diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index 001d712..206b25d 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -1178,7 +1178,7 @@ class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 py-1 px-2 btn-order-toggle-all" id="btn-mobile-order-toggle-all-{{ order.id }}" onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ order.id }}')" - {% if not can_edit_intervention %}disabled{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% endif %} style="font-size: 0.75rem;"> @@ -1201,14 +1201,16 @@ data-is-other="false" data-order-id="{{ order.id }}" {% if item.is_used %}checked{% endif %} - {% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% endif %} onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)">
{{ item.product.name }}
{% if item.product.code %}{{ item.product.code }}{% endif %} - {% if item.is_prepared %} + {% if order.status == 'cancelled' %} + {% translate "Annulé" %} + {% elif item.is_prepared or order.status == 'processed' %} {% translate "Préparé" %} {% else %} {% translate "En attente" %} @@ -1244,7 +1246,7 @@ data-is-other="true" data-order-id="{{ order.id }}" {% if item.is_used %}checked{% endif %} - {% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %} + {% if order.status == 'cancelled' or not can_edit_intervention %}disabled title="{% if order.status == 'cancelled' %}{% translate 'Bon annulé' %}{% else %}{% translate 'Permissions insuffisantes' %}{% endif %}"{% endif %} onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)">
@@ -1253,7 +1255,9 @@ {% translate "Hors cat." %}
- {% if item.is_prepared %} + {% if order.status == 'cancelled' %} + {% translate "Annulé" %} + {% elif item.is_prepared or order.status == 'processed' %} {% translate "Préparé" %} {% else %} {% translate "En attente" %} diff --git a/loko/interventions/tests/test_material_used.py b/loko/interventions/tests/test_material_used.py index 4c89766..70eb254 100644 --- a/loko/interventions/tests/test_material_used.py +++ b/loko/interventions/tests/test_material_used.py @@ -55,7 +55,7 @@ class MaterialUsedTests(TestCase): contract=self.contract, can_view_interventions=True, ) - for status in ['assigned', 'in_progress', 'finished']: + for status in ['to_be_processed', 'assigned', 'in_progress', 'finished', 'canceled']: UserContractStatusPermission.objects.create( user_contract=uca, status=status, @@ -343,3 +343,179 @@ class MaterialUsedTests(TestCase): self.assertEqual(_("utilisé(s)"), "used") self.assertEqual(_("Hors catalogue"), "Out of catalog") + def test_preparation_orders_status_and_annotation_excluding_cancelled(self): + """Vérifie que get_preparation_orders_status et annotate_preparation_status ignorent les bons annulés.""" + from interventions.views.helpers import annotate_preparation_status + + # 1. Avec order1 au statut to_process -> 'not_processed' + self.assertEqual(self.intervention.get_preparation_orders_status(), 'not_processed') + annotated_itv = annotate_preparation_status(Intervention.objects.filter(pk=self.intervention.pk)).first() + self.assertEqual(annotated_itv.prep_state, 'not_processed') + + # 2. Annulation du bon -> statut devient 'none' (et NON 'processed' comme avant le bug !) + self.order1.status = 'cancelled' + self.order1.save() + self.assertEqual(self.intervention.get_preparation_orders_status(), 'none') + annotated_itv = annotate_preparation_status(Intervention.objects.filter(pk=self.intervention.pk)).first() + self.assertEqual(annotated_itv.prep_state, 'none') + + # 3. Ajout d'un bon traité à côté du bon annulé -> statut devient 'processed' + order2 = PreparationOrder.objects.create( + order_number='BP-002', + intervention=self.intervention, + status='processed', + ) + self.assertEqual(self.intervention.get_preparation_orders_status(), 'processed') + annotated_itv = annotate_preparation_status(Intervention.objects.filter(pk=self.intervention.pk)).first() + self.assertEqual(annotated_itv.prep_state, 'processed') + + # 4. Ajout d'un bon en attente -> statut redevient 'not_processed' + order3 = PreparationOrder.objects.create( + order_number='BP-003', + intervention=self.intervention, + status='pending', + ) + self.assertEqual(self.intervention.get_preparation_orders_status(), 'not_processed') + annotated_itv = annotate_preparation_status(Intervention.objects.filter(pk=self.intervention.pk)).first() + self.assertEqual(annotated_itv.prep_state, 'not_processed') + + def test_has_material_and_stats_exclude_cancelled_orders(self): + """Vérifie que has_material et get_material_stats excluent les bons annulés.""" + # Actif : a du matériel + self.assertTrue(self.intervention.has_material) + stats = self.intervention.get_material_stats() + self.assertEqual(stats['total'], 3) + self.assertEqual(stats['used'], 0) + self.assertFalse(stats['all_used']) + + # Annulé : n'a plus de matériel actif + self.order1.status = 'cancelled' + self.order1.save() + self.assertFalse(self.intervention.has_material) + stats = self.intervention.get_material_stats() + self.assertEqual(stats['total'], 0) + self.assertEqual(stats['used'], 0) + self.assertFalse(stats['all_used']) + + def test_toggle_material_used_on_cancelled_order_rejected(self): + """Vérifie qu'il est impossible de cocher/décocher un article d'un bon annulé.""" + self.order1.status = 'cancelled' + self.order1.save() + + url = reverse('interventions:toggle_material_used', kwargs={'intervention_id': self.intervention.id, 'item_id': self.item1.id}) + response = self.client_editor.post( + url, + data=json.dumps({'item_id': self.item1.id, 'is_other': False, 'is_used': True}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertFalse(data['success']) + self.assertTrue("annulé" in data['error'].lower() or "cancel" in data['error'].lower()) + + self.item1.refresh_from_db() + self.assertFalse(self.item1.is_used) + + def test_toggle_all_material_used_ignores_cancelled_order(self): + """Vérifie que 'Tout cocher' rejette un bon annulé.""" + self.order1.status = 'cancelled' + self.order1.save() + + url = reverse('interventions:toggle_all_material_used', kwargs={'intervention_id': self.intervention.id}) + response = self.client_editor.post( + url, + data=json.dumps({'order_id': self.order1.id}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertFalse(data['success']) + + def test_sync_cancel_unprepared_order_when_intervention_canceled(self): + """Une intervention annulée annule automatiquement les bons de préparation non préparés.""" + from interventions.views.workflow import attempt_status_update + from interventions.models import InterventionTimeLine + + self.intervention.status = 'to_be_processed' + self.intervention.save() + self.assertEqual(self.order1.status, 'to_process') + + success, msg, _ = attempt_status_update( + None, self.intervention, self.user_editor, 'canceled', + cancellation_type='other', cancellation_reason='Annulation test' + ) + self.assertTrue(success, msg) + + self.order1.refresh_from_db() + self.assertEqual(self.order1.status, 'cancelled') + self.assertEqual(self.intervention.get_preparation_orders_status(), 'none') + + # Vérifier l'événement timeline + timeline_events = InterventionTimeLine.objects.filter( + intervention=self.intervention, + event_description__icontains=self.order1.order_number, + ) + self.assertTrue(timeline_events.exists()) + + def test_sync_does_not_cancel_prepared_order_and_notifies_stock_managers(self): + """Une intervention annulée n'annule PAS un bon déjà préparé et alerte les magasiniers.""" + from interventions.views.workflow import attempt_status_update + from notifications.models import Notification + + self.intervention.status = 'to_be_processed' + self.intervention.save() + + # Rendre le bon "préparé" + self.order1.status = 'processed' + self.order1.save() + + # Créer un magasinier + role_sm, _ = Role.objects.get_or_create(name='stock_manager') + sm_user = self.User.objects.create_user(username='stock_mgr', password='password123') + sm_config = UserConfig.objects.create(user=sm_user) + sm_config.roles.add(role_sm) + + success, msg, _ = attempt_status_update( + None, self.intervention, self.user_editor, 'canceled', + cancellation_type='other', cancellation_reason='Annulation test' + ) + self.assertTrue(success, msg) + + self.order1.refresh_from_db() + # Le bon n'est PAS annulé car déjà préparé + self.assertEqual(self.order1.status, 'processed') + + # Magasinier prévenu + notif = Notification.objects.filter(recipients__user=sm_user).first() + self.assertIsNotNone(notif) + self.assertTrue("retour en stock" in notif.title.lower() or "return to stock" in notif.title.lower()) + + def test_cannot_create_preparation_order_when_intervention_canceled(self): + """Empêche la création d'un bon de préparation pour une intervention annulée.""" + self.intervention.status = 'canceled' + self.intervention.save() + + # Test permission dans le contexte de détail + desktop_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': self.intervention.id}) + response = self.client_editor.get(desktop_url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.context['can_create_preparation_order']) + + # Test vue stock + stock_url = reverse('stock:new_preparation_order_from_intervention', kwargs={'intervention_id': self.intervention.id}) + post_response = self.client_editor.post(stock_url, data={'date': timezone.now().strftime('%Y-%m-%d %H:%M')}) + self.assertEqual(post_response.status_code, 302) + + def test_mark_as_processed_updates_items_is_prepared(self): + """Vérifie que marquer un bon comme traité met à jour is_prepared sur ses articles.""" + self.assertFalse(self.item1.is_prepared) + self.assertFalse(self.other_item1.is_prepared) + + self.order1.mark_as_processed() + + self.item1.refresh_from_db() + self.other_item1.refresh_from_db() + self.assertTrue(self.item1.is_prepared) + self.assertTrue(self.other_item1.is_prepared) + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 7a0adfe..c9a4a1f 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -514,7 +514,7 @@ def intervention_detail(request, intervention_id): # - admin # - ou droit d'édition sur l'intervention + droit de gérer les bons de préparation sur au moins un entrepôt can_create_preparation_order = bool( - user_config and ( + user_config and intervention.status != 'canceled' and ( user_config.has_role('admin') or (can_manage_links and user_config.can_manage_preparation_orders_any()) ) @@ -3937,15 +3937,18 @@ def toggle_material_used(request, intervention_id, item_id): if is_other: try: - item = PreparationOrderOtherItem.objects.get(pk=item_id, preparation_order_id__in=order_ids) + item = PreparationOrderOtherItem.objects.select_related('preparation_order').get(pk=item_id, preparation_order_id__in=order_ids) except PreparationOrderOtherItem.DoesNotExist: return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404) else: try: - item = PreparationOrderItem.objects.get(pk=item_id, preparation_order_id__in=order_ids) + item = PreparationOrderItem.objects.select_related('preparation_order').get(pk=item_id, preparation_order_id__in=order_ids) except PreparationOrderItem.DoesNotExist: return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404) + if item.preparation_order.status == 'cancelled': + return JsonResponse({'success': False, 'error': _("Impossible de modifier le matériel d'un bon de préparation annulé.")}, status=400) + if set_value is not None: item.is_used = (set_value in ('true', '1', True)) else: @@ -3989,12 +3992,27 @@ def toggle_all_material_used(request, intervention_id): if not can_edit_intervention(request.user, intervention): return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier cette intervention.")}, status=403) - order_id = request.POST.get('order_id') + if request.content_type == 'application/json' and request.body: + try: + payload = json.loads(request.body) + order_id = payload.get('order_id') + used_param = payload.get('used') + except Exception: + order_id = request.POST.get('order_id') + used_param = request.POST.get('used') + else: + order_id = request.POST.get('order_id') + used_param = request.POST.get('used') + orders = intervention.preparation_orders.all() if order_id: orders = orders.filter(pk=order_id) if not orders.exists(): return JsonResponse({'success': False, 'error': _("Bon de préparation introuvable.")}, status=404) + if orders.first().status == 'cancelled': + return JsonResponse({'success': False, 'error': _("Impossible de modifier le matériel d'un bon de préparation annulé.")}, status=400) + else: + orders = orders.exclude(status='cancelled') order_ids = list(orders.values_list('id', flat=True)) std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids) @@ -4003,8 +4021,6 @@ def toggle_all_material_used(request, intervention_id): total_target = std_items.count() + other_items.count() if total_target == 0: return JsonResponse({'success': True, 'all_used': False, 'used_count': 0, 'total_count': 0}) - - used_param = request.POST.get('used') if used_param is not None: target_state = (used_param in ('true', '1', True)) else: diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 0c38e89..55caea9 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -1701,8 +1701,8 @@ def _expand_csv_params(param_list): def annotate_preparation_status(qs): """ - Annote le queryset avec un champ 'prep_state' indiquant l'état du bon de préparation. - Valeurs: 'processed' (tous traités/annulés), 'not_processed' (au moins un non traité), 'none' (pas de bon). + Annote le queryset avec un champ 'prep_state' indiquant l'état du bon de préparation (hors bons annulés). + Valeurs: 'processed' (tous les bons actifs traités), 'not_processed' (au moins un bon actif non traité), 'none' (pas de bon actif). """ from stock.models import PreparationOrder @@ -1712,21 +1712,32 @@ def annotate_preparation_status(qs): filter=Q(preparation_orders__status__in=['to_process', 'pending']) ) - # Compte le total des bons - total_count = Count('preparation_orders') + # Compte les bons traités + processed_count = Count( + 'preparation_orders', + filter=Q(preparation_orders__status='processed') + ) + + # Compte le total des bons actifs (hors annulés) + active_count = Count( + 'preparation_orders', + filter=~Q(preparation_orders__status='cancelled') + ) # Annote le queryset qs = qs.annotate( _prep_untreated_count=untreated_count, - _prep_total_count=total_count + _prep_processed_count=processed_count, + _prep_active_count=active_count ) # Ajoute le champ 'prep_state' basé sur les annotations qs = qs.annotate( prep_state=Case( - When(_prep_total_count=0, then=Value('none')), + When(_prep_active_count=0, then=Value('none')), When(_prep_untreated_count__gt=0, then=Value('not_processed')), - default=Value('processed'), + When(_prep_processed_count__gt=0, then=Value('processed')), + default=Value('none'), output_field=CharField() ) ) diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 9c52dad..0742e35 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -1112,7 +1112,7 @@ def intervention_detail_mobile(request, intervention_id): pass can_create_preparation_order = bool( - user_config and ( + user_config and intervention.status != 'canceled' and ( user_config.has_role('admin') or (can_edit_mobile and user_config.can_manage_preparation_orders_any()) ) diff --git a/loko/interventions/views/workflow.py b/loko/interventions/views/workflow.py index 2b9a74c..ac23ff9 100644 --- a/loko/interventions/views/workflow.py +++ b/loko/interventions/views/workflow.py @@ -672,6 +672,17 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason= # Déclencher les règles de notification configurées par l'administrateur if new_status == 'canceled': + # Synchronisation des bons de préparation liés + try: + from interventions.services.stock_sync_service import sync_preparation_orders_on_intervention_cancel + sync_preparation_orders_on_intervention_cancel(intervention, user=user) + except Exception: + import logging + logging.getLogger(__name__).exception( + "Erreur lors de la synchronisation des bons de préparation suite à l'annulation de l'intervention %s", + intervention.pk, + ) + try: from notifications.services import process_notification_rules from interventions.models import CANCELLATION_TYPE_CHOICES diff --git a/loko/stock/locale/en/LC_MESSAGES/django.mo b/loko/stock/locale/en/LC_MESSAGES/django.mo index ea33d10..6158c5a 100644 Binary files a/loko/stock/locale/en/LC_MESSAGES/django.mo and b/loko/stock/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/stock/locale/en/LC_MESSAGES/django.po b/loko/stock/locale/en/LC_MESSAGES/django.po index f12a3c7..a811dc9 100644 --- a/loko/stock/locale/en/LC_MESSAGES/django.po +++ b/loko/stock/locale/en/LC_MESSAGES/django.po @@ -5873,6 +5873,30 @@ msgstr "Status updated." msgid "Informations de prêt enregistrées." msgstr "Preparation order recorded." +msgid "Impossible de créer un bon de préparation pour une intervention annulée." +msgstr "Cannot create a preparation order for a canceled intervention." + +msgid "Attention : Intervention liée annulée !" +msgstr "Warning: Linked intervention canceled!" + +msgid "Intervention liée annulée !" +msgstr "Linked intervention canceled!" + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti du stock, veuillez effectuer une remise en stock du matériel ou annuler ce bon pour réintégrer automatiquement les articles." +msgstr "The intervention associated with this order has been canceled. If material has already been prepared or issued from stock, please return the material to stock or cancel this order to automatically return items." + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti, veuillez effectuer un retour en stock ou annuler ce bon pour réintégrer les articles." +msgstr "The intervention associated with this order has been canceled. If material has already been prepared or issued, please return to stock or cancel this order to return items." + +msgid "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." +msgstr "Are you sure you want to cancel this order? Issued products will be automatically returned to stock." + +msgid "Annuler le bon et réintégrer en stock" +msgstr "Cancel order and return to stock" + +msgid "Intervention annulée" +msgstr "Intervention canceled" + #~ msgid "Discussion liée à ce bon" #~ msgstr "Discussion related to this order" diff --git a/loko/stock/locale/fr/LC_MESSAGES/django.mo b/loko/stock/locale/fr/LC_MESSAGES/django.mo index 86160ed..6d748df 100644 Binary files a/loko/stock/locale/fr/LC_MESSAGES/django.mo and b/loko/stock/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/stock/locale/fr/LC_MESSAGES/django.po b/loko/stock/locale/fr/LC_MESSAGES/django.po index 88cf9b3..4560dfd 100644 --- a/loko/stock/locale/fr/LC_MESSAGES/django.po +++ b/loko/stock/locale/fr/LC_MESSAGES/django.po @@ -5689,6 +5689,30 @@ msgstr "Statut de retour mis à jour." msgid "Informations de prêt enregistrées." msgstr "Informations de prêt enregistrées." +msgid "Impossible de créer un bon de préparation pour une intervention annulée." +msgstr "Impossible de créer un bon de préparation pour une intervention annulée." + +msgid "Attention : Intervention liée annulée !" +msgstr "Attention : Intervention liée annulée !" + +msgid "Intervention liée annulée !" +msgstr "Intervention liée annulée !" + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti du stock, veuillez effectuer une remise en stock du matériel ou annuler ce bon pour réintégrer automatiquement les articles." +msgstr "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti du stock, veuillez effectuer une remise en stock du matériel ou annuler ce bon pour réintégrer automatiquement les articles." + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti, veuillez effectuer un retour en stock ou annuler ce bon pour réintégrer les articles." +msgstr "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti, veuillez effectuer un retour en stock ou annuler ce bon pour réintégrer les articles." + +msgid "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." +msgstr "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." + +msgid "Annuler le bon et réintégrer en stock" +msgstr "Annuler le bon et réintégrer en stock" + +msgid "Intervention annulée" +msgstr "Intervention annulée" + #~ msgid "Discussion liée à ce bon" #~ msgstr "Discussion liée à ce bon" diff --git a/loko/stock/locale/nl/LC_MESSAGES/django.mo b/loko/stock/locale/nl/LC_MESSAGES/django.mo index 77d5e13..4b56376 100644 Binary files a/loko/stock/locale/nl/LC_MESSAGES/django.mo and b/loko/stock/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/stock/locale/nl/LC_MESSAGES/django.po b/loko/stock/locale/nl/LC_MESSAGES/django.po index b143470..87c1a42 100644 --- a/loko/stock/locale/nl/LC_MESSAGES/django.po +++ b/loko/stock/locale/nl/LC_MESSAGES/django.po @@ -5689,6 +5689,30 @@ msgstr "Status bijgewerkt." msgid "Informations de prêt enregistrées." msgstr "Voorbereidingsbestelling geregistreerd." +msgid "Impossible de créer un bon de préparation pour une intervention annulée." +msgstr "Kan geen voorbereidingsbon aanmaken voor een geannuleerde interventie." + +msgid "Attention : Intervention liée annulée !" +msgstr "Opgelet: Gekoppelde interventie geannuleerd!" + +msgid "Intervention liée annulée !" +msgstr "Gekoppelde interventie geannuleerd!" + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti du stock, veuillez effectuer une remise en stock du matériel ou annuler ce bon pour réintégrer automatiquement les articles." +msgstr "De aan deze bon gekoppelde interventie is geannuleerd. Als het materiaal reeds is voorbereid of uit voorraad is gehaald, voer dan een terugname naar de voorraad uit of annuleer deze bon om de artikelen automatisch terug te boeken." + +msgid "L'intervention associée à ce bon a été annulée. Si le matériel a déjà été préparé ou sorti, veuillez effectuer un retour en stock ou annuler ce bon pour réintégrer les articles." +msgstr "De aan deze bon gekoppelde interventie is geannuleerd. Als het materiaal reeds is voorbereid of uitgegeven, voer dan een terugname naar de voorraad uit of annuleer deze bon om de artikelen terug te boeken." + +msgid "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." +msgstr "Weet u zeker dat u deze bon wilt annuleren? De uitgegeven producten worden automatisch weer aan de voorraad toegevoegd." + +msgid "Annuler le bon et réintégrer en stock" +msgstr "Bon annuleren en terugboeken naar voorraad" + +msgid "Intervention annulée" +msgstr "Interventie geannuleerd" + #~ msgid "Discussion liée à ce bon" #~ msgstr "Discussie gekoppeld aan deze bon" diff --git a/loko/stock/models.py b/loko/stock/models.py index 9e3702f..5b95a06 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -1423,13 +1423,13 @@ class PreparationOrder(models.Model): qr_code = models.ImageField(upload_to='stock/qrcodes/preparation_orders/', blank=True, null=True, verbose_name=_('QR Code')) STATUS_BADGE_CLASSES = { - 'to_process': 'bg-danger', + 'to_process': 'bg-warning text-dark', 'pending': 'bg-info', 'processed': 'bg-success', 'cancelled': 'bg-secondary', } STATUS_ICONS = { - 'to_process': 'bi-hourglass', + 'to_process': 'bi-hourglass-split', 'pending': 'bi-clock', 'processed': 'bi-check-circle', 'cancelled': 'bi-x-circle', @@ -1456,6 +1456,19 @@ class PreparationOrder(models.Model): def status_icon(self): return self.STATUS_ICONS.get(self.status, 'bi-circle') + @property + def is_already_prepared(self): + """Retourne True si le bon est traité, ou si des sorties de stock ont été exécutées, ou si des articles sont marqués préparés.""" + if self.status == 'processed': + return True + if self.stock_batches.filter(movement_type='out').exists(): + return True + if self.preparationorderitem_set.filter(is_prepared=True).exists(): + return True + if self.other_items.filter(is_prepared=True).exists(): + return True + return False + def generate_qr_code(self): """Génère un QR code basé sur le numéro de bon de préparation et le sauvegarde dans le champ qr_code.""" qr = qrcode.QRCode( @@ -1520,9 +1533,11 @@ class PreparationOrder(models.Model): item.update_reserved_quantity() def mark_as_processed(self): - """Marque le bon comme traité et libère les quantités réservées""" + """Marque le bon comme traité, marque ses articles comme préparés et libère les quantités réservées""" self.status = "processed" self.save() + self.preparationorderitem_set.all().update(is_prepared=True) + self.other_items.all().update(is_prepared=True) self.update_reserved_quantities() def reopen(self): 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 52d8fb3..32ea3ca 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 @@ -97,6 +97,25 @@
{% endif %} +{% if preparation_order.intervention and preparation_order.intervention.status == 'canceled' and preparation_order.status != 'cancelled' %} + +{% endif %} + {% if preparation_order.intervention %}
@@ -107,6 +126,9 @@ #{{ preparation_order.intervention.id }} + {% if preparation_order.intervention.status == 'canceled' %} + {% translate "Annulée" %} + {% endif %}
{% if preparation_order.intervention.title or preparation_order.intervention.description %} diff --git a/loko/stock/templates/stock/preparation_order_detail.html b/loko/stock/templates/stock/preparation_order_detail.html index 8b69228..db4d42a 100644 --- a/loko/stock/templates/stock/preparation_order_detail.html +++ b/loko/stock/templates/stock/preparation_order_detail.html @@ -39,7 +39,7 @@ {{ preparation_order.get_status_display }} {% elif preparation_order.status == 'cancelled' %} - + {{ preparation_order.get_status_display }} {% endif %} @@ -77,6 +77,27 @@
{% endif %} + {% if preparation_order.intervention and preparation_order.intervention.status == 'canceled' and preparation_order.status != 'cancelled' %} + + {% endif %} + {% if preparation_order.intervention %}
@@ -90,6 +111,9 @@ {{ preparation_order.intervention.code }} + {% if preparation_order.intervention.status == 'canceled' %} + {% translate "Intervention annulée" %} + {% endif %}
{% translate "Titre" %}: diff --git a/loko/stock/views.py b/loko/stock/views.py index 534f058..b05d269 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -3608,6 +3608,10 @@ def new_preparation_order_from_intervention(request, intervention_id): intervention = get_object_or_404(Intervention, pk=intervention_id) + if intervention.status == 'canceled': + messages.error(request, _("Impossible de créer un bon de préparation pour une intervention annulée.")) + return redirect(redirect_target, intervention_id=intervention_id) + can_create_from_intervention = bool( user_config.has_role('admin') or (