feat: add preparation order management features and mobile intervention detail view
This commit is contained in:
parent
ee90cf3173
commit
fecd7430ad
5 changed files with 195 additions and 6 deletions
|
|
@ -1057,6 +1057,99 @@
|
|||
{% 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 }} × {{ 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 }} × {{ 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 -->
|
||||
{% if intervention.status != 'in_preparation' %}
|
||||
|
||||
|
|
|
|||
|
|
@ -1073,10 +1073,25 @@ def intervention_detail_mobile(request, intervention_id):
|
|||
except Exception:
|
||||
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)
|
||||
|
||||
context = {
|
||||
'intervention': intervention,
|
||||
'can_create_preparation_order': can_create_preparation_order,
|
||||
'preparation_orders': preparation_orders,
|
||||
'sequenced_blocks': sequenced_data['blocks'],
|
||||
'document_blocks': sequenced_data['blocks'],
|
||||
'all_sequenced_documents': sequenced_data['all_documents'],
|
||||
|
|
|
|||
|
|
@ -103,3 +103,73 @@ class MobileStockPermissionTests(TestCase):
|
|||
stock_response = self.client.get(reverse("mobile:stock_index_mobile"))
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
<form method="post" enctype="multipart/form-data" id="preparation-order-form">
|
||||
{% csrf_token %}
|
||||
{% if variant %}<input type="hidden" name="v" value="{{ variant }}">{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.non_field_errors }}
|
||||
|
|
@ -262,9 +263,15 @@
|
|||
<!-- Envoi du formulaire -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
{% if from_intervention %}
|
||||
{% if variant == 'mobile' %}
|
||||
<a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="btn btn-secondary">
|
||||
<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 %}
|
||||
<a href="{% url 'stock:stock_movements' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> {% translate "Retour" %}
|
||||
|
|
|
|||
|
|
@ -3562,12 +3562,15 @@ def new_preparation_order_from_intervention(request, intervention_id):
|
|||
from interventions.permissions import can_edit_intervention
|
||||
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
|
||||
try:
|
||||
user_config = request.user.config
|
||||
except UserConfig.DoesNotExist:
|
||||
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)
|
||||
|
||||
|
|
@ -3581,7 +3584,7 @@ def new_preparation_order_from_intervention(request, intervention_id):
|
|||
|
||||
if not can_create_from_intervention:
|
||||
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(
|
||||
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."))
|
||||
return redirect("interventions:intervention_detail", intervention_id=intervention_id)
|
||||
return redirect(redirect_target, intervention_id=intervention_id)
|
||||
else:
|
||||
# Générer un numéro de bon automatique
|
||||
import datetime
|
||||
|
|
@ -3637,6 +3640,7 @@ def new_preparation_order_from_intervention(request, intervention_id):
|
|||
"from_intervention": True,
|
||||
"categories": categories,
|
||||
"signalisation_category_ids": signalisation_ids,
|
||||
"variant": variant,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue