feat(interventions,stock): sync preparation orders and material status on intervention cancellation

- Fix preparation status calculation by ignoring cancelled orders, preventing false green 'stock ready' icon
- Update item prepared state when preparation order is marked as processed
- Automatically cancel unprepared preparation orders when intervention is canceled
- Send alert notification to stock managers when canceled intervention has already prepared orders
- Add alert banner with direct restock/cancellation action on preparation order detail views
- Prevent editing material switches or creating orders for canceled interventions
- Add and compile translations for FR, EN and NL
- Add comprehensive automated unit tests
This commit is contained in:
kdeterme 2026-09-28 20:01:39 +02:00
parent c00212f79e
commit de73de7328
26 changed files with 715 additions and 73 deletions

View file

@ -8372,5 +8372,40 @@ msgstr "%(count)d unlinked %(relation)ss: %(targets)s"
msgid "%(relation)s déliée(s)" msgid "%(relation)s déliée(s)"
msgstr "Unlinked %(relation)s(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"

View file

@ -8479,5 +8479,40 @@ msgstr "%(count)d %(relation)ss déliées : %(targets)s"
msgid "%(relation)s déliée(s)" msgid "%(relation)s déliée(s)"
msgstr "%(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é"

View file

@ -8413,4 +8413,39 @@ msgstr "%(count)d %(relation)ss ontkoppeld: %(targets)s"
msgid "%(relation)s déliée(s)" msgid "%(relation)s déliée(s)"
msgstr "Ontkoppelde %(relation)s(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"

View file

@ -995,55 +995,57 @@ class Intervention(models.Model):
def get_preparation_orders_status(self): def get_preparation_orders_status(self):
""" """
Agrège les statuts des bons de préparation liés. Agrège les statuts des bons de préparation liés (hors bons annulés).
Retourne: 'processed' si tous les bons sont traités/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 est à traiter/en attente, 'not_processed' si au moins un bon actif est à traiter/en attente,
'none' si aucun bon n'existe. '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 hasattr(self, 'preparation_orders'):
if not prep_orders: return 'none'
active_orders = list(
self.preparation_orders.exclude(status='cancelled').values_list('status', flat=True)
)
if not active_orders:
return 'none' return 'none'
# Statuts considérés comme 'traité' if any(s in ('to_process', 'pending') for s in active_orders):
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:
return 'not_processed' return 'not_processed'
return 'processed'
if any(s == 'processed' for s in active_orders):
return 'processed'
return 'none'
@property @property
def has_pending_preparation(self): def has_pending_preparation(self):
"""Retourne True si au moins un bon de préparation est à traiter ou en attente.""" """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( return self.preparation_orders.filter(
status__in=['to_process', 'pending'] status__in=['to_process', 'pending']
).exists() ).exists()
@property @property
def has_material(self): 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'): if not hasattr(self, 'preparation_orders'):
return False return False
return self.preparation_orders.filter( return self.preparation_orders.exclude(status='cancelled').filter(
Q(preparationorderitem__isnull=False) | Q(other_items__isnull=False) Q(preparationorderitem__isnull=False) | Q(other_items__isnull=False)
).distinct().exists() ).distinct().exists()
def get_material_stats(self): def get_material_stats(self):
""" """
Retourne le statut d'utilisation du matériel pour cette intervention : Retourne le statut d'utilisation du matériel pour cette intervention (hors bons annulés) :
- total : nombre total d'articles (catalogue + autres) - total : nombre total d'articles (catalogue + autres) des bons actifs
- used : nombre d'articles marqués comme utilisés - used : nombre d'articles marqués comme utilisés
- all_used : True si tous les articles sont utilisés (et total > 0) - 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'): if not hasattr(self, 'preparation_orders'):
return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False} return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False}
from stock.models import PreparationOrderItem, PreparationOrderOtherItem 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: if not order_ids:
return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False} return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False}
std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids) std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids)

View file

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

View file

@ -94,32 +94,34 @@
</div> </div>
</div> </div>
{% if intervention.preparation_orders.all %} {% if intervention.has_material %}
<div class="d-flex flex-wrap align-items-center gap-1 mt-2 pt-2 border-top"> <div class="d-flex flex-wrap align-items-center gap-1 mt-2 pt-2 border-top">
<i class="bi bi-box-seam text-primary me-1" style="font-size:0.8rem;" title="{% translate 'Matériel / Stock' %}"></i> <i class="bi bi-box-seam text-primary me-1" style="font-size:0.8rem;" title="{% translate 'Matériel / Stock' %}"></i>
{% for order in intervention.preparation_orders.all %} {% for order in intervention.preparation_orders.all %}
{% if order.is_loan %} {% if order.status != 'cancelled' %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size:0.7rem;"> {% if order.is_loan %}
<i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %} <span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size:0.7rem;">
</span> <i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %}
{% endif %}
{% for item in order.preparationorderitem_set.all|slice:":3" %}
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.product.name }} &times; {{ item.quantity }}">
{{ item.product.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% for item in order.other_items.all|slice:":2" %}
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.name }} &times; {{ item.quantity }}">
{{ item.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% with total_count=order.preparationorderitem_set.all|length|add:order.other_items.all|length %}
{% if total_count > 3 %}
<span class="badge bg-secondary-subtle text-muted" style="font-size:0.7rem;">
+{{ total_count|add:"-3" }}
</span> </span>
{% endif %} {% endif %}
{% endwith %} {% for item in order.preparationorderitem_set.all|slice:":3" %}
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.product.name }} &times; {{ item.quantity }}">
{{ item.product.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% for item in order.other_items.all|slice:":2" %}
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.name }} &times; {{ item.quantity }}">
{{ item.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% with total_count=order.preparationorderitem_set.all|length|add:order.other_items.all|length %}
{% if total_count > 3 %}
<span class="badge bg-secondary-subtle text-muted" style="font-size:0.7rem;">
+{{ total_count|add:"-3" }}
</span>
{% endif %}
{% endwith %}
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View file

@ -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" 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 }}" id="btn-order-toggle-all-{{ order.id }}"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ 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;"> style="font-size: 0.75rem;">
<i class="bi bi-check-all fs-6"></i> <i class="bi bi-check-all fs-6"></i>
<span class="btn-order-toggle-all-text" id="btn-order-toggle-all-text-{{ order.id }}"> <span class="btn-order-toggle-all-text" id="btn-order-toggle-all-text-{{ order.id }}">
@ -107,14 +107,14 @@
data-is-other="false" data-is-other="false"
data-order-id="{{ order.id }}" data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %} {% 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)"> onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)">
</div> </div>
</td> </td>
<td> <td>
<span class="fw-semibold">{{ item.product.name }}</span> <span class="fw-semibold">{{ item.product.name }}</span>
{% if item.product.category %} {% if item.product.category %}
<small class="text-muted d-block">{{ item.product.category.name_fr }}</small> <small class="text-muted d-block">{{ item.product.category.name_fr }}</small>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -130,7 +130,11 @@
</span> </span>
</td> </td>
<td class="text-center"> <td class="text-center">
{% if item.is_prepared %} {% if order.status == 'cancelled' %}
<span class="badge bg-secondary-subtle text-muted border" title="{% translate 'Bon annulé' %}">
<i class="bi bi-x-circle"></i> {% translate "Annulé" %}
</span>
{% elif item.is_prepared or order.status == 'processed' %}
<span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}"> <span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}">
<i class="bi bi-check2"></i> {% translate "Préparé" %} <i class="bi bi-check2"></i> {% translate "Préparé" %}
</span> </span>
@ -164,7 +168,7 @@
data-is-other="true" data-is-other="true"
data-order-id="{{ order.id }}" data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %} {% 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)"> onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)">
</div> </div>
</td> </td>
@ -184,7 +188,11 @@
</span> </span>
</td> </td>
<td class="text-center"> <td class="text-center">
{% if item.is_prepared %} {% if order.status == 'cancelled' %}
<span class="badge bg-secondary-subtle text-muted border" title="{% translate 'Bon annulé' %}">
<i class="bi bi-x-circle"></i> {% translate "Annulé" %}
</span>
{% elif item.is_prepared or order.status == 'processed' %}
<span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}"> <span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}">
<i class="bi bi-check2"></i> {% translate "Préparé" %} <i class="bi bi-check2"></i> {% translate "Préparé" %}
</span> </span>

View file

@ -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" 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 }}" id="btn-mobile-order-toggle-all-{{ order.id }}"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ 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;"> style="font-size: 0.75rem;">
<i class="bi bi-check-all fs-6"></i> <i class="bi bi-check-all fs-6"></i>
<span class="btn-order-toggle-all-text" id="btn-mobile-order-toggle-all-text-{{ order.id }}"> <span class="btn-order-toggle-all-text" id="btn-mobile-order-toggle-all-text-{{ order.id }}">
@ -1201,14 +1201,16 @@
data-is-other="false" data-is-other="false"
data-order-id="{{ order.id }}" data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %} {% 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)"> onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)">
</div> </div>
<div class="text-truncate"> <div class="text-truncate">
<div class="fw-semibold text-truncate small">{{ item.product.name }}</div> <div class="fw-semibold text-truncate small">{{ item.product.name }}</div>
<div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;"> <div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;">
{% if item.product.code %}<span><code>{{ item.product.code }}</code></span>{% endif %} {% if item.product.code %}<span><code>{{ item.product.code }}</code></span>{% endif %}
{% if item.is_prepared %} {% if order.status == 'cancelled' %}
<span class="text-muted"><i class="bi bi-x-circle"></i> {% translate "Annulé" %}</span>
{% elif item.is_prepared or order.status == 'processed' %}
<span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span> <span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span>
{% else %} {% else %}
<span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span> <span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span>
@ -1244,7 +1246,7 @@
data-is-other="true" data-is-other="true"
data-order-id="{{ order.id }}" data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %} {% 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)"> onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)">
</div> </div>
<div class="text-truncate"> <div class="text-truncate">
@ -1253,7 +1255,9 @@
<span class="badge bg-light text-secondary border ms-1" style="font-size:0.65rem;">{% translate "Hors cat." %}</span> <span class="badge bg-light text-secondary border ms-1" style="font-size:0.65rem;">{% translate "Hors cat." %}</span>
</div> </div>
<div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;"> <div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;">
{% if item.is_prepared %} {% if order.status == 'cancelled' %}
<span class="text-muted"><i class="bi bi-x-circle"></i> {% translate "Annulé" %}</span>
{% elif item.is_prepared or order.status == 'processed' %}
<span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span> <span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span>
{% else %} {% else %}
<span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span> <span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span>

View file

@ -55,7 +55,7 @@ class MaterialUsedTests(TestCase):
contract=self.contract, contract=self.contract,
can_view_interventions=True, 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( UserContractStatusPermission.objects.create(
user_contract=uca, user_contract=uca,
status=status, status=status,
@ -343,3 +343,179 @@ class MaterialUsedTests(TestCase):
self.assertEqual(_("utilisé(s)"), "used") self.assertEqual(_("utilisé(s)"), "used")
self.assertEqual(_("Hors catalogue"), "Out of catalog") 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)

View file

@ -514,7 +514,7 @@ def intervention_detail(request, intervention_id):
# - admin # - admin
# - ou droit d'édition sur l'intervention + droit de gérer les bons de préparation sur au moins un entrepôt # - 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( can_create_preparation_order = bool(
user_config and ( user_config and intervention.status != 'canceled' and (
user_config.has_role('admin') user_config.has_role('admin')
or (can_manage_links and user_config.can_manage_preparation_orders_any()) 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: if is_other:
try: 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: except PreparationOrderOtherItem.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404) return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404)
else: else:
try: 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: except PreparationOrderItem.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404) 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: if set_value is not None:
item.is_used = (set_value in ('true', '1', True)) item.is_used = (set_value in ('true', '1', True))
else: else:
@ -3989,12 +3992,27 @@ def toggle_all_material_used(request, intervention_id):
if not can_edit_intervention(request.user, intervention): 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) 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() orders = intervention.preparation_orders.all()
if order_id: if order_id:
orders = orders.filter(pk=order_id) orders = orders.filter(pk=order_id)
if not orders.exists(): if not orders.exists():
return JsonResponse({'success': False, 'error': _("Bon de préparation introuvable.")}, status=404) 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)) order_ids = list(orders.values_list('id', flat=True))
std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids) 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() total_target = std_items.count() + other_items.count()
if total_target == 0: if total_target == 0:
return JsonResponse({'success': True, 'all_used': False, 'used_count': 0, 'total_count': 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: if used_param is not None:
target_state = (used_param in ('true', '1', True)) target_state = (used_param in ('true', '1', True))
else: else:

View file

@ -1701,8 +1701,8 @@ def _expand_csv_params(param_list):
def annotate_preparation_status(qs): def annotate_preparation_status(qs):
""" """
Annote le queryset avec un champ 'prep_state' indiquant l'état du bon de préparation. Annote le queryset avec un champ 'prep_state' indiquant l'état du bon de préparation (hors bons annulés).
Valeurs: 'processed' (tous traités/annulés), 'not_processed' (au moins un non traité), 'none' (pas de bon). 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 from stock.models import PreparationOrder
@ -1712,21 +1712,32 @@ def annotate_preparation_status(qs):
filter=Q(preparation_orders__status__in=['to_process', 'pending']) filter=Q(preparation_orders__status__in=['to_process', 'pending'])
) )
# Compte le total des bons # Compte les bons traités
total_count = Count('preparation_orders') 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 # Annote le queryset
qs = qs.annotate( qs = qs.annotate(
_prep_untreated_count=untreated_count, _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 # Ajoute le champ 'prep_state' basé sur les annotations
qs = qs.annotate( qs = qs.annotate(
prep_state=Case( 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')), 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() output_field=CharField()
) )
) )

View file

@ -1112,7 +1112,7 @@ def intervention_detail_mobile(request, intervention_id):
pass pass
can_create_preparation_order = bool( can_create_preparation_order = bool(
user_config and ( user_config and intervention.status != 'canceled' and (
user_config.has_role('admin') user_config.has_role('admin')
or (can_edit_mobile and user_config.can_manage_preparation_orders_any()) or (can_edit_mobile and user_config.can_manage_preparation_orders_any())
) )

View file

@ -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 # Déclencher les règles de notification configurées par l'administrateur
if new_status == 'canceled': 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: try:
from notifications.services import process_notification_rules from notifications.services import process_notification_rules
from interventions.models import CANCELLATION_TYPE_CHOICES from interventions.models import CANCELLATION_TYPE_CHOICES

View file

@ -5873,6 +5873,30 @@ msgstr "Status updated."
msgid "Informations de prêt enregistrées." msgid "Informations de prêt enregistrées."
msgstr "Preparation order recorded." 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" #~ msgid "Discussion liée à ce bon"
#~ msgstr "Discussion related to this order" #~ msgstr "Discussion related to this order"

View file

@ -5689,6 +5689,30 @@ msgstr "Statut de retour mis à jour."
msgid "Informations de prêt enregistrées." msgid "Informations de prêt enregistrées."
msgstr "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" #~ msgid "Discussion liée à ce bon"
#~ msgstr "Discussion liée à ce bon" #~ msgstr "Discussion liée à ce bon"

View file

@ -5689,6 +5689,30 @@ msgstr "Status bijgewerkt."
msgid "Informations de prêt enregistrées." msgid "Informations de prêt enregistrées."
msgstr "Voorbereidingsbestelling geregistreerd." 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" #~ msgid "Discussion liée à ce bon"
#~ msgstr "Discussie gekoppeld aan deze bon" #~ msgstr "Discussie gekoppeld aan deze bon"

View file

@ -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')) qr_code = models.ImageField(upload_to='stock/qrcodes/preparation_orders/', blank=True, null=True, verbose_name=_('QR Code'))
STATUS_BADGE_CLASSES = { STATUS_BADGE_CLASSES = {
'to_process': 'bg-danger', 'to_process': 'bg-warning text-dark',
'pending': 'bg-info', 'pending': 'bg-info',
'processed': 'bg-success', 'processed': 'bg-success',
'cancelled': 'bg-secondary', 'cancelled': 'bg-secondary',
} }
STATUS_ICONS = { STATUS_ICONS = {
'to_process': 'bi-hourglass', 'to_process': 'bi-hourglass-split',
'pending': 'bi-clock', 'pending': 'bi-clock',
'processed': 'bi-check-circle', 'processed': 'bi-check-circle',
'cancelled': 'bi-x-circle', 'cancelled': 'bi-x-circle',
@ -1456,6 +1456,19 @@ class PreparationOrder(models.Model):
def status_icon(self): def status_icon(self):
return self.STATUS_ICONS.get(self.status, 'bi-circle') 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): 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.""" """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( qr = qrcode.QRCode(
@ -1520,9 +1533,11 @@ class PreparationOrder(models.Model):
item.update_reserved_quantity() item.update_reserved_quantity()
def mark_as_processed(self): 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.status = "processed"
self.save() self.save()
self.preparationorderitem_set.all().update(is_prepared=True)
self.other_items.all().update(is_prepared=True)
self.update_reserved_quantities() self.update_reserved_quantities()
def reopen(self): def reopen(self):

View file

@ -97,6 +97,25 @@
</div> </div>
{% endif %} {% endif %}
{% if preparation_order.intervention and preparation_order.intervention.status == 'canceled' and preparation_order.status != 'cancelled' %}
<div class="alert alert-danger border-danger shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-exclamation-octagon-fill text-danger me-2 fs-4"></i>
<h6 class="alert-heading mb-0 fw-bold">{% translate "Intervention liée annulée !" %}</h6>
</div>
<p class="mb-2 small" style="font-size:0.82rem;">{% translate "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." %}</p>
{% if can_cancel %}
<form method="post" action="{% url 'stock:preparation_order_cancel' preparation_order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." %}');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-arrow-return-left me-1"></i> {% translate "Annuler le bon et réintégrer en stock" %}
</button>
</form>
{% endif %}
</div>
{% endif %}
<!-- Intervention liée --> <!-- Intervention liée -->
{% if preparation_order.intervention %} {% if preparation_order.intervention %}
<div class="info-card"> <div class="info-card">
@ -107,6 +126,9 @@
<a href="{% url 'mobile:intervention_detail_mobile' preparation_order.intervention.id %}"> <a href="{% url 'mobile:intervention_detail_mobile' preparation_order.intervention.id %}">
#{{ preparation_order.intervention.id }} #{{ preparation_order.intervention.id }}
</a> </a>
{% if preparation_order.intervention.status == 'canceled' %}
<span class="badge bg-danger ms-1">{% translate "Annulée" %}</span>
{% endif %}
</span> </span>
</div> </div>
{% if preparation_order.intervention.title or preparation_order.intervention.description %} {% if preparation_order.intervention.title or preparation_order.intervention.description %}

View file

@ -39,7 +39,7 @@
<i class="bi bi-check-circle"></i> {{ preparation_order.get_status_display }} <i class="bi bi-check-circle"></i> {{ preparation_order.get_status_display }}
</span> </span>
{% elif preparation_order.status == 'cancelled' %} {% elif preparation_order.status == 'cancelled' %}
<span class="badge bg-danger fs-5"> <span class="badge bg-secondary fs-5">
<i class="bi bi-x-circle"></i> {{ preparation_order.get_status_display }} <i class="bi bi-x-circle"></i> {{ preparation_order.get_status_display }}
</span> </span>
{% endif %} {% endif %}
@ -77,6 +77,27 @@
</div> </div>
{% endif %} {% endif %}
{% if preparation_order.intervention and preparation_order.intervention.status == 'canceled' and preparation_order.status != 'cancelled' %}
<div class="alert alert-danger border-danger shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-octagon-fill text-danger me-3 fs-1"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">{% translate "Attention : Intervention liée annulée !" %}</h5>
<p class="mb-2">{% translate "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." %}</p>
{% if can_cancel %}
<form method="post" action="{% url 'stock:preparation_order_cancel' preparation_order.pk %}" class="d-inline"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir annuler ce bon ? Les produits sortis seront automatiquement réintégrés en stock." %}');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-arrow-return-left me-1"></i> {% translate "Annuler le bon et réintégrer en stock" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Intervention liée --> <!-- Intervention liée -->
{% if preparation_order.intervention %} {% if preparation_order.intervention %}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
@ -90,6 +111,9 @@
<a href="{% url 'interventions:intervention_detail' preparation_order.intervention.id %}" target="_blank"> <a href="{% url 'interventions:intervention_detail' preparation_order.intervention.id %}" target="_blank">
{{ preparation_order.intervention.code }} {{ preparation_order.intervention.code }}
</a> </a>
{% if preparation_order.intervention.status == 'canceled' %}
<span class="badge bg-danger ms-1">{% translate "Intervention annulée" %}</span>
{% endif %}
</div> </div>
<div class="{% if preparation_order.intervention.intervention_manager %}col-md-4{% else %}col-md-6{% endif %}"> <div class="{% if preparation_order.intervention.intervention_manager %}col-md-4{% else %}col-md-6{% endif %}">
<strong>{% translate "Titre" %}:</strong> <strong>{% translate "Titre" %}:</strong>

View file

@ -3608,6 +3608,10 @@ def new_preparation_order_from_intervention(request, intervention_id):
intervention = get_object_or_404(Intervention, pk=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( can_create_from_intervention = bool(
user_config.has_role('admin') user_config.has_role('admin')
or ( or (