feat: add preparation order management features and mobile intervention detail view

This commit is contained in:
Karl Determe 2026-09-01 14:43:52 +02:00
parent ee90cf3173
commit fecd7430ad
5 changed files with 195 additions and 6 deletions

View file

@ -1057,6 +1057,99 @@
{% endif %} {% endif %}
<!-- Stock / Bons de préparation -->
{% if can_create_preparation_order or preparation_orders %}
<div class="card mt-4 mb-4 bg-white" id="section-stock">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold d-flex align-items-center gap-1">
<i class="bi bi-box-seam me-1 text-primary"></i>{% translate "Stock" %}
</span>
<div class="d-flex align-items-center gap-2">
{% if preparation_orders %}
<span class="badge bg-secondary rounded-pill" style="font-size:0.75rem;">
{{ preparation_orders|length }}
</span>
{% endif %}
{% if can_create_preparation_order %}
<a href="{% url 'stock:new_preparation_order_from_intervention' intervention.id %}?v=mobile"
class="btn btn-light border-0 p-1"
title="{% translate 'Créer un bon de préparation' %}">
<i class="bi bi-plus-circle text-primary fs-5"></i>
</a>
{% endif %}
</div>
</div>
<div class="card-body">
{% if preparation_orders %}
<div class="list-group list-group-flush">
{% for order in preparation_orders %}
<div class="list-group-item px-0 py-2 {% if not forloop.last %}border-bottom{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}" class="fw-semibold text-decoration-none">
#{{ order.order_number }}
</a>
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
<span class="badge {{ order.priority_badge_class }}">
{{ order.get_priority_display }}
</span>
{% if order.is_loan %}
<span class="badge bg-primary">
<i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %}
</span>
{% endif %}
</div>
<div class="text-muted small mb-1">
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
</div>
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<div class="d-flex flex-wrap gap-1 mt-1">
{% for item in items|slice:":3" %}
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
{{ item.product.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% for item in other_items|slice:":2" %}
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
{{ item.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% with total_count=items.count|add:other_items.count %}
{% if total_count > 3 %}
<span class="badge bg-secondary-subtle text-muted" style="font-size:0.75rem;">
+{{ total_count|add:"-3" }}
</span>
{% endif %}
{% endwith %}
</div>
{% else %}
<span class="text-muted small fst-italic">{% translate "Aucun article" %}</span>
{% endif %}
{% endwith %}
</div>
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}"
class="btn btn-sm btn-outline-primary align-self-center flex-shrink-0"
title="{% translate 'Voir les détails' %}">
<i class="bi bi-chevron-right"></i>
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0 small">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun bon de préparation créé pour cette intervention." %}
</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Contrôles / Rapports de suivi --> <!-- Contrôles / Rapports de suivi -->
{% if intervention.status != 'in_preparation' %} {% if intervention.status != 'in_preparation' %}

View file

@ -1073,10 +1073,25 @@ def intervention_detail_mobile(request, intervention_id):
except Exception: except Exception:
pass pass
can_create_preparation_order = bool(
user_config and (
user_config.has_role('admin')
or (can_edit_mobile and user_config.can_manage_preparation_orders_any())
)
)
preparation_orders = (
intervention.preparation_orders.all()
.prefetch_related('preparationorderitem_set__product', 'other_items')
.order_by('-date')
if hasattr(intervention, 'preparation_orders') else []
)
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes) sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
context = { context = {
'intervention': intervention, 'intervention': intervention,
'can_create_preparation_order': can_create_preparation_order,
'preparation_orders': preparation_orders,
'sequenced_blocks': sequenced_data['blocks'], 'sequenced_blocks': sequenced_data['blocks'],
'document_blocks': sequenced_data['blocks'], 'document_blocks': sequenced_data['blocks'],
'all_sequenced_documents': sequenced_data['all_documents'], 'all_sequenced_documents': sequenced_data['all_documents'],

View file

@ -103,3 +103,73 @@ class MobileStockPermissionTests(TestCase):
stock_response = self.client.get(reverse("mobile:stock_index_mobile")) stock_response = self.client.get(reverse("mobile:stock_index_mobile"))
self.assertEqual(stock_response.status_code, 200) self.assertEqual(stock_response.status_code, 200)
class MobileInterventionDetailStockTests(TestCase):
def setUp(self):
from stock.models import PreparationOrder, PreparationOrderItem, Product, ProductCategory
from interventions.models import Intervention
self.user = User.objects.create_user(username="detailstockuser", password="password")
self.client.login(username="detailstockuser", password="password")
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.admin_role, _ = Role.objects.get_or_create(name="admin")
self.user_config.roles.add(self.admin_role)
self.thematic, _ = Thematic.objects.get_or_create(code="trafficlights", defaults={"name_fr": "Feux", "name_nl": "Verkeerslichten"})
self.user_config.default_thematic = self.thematic
self.user_config.save()
self.intervention = Intervention.objects.create(
title="Intervention Test Stock",
status="in_progress",
thematic=self.thematic,
)
self.category, _ = ProductCategory.objects.get_or_create(code="SIG", defaults={"name_fr": "Signalisation"})
self.product = Product.objects.create(
name="Poteau feux",
code="POT-01",
category=self.category,
)
self.prep_order = PreparationOrder.objects.create(
order_number="BP-TEST-001",
intervention=self.intervention,
status="to_process",
priority="2",
)
PreparationOrderItem.objects.create(
preparation_order=self.prep_order,
product=self.product,
quantity=3,
)
def test_stock_section_in_mobile_intervention_detail(self):
url = reverse("mobile:intervention_detail_mobile", args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("can_create_preparation_order", response.context)
self.assertTrue(response.context["can_create_preparation_order"])
self.assertIn("preparation_orders", response.context)
self.assertEqual(len(response.context["preparation_orders"]), 1)
content = response.content.decode("utf-8")
self.assertIn("BP-TEST-001", content)
self.assertIn("Poteau feux", content)
self.assertIn("section-stock", content)
def test_stock_section_without_create_rights(self):
# Non-admin user without preparation order management rights
self.user_config.roles.remove(self.admin_role)
observer_role, _ = Role.objects.get_or_create(name="observer")
self.user_config.roles.add(observer_role)
url = reverse("mobile:intervention_detail_mobile", args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["can_create_preparation_order"])
self.assertEqual(len(response.context["preparation_orders"]), 1)

View file

@ -11,6 +11,7 @@
<form method="post" enctype="multipart/form-data" id="preparation-order-form"> <form method="post" enctype="multipart/form-data" id="preparation-order-form">
{% csrf_token %} {% csrf_token %}
{% if variant %}<input type="hidden" name="v" value="{{ variant }}">{% endif %}
<div class="mb-3"> <div class="mb-3">
{{ form.non_field_errors }} {{ form.non_field_errors }}
@ -262,9 +263,15 @@
<!-- Envoi du formulaire --> <!-- Envoi du formulaire -->
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
{% if from_intervention %} {% if from_intervention %}
<a href="{% url 'interventions:intervention_detail' intervention.id %}" class="btn btn-secondary"> {% if variant == 'mobile' %}
<i class="bi bi-arrow-left"></i> {% translate "Retour à l'intervention" %} <a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="btn btn-secondary">
</a> <i class="bi bi-arrow-left"></i> {% translate "Retour à l'intervention" %}
</a>
{% else %}
<a href="{% url 'interventions:intervention_detail' intervention.id %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> {% translate "Retour à l'intervention" %}
</a>
{% endif %}
{% else %} {% else %}
<a href="{% url 'stock:stock_movements' %}" class="btn btn-secondary"> <a href="{% url 'stock:stock_movements' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> {% translate "Retour" %} <i class="bi bi-arrow-left"></i> {% translate "Retour" %}

View file

@ -3562,12 +3562,15 @@ def new_preparation_order_from_intervention(request, intervention_id):
from interventions.permissions import can_edit_intervention from interventions.permissions import can_edit_intervention
from common.models import UserConfig from common.models import UserConfig
variant = request.GET.get('v') or request.POST.get('v')
redirect_target = "mobile:intervention_detail_mobile" if variant == 'mobile' else "interventions:intervention_detail"
# Vérifier les permissions # Vérifier les permissions
try: try:
user_config = request.user.config user_config = request.user.config
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
messages.error(request, _("Configuration utilisateur non trouvée.")) messages.error(request, _("Configuration utilisateur non trouvée."))
return redirect("interventions:intervention_detail", intervention_id=intervention_id) return redirect(redirect_target, intervention_id=intervention_id)
intervention = get_object_or_404(Intervention, pk=intervention_id) intervention = get_object_or_404(Intervention, pk=intervention_id)
@ -3581,7 +3584,7 @@ def new_preparation_order_from_intervention(request, intervention_id):
if not can_create_from_intervention: if not can_create_from_intervention:
messages.error(request, _("Vous n'avez pas la permission de créer des bons de préparation.")) messages.error(request, _("Vous n'avez pas la permission de créer des bons de préparation."))
return redirect("interventions:intervention_detail", intervention_id=intervention_id) return redirect(redirect_target, intervention_id=intervention_id)
preparation_order_item_formset = inlineformset_factory( preparation_order_item_formset = inlineformset_factory(
PreparationOrder, PreparationOrderItem, form=PreparationOrderItemForm, extra=3, can_delete=True PreparationOrder, PreparationOrderItem, form=PreparationOrderItemForm, extra=3, can_delete=True
@ -3609,7 +3612,7 @@ def new_preparation_order_from_intervention(request, intervention_id):
) )
messages.success(request, _("Bon de préparation créé avec succès.")) messages.success(request, _("Bon de préparation créé avec succès."))
return redirect("interventions:intervention_detail", intervention_id=intervention_id) return redirect(redirect_target, intervention_id=intervention_id)
else: else:
# Générer un numéro de bon automatique # Générer un numéro de bon automatique
import datetime import datetime
@ -3637,6 +3640,7 @@ def new_preparation_order_from_intervention(request, intervention_id):
"from_intervention": True, "from_intervention": True,
"categories": categories, "categories": categories,
"signalisation_category_ids": signalisation_ids, "signalisation_category_ids": signalisation_ids,
"variant": variant,
}, },
) )