loko/loko/interventions/services/stock_sync_service.py
kdeterme de73de7328 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
2026-09-28 20:01:39 +02:00

170 lines
7.8 KiB
Python

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