From 26c07a769ffce7ee76fd63722da242c00806c9af Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 11 Sep 2026 10:48:36 +0200 Subject: [PATCH] feat: add usage tracking for material items in preparation orders and include status in reporting --- loko/interventions/models.py | 34 ++ .../interventions/detail/card_stock.html | 303 ++++++++++++---- .../interventions/detail/scripts.html | 126 +++++++ .../intervention_detail_mobile.html | 340 +++++++++++++++--- .../interventions/tests/test_material_used.py | 310 ++++++++++++++++ loko/interventions/urls.py | 2 + loko/interventions/views/detail.py | 116 +++++- loko/interventions/views/mobile.py | 2 + .../views/views_pdf_reporting.py | 17 +- loko/stock/admin.py | 20 +- ...2_preparationorderitem_is_used_and_more.py | 23 ++ loko/stock/models.py | 57 +++ loko/stock/tests.py | 3 +- 13 files changed, 1217 insertions(+), 136 deletions(-) create mode 100644 loko/interventions/tests/test_material_used.py create mode 100644 loko/stock/migrations/0002_preparationorderitem_is_used_and_more.py diff --git a/loko/interventions/models.py b/loko/interventions/models.py index f3fdf00..cadd6ad 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -810,6 +810,40 @@ class Intervention(models.Model): 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.""" + if not hasattr(self, 'preparation_orders'): + return False + return self.preparation_orders.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) + - 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 + """ + 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)) + 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) + other_items = PreparationOrderOtherItem.objects.filter(preparation_order_id__in=order_ids) + total = std_items.count() + other_items.count() + used = std_items.filter(is_used=True).count() + other_items.filter(is_used=True).count() + return { + 'total': total, + 'used': used, + 'all_used': (total > 0 and used == total), + 'has_material': total > 0, + } + def get_associated_observations(self): """ Retourne le QuerySet de toutes les observations associées à cette intervention : diff --git a/loko/interventions/templates/interventions/detail/card_stock.html b/loko/interventions/templates/interventions/detail/card_stock.html index 834b01d..25a502d 100644 --- a/loko/interventions/templates/interventions/detail/card_stock.html +++ b/loko/interventions/templates/interventions/detail/card_stock.html @@ -1,86 +1,235 @@ {% load i18n %} {% if can_create_preparation_order or preparation_orders %} -
-
-

{% translate "Stock" %}

+
+
+

+ + {% translate "Matériel / Stock" %} +

+
+ {% if material_stats.has_material %} + + + {{ material_stats.used }} / {{ material_stats.total }} {% translate "utilisé(s)" %} + + + {% endif %} {% if can_create_preparation_order %} - + {% translate "Nouveau bon" %} {% endif %}
-
- {% if preparation_orders %} -
- - - - - - - - - - - - {% for order in preparation_orders %} - - - - - - - - {% endfor %} - -
{% translate "Numéro" %}{% translate "Date" %}{% translate "Statut" %}{% translate "Articles" %}{% translate "Actions" %}
{{ order.order_number }}{{ order.date|date:"d/m/Y H:i" }} - {% if order.status == 'to_process' %} - - {{ order.get_status_display }} - - {% elif order.status == 'pending' %} - - {{ order.get_status_display }} - - {% elif order.status == 'processed' %} - - {{ order.get_status_display }} - - {% endif %} - - {% with items=order.preparationorderitem_set.all %} - {% if items %} - - {% for item in items|slice:":3" %} - {{ item.product.name }} ({{ item.quantity }}){% if not forloop.last %}, {% endif %} - {% endfor %} - {% if items.count > 3 %} - ... +{{ items.count|add:"-3" }} - {% endif %} - - {% else %} - {% translate "Aucun article" %} - {% endif %} - {% endwith %} - - - - -
-
- {% else %} -

- - {% translate "Aucun bon de préparation créé pour cette intervention." %} -

- {% endif %} -
- {% endif %} - - - +
+ {% if preparation_orders %} + {% for order in preparation_orders %} +
+
+
+ + {{ order.order_number }} + + + {{ order.date|date:"d/m/Y H:i" }} + + {% if order.status == 'to_process' %} + + {{ order.get_status_display }} + + {% elif order.status == 'pending' %} + + {{ order.get_status_display }} + + {% elif order.status == 'processed' %} + + {{ order.get_status_display }} + + {% elif order.status == 'cancelled' %} + + {{ order.get_status_display }} + + {% endif %} + {% if order.is_loan %} + + {% translate "Prêt" %} + + {% endif %} +
+
+ {% with items=order.preparationorderitem_set.all other_items=order.other_items.all %} + {% if items or other_items %} + + {% endif %} + {% endwith %} + + + +
+
+
+ {% with items=order.preparationorderitem_set.all other_items=order.other_items.all %} + {% if items or other_items %} +
+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% endfor %} + {% for item in other_items %} + + + + + + + + + {% endfor %} + +
{% translate "Utilisé ?" %}{% translate "Article" %}{% translate "Référence" %}{% translate "Quantité" %}{% translate "Préparation" %}{% translate "Statut" %}
+
+ +
+
+ {{ item.product.name }} + {% if item.product.category %} + {{ item.product.category.name_fr }} + {% endif %} + + {% if item.product.code %} + {{ item.product.code }} + {% else %} + — + {% endif %} + + + × {{ item.quantity }} + + + {% if item.is_prepared %} + + {% translate "Préparé" %} + + {% else %} + + {% translate "En attente" %} + + {% endif %} + + + {% if item.is_used %} + {% translate "Utilisé" %} + {% else %} + {% translate "Non utilisé" %} + {% endif %} + +
+
+ +
+
+ {{ item.description }} + {% translate "Hors catalogue" %} + {% if item.category %} + {{ item.category.name_fr }} + {% endif %} + + — + + + × {{ item.quantity }} + + + {% if item.is_prepared %} + + {% translate "Préparé" %} + + {% else %} + + {% translate "En attente" %} + + {% endif %} + + + {% if item.is_used %} + {% translate "Utilisé" %} + {% else %} + {% translate "Non utilisé" %} + {% endif %} + +
+
+ {% else %} +
+ {% translate "Aucun article sur ce bon de préparation." %} +
+ {% endif %} + {% endwith %} +
+
+ {% endfor %} + {% else %} +

+ + {% translate "Aucun bon de préparation créé pour cette intervention." %} +

+ {% endif %} +
+
+{% endif %} diff --git a/loko/interventions/templates/interventions/detail/scripts.html b/loko/interventions/templates/interventions/detail/scripts.html index fa93bc9..5db1bc5 100644 --- a/loko/interventions/templates/interventions/detail/scripts.html +++ b/loko/interventions/templates/interventions/detail/scripts.html @@ -600,6 +600,132 @@ function toggleInterventionChecked(interventionId, checkboxEl) { alert('{% translate "Erreur réseau." %}'); }); } + +function updateMaterialUI(data) { + const usedCountEls = document.querySelectorAll('#desktop-material-used-count, #mobile-material-used-count'); + const totalCountEls = document.querySelectorAll('#desktop-material-total-count, #mobile-material-total-count'); + usedCountEls.forEach(el => el.textContent = data.used_count); + totalCountEls.forEach(el => el.textContent = data.total_count); + + const toggleAllBtnTexts = document.querySelectorAll('#btn-desktop-toggle-all-text, #btn-mobile-toggle-all-text'); + toggleAllBtnTexts.forEach(el => { + el.textContent = data.all_used ? "{% translate 'Tout décocher' %}" : "{% translate 'Tout cocher' %}"; + }); + + if (data.item_id !== undefined && data.is_other !== undefined) { + const prefix = data.is_other ? 'other-' : 'item-'; + const badges = document.querySelectorAll(`#material-badge-${prefix}${data.item_id}, #mobile-material-item-badge-${prefix}${data.item_id}`); + badges.forEach(b => { + if (data.is_used) { + b.className = 'badge bg-success text-white material-status-badge'; + b.innerHTML = '{% translate "Utilisé" %}'; + } else { + b.className = 'badge bg-secondary-subtle text-muted material-status-badge'; + b.innerHTML = '{% translate "Non utilisé" %}'; + } + }); + } +} + +function toggleMaterialItemUsed(interventionId, itemId, isOther, checkboxEl) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value + || document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || ''; + + checkboxEl.disabled = true; + const formData = new FormData(); + formData.append('is_other', isOther ? 'true' : 'false'); + formData.append('is_used', checkboxEl.checked ? 'true' : 'false'); + + fetch(`/interventions/${interventionId}/material/${itemId}/toggle-used/`, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: formData, + }) + .then(r => r.json()) + .then(data => { + checkboxEl.disabled = false; + if (data.success) { + checkboxEl.checked = data.is_used; + const otherFlag = isOther ? 'true' : 'false'; + document.querySelectorAll(`.material-used-checkbox[data-item-id="${itemId}"][data-is-other="${otherFlag}"]`).forEach(cb => { + cb.checked = data.is_used; + }); + updateMaterialUI(data); + } else { + checkboxEl.checked = !checkboxEl.checked; + alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}"); + } + }) + .catch(() => { + checkboxEl.disabled = false; + checkboxEl.checked = !checkboxEl.checked; + alert("{% translate 'Erreur réseau.' %}"); + }); +} + +function toggleAllMaterialUsed(interventionId, orderId) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value + || document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || ''; + + const buttons = document.querySelectorAll('#btn-desktop-toggle-all-material, #btn-mobile-toggle-all-material'); + buttons.forEach(b => b.disabled = true); + + const formData = new FormData(); + if (orderId) { + formData.append('order_id', orderId); + } + + fetch(`/interventions/${interventionId}/material/toggle-all-used/`, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: formData, + }) + .then(r => r.json()) + .then(data => { + buttons.forEach(b => b.disabled = false); + if (data.success) { + const isAllUsed = data.all_used; + const selector = orderId + ? `.material-used-checkbox[data-order-id="${orderId}"]` + : '.material-used-checkbox'; + + document.querySelectorAll(selector).forEach(cb => { + cb.checked = isAllUsed; + const itemId = cb.dataset.itemId; + const isOther = cb.dataset.isOther === 'true'; + const prefix = isOther ? 'other-' : 'item-'; + const badges = document.querySelectorAll(`#material-badge-${prefix}${itemId}, #mobile-material-item-badge-${prefix}${itemId}`); + badges.forEach(b => { + if (isAllUsed) { + b.className = 'badge bg-success text-white material-status-badge'; + b.innerHTML = '{% translate "Utilisé" %}'; + } else { + b.className = 'badge bg-secondary-subtle text-muted material-status-badge'; + b.innerHTML = '{% translate "Non utilisé" %}'; + } + }); + }); + + updateMaterialUI({ + used_count: data.used_count, + total_count: data.total_count, + all_used: data.intervention_all_used !== undefined ? data.intervention_all_used : data.all_used + }); + } else { + alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}"); + } + }) + .catch(() => { + buttons.forEach(b => b.disabled = false); + alert("{% translate 'Erreur réseau.' %}"); + }); +} + + {% endblock extra_scripts %} \ No newline at end of file diff --git a/loko/interventions/tests/test_material_used.py b/loko/interventions/tests/test_material_used.py new file mode 100644 index 0000000..927555e --- /dev/null +++ b/loko/interventions/tests/test_material_used.py @@ -0,0 +1,310 @@ +import json +from datetime import timedelta +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone + +from common.models import Role, UserConfig, Thematic, UserThematics, UserContractAccess, UserContractStatusPermission +from contracts.models import Company, CompanyTeam, CompanyMember, Contract +from interventions.models import Intervention +from stock.models import Product, PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem + + +class MaterialUsedTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.role_tech, _ = Role.objects.get_or_create(name='technician') + + self.company = Company.objects.create(name='Entreprise Stock') + self.team = CompanyTeam.objects.create(name='Équipe Stock', company=self.company) + self.thematic = Thematic.objects.create(code='roads', name_fr='Voirie', name_nl='Wegen') + + # Utilisateur avec droits d'édition + self.user_editor = self.User.objects.create_user(username='editor_user', password='password123') + self.config_editor = UserConfig.objects.create(user=self.user_editor, is_intern=False) + self.config_editor.roles.add(self.role_tech) + self.member_editor = CompanyMember.objects.create(user=self.user_editor, name='Editeur') + self.member_editor.teams.add(self.team) + + # Utilisateur sans droits + self.user_no_perm = self.User.objects.create_user(username='no_perm_user', password='password123') + self.config_no_perm = UserConfig.objects.create(user=self.user_no_perm, is_intern=False) + self.config_no_perm.roles.add(self.role_tech) + + # Contrat + self.contract = Contract.objects.create( + company=self.company, + contract_number='C-STOCK-TEST', + start_date=timezone.now().date() - timedelta(days=5), + end_date=timezone.now().date() + timedelta(days=30), + is_active=True, + allow_team_members_edit=True, + ) + self.contract.thematics.add(self.thematic) + + # Permissions d'accès pour l'éditeur + UserThematics.objects.create( + user_config=self.config_editor, + thematic=self.thematic, + can_view_interventions=True, + can_edit_interventions=True, + ) + uca = UserContractAccess.objects.create( + user_config=self.config_editor, + contract=self.contract, + can_view_interventions=True, + ) + for status in ['assigned', 'in_progress', 'finished']: + UserContractStatusPermission.objects.create( + user_contract=uca, + status=status, + can_view=True, + can_edit=True, + can_change_status_to=True, + ) + + # Intervention + self.intervention = Intervention.objects.create( + title='Intervention avec Matériel', + thematic=self.thematic, + contract=self.contract, + assigned_provider=self.company, + assigned_member=self.member_editor, + status='assigned', + ) + + # Produits et Bon de préparation + self.product1 = Product.objects.create(code='P1', name='Panneau A1', sku='SKU-P1') + self.product2 = Product.objects.create(code='P2', name='Poteau acier', sku='SKU-P2') + + self.order1 = PreparationOrder.objects.create( + order_number='BP-001', + intervention=self.intervention, + status='to_process', + ) + self.item1 = PreparationOrderItem.objects.create( + preparation_order=self.order1, + product=self.product1, + quantity=2, + is_used=False, + ) + self.item2 = PreparationOrderItem.objects.create( + preparation_order=self.order1, + product=self.product2, + quantity=1, + is_used=False, + ) + self.other_item1 = PreparationOrderOtherItem.objects.create( + preparation_order=self.order1, + description='Vis et écrous 10mm', + quantity=10, + is_used=False, + ) + + self.client_editor = Client() + self.client_editor.login(username='editor_user', password='password123') + + self.client_no_perm = Client() + self.client_no_perm.login(username='no_perm_user', password='password123') + + def test_model_methods_and_material_stats(self): + """Vérifie le calcul des statistiques de matériel sur Intervention et PreparationOrder.""" + # Initialement aucun article n'est marqué comme utilisé + stats = self.intervention.get_material_stats() + self.assertTrue(stats['has_material']) + self.assertEqual(stats['total'], 3) + self.assertEqual(stats['used'], 0) + self.assertFalse(stats['all_used']) + self.assertFalse(self.order1.all_items_used) + self.assertEqual(self.order1.used_items_count, 0) + self.assertEqual(self.order1.total_items_count, 3) + + # Marquer un article catalogue comme utilisé + self.item1.is_used = True + self.item1.save() + stats = self.intervention.get_material_stats() + self.assertEqual(stats['used'], 1) + self.assertFalse(stats['all_used']) + + # Marquer tous les articles comme utilisés + self.item2.is_used = True + self.item2.save() + self.other_item1.is_used = True + self.other_item1.save() + + stats = self.intervention.get_material_stats() + self.assertEqual(stats['used'], 3) + self.assertTrue(stats['all_used']) + self.assertTrue(self.order1.all_items_used) + + def test_other_item_name_property(self): + """Vérifie que la propriété name sur PreparationOrderOtherItem renvoie description.""" + self.assertEqual(self.other_item1.name, 'Vis et écrous 10mm') + + def test_toggle_material_used_endpoint_catalog_item(self): + """Vérifie le toggle d'un article catalogue via POST JSON.""" + url = reverse('interventions:toggle_material_used', kwargs={ + 'intervention_id': self.intervention.id, + 'item_id': self.item1.id, + }) + + # Basculer vers is_used=True + response = self.client_editor.post(url, {'is_other': 'false', 'is_used': 'true'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertTrue(data['is_used']) + self.assertEqual(data['used_count'], 1) + self.assertEqual(data['total_count'], 3) + self.assertFalse(data['all_used']) + + self.item1.refresh_from_db() + self.assertTrue(self.item1.is_used) + + # Basculer vers is_used=False + response = self.client_editor.post(url, {'is_other': 'false', 'is_used': 'false'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertFalse(data['is_used']) + self.assertEqual(data['used_count'], 0) + + self.item1.refresh_from_db() + self.assertFalse(self.item1.is_used) + + def test_toggle_material_used_endpoint_other_item(self): + """Vérifie le toggle d'un article hors catalogue (other_item).""" + url = reverse('interventions:toggle_material_used', kwargs={ + 'intervention_id': self.intervention.id, + 'item_id': self.other_item1.id, + }) + + response = self.client_editor.post(url, {'is_other': 'true', 'is_used': 'true'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertTrue(data['is_used']) + self.assertTrue(data['is_other']) + + self.other_item1.refresh_from_db() + self.assertTrue(self.other_item1.is_used) + + def test_toggle_material_used_permission_denied(self): + """Un utilisateur sans permissions d'édition ne peut pas cocher un article.""" + url = reverse('interventions:toggle_material_used', kwargs={ + 'intervention_id': self.intervention.id, + 'item_id': self.item1.id, + }) + + response = self.client_no_perm.post(url, {'is_other': 'false', 'is_used': 'true'}) + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertFalse(data['success']) + + self.item1.refresh_from_db() + self.assertFalse(self.item1.is_used) + + def test_toggle_all_material_used_intervention(self): + """Vérifie l'action 'Tout cocher' / 'Tout décocher' pour l'ensemble de l'intervention.""" + url = reverse('interventions:toggle_all_material_used', kwargs={ + 'intervention_id': self.intervention.id, + }) + + # 1. Tout cocher + response = self.client_editor.post(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertTrue(data['all_used']) + self.assertEqual(data['used_count'], 3) + self.assertEqual(data['total_count'], 3) + + self.item1.refresh_from_db() + self.item2.refresh_from_db() + self.other_item1.refresh_from_db() + self.assertTrue(self.item1.is_used) + self.assertTrue(self.item2.is_used) + self.assertTrue(self.other_item1.is_used) + + # 2. Tout décocher (puisque tout était déjà coché) + response = self.client_editor.post(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertFalse(data['all_used']) + self.assertEqual(data['used_count'], 0) + + self.item1.refresh_from_db() + self.item2.refresh_from_db() + self.other_item1.refresh_from_db() + self.assertFalse(self.item1.is_used) + self.assertFalse(self.item2.is_used) + self.assertFalse(self.other_item1.is_used) + + def test_toggle_all_material_used_single_order(self): + """Vérifie l'action 'Tout cocher' ciblée sur un bon de commande spécifique.""" + # Créer un 2ème bon de commande avec un article + order2 = PreparationOrder.objects.create( + order_number='BP-002', + intervention=self.intervention, + status='to_process', + ) + item_order2 = PreparationOrderItem.objects.create( + preparation_order=order2, + product=self.product1, + quantity=1, + is_used=False, + ) + + url = reverse('interventions:toggle_all_material_used', kwargs={ + 'intervention_id': self.intervention.id, + }) + + # Cocher uniquement order1 + response = self.client_editor.post(url, {'order_id': self.order1.id}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertTrue(data['all_used']) # les articles du bon 1 sont tous cochés + self.assertFalse(data['intervention_all_used']) # l'intervention entière n'est pas encore tout cochée + self.assertEqual(data['used_count'], 3) + self.assertEqual(data['total_count'], 4) + + self.item1.refresh_from_db() + self.item2.refresh_from_db() + self.other_item1.refresh_from_db() + item_order2.refresh_from_db() + + self.assertTrue(self.item1.is_used) + self.assertTrue(self.item2.is_used) + self.assertTrue(self.other_item1.is_used) + self.assertFalse(item_order2.is_used) # non affecté ! + + def test_toggle_all_material_used_permission_denied(self): + """Un utilisateur sans permission ne peut pas exécuter 'Tout cocher'.""" + url = reverse('interventions:toggle_all_material_used', kwargs={ + 'intervention_id': self.intervention.id, + }) + + response = self.client_no_perm.post(url) + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertFalse(data['success']) + + def test_detail_views_contain_material_stats(self): + """Vérifie que la page desktop et la page mobile contiennent material_stats dans le contexte.""" + 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.assertIn('material_stats', response.context) + self.assertEqual(response.context['material_stats']['total'], 3) + self.assertContains(response, 'id="section-stock"') + self.assertContains(response, 'material-used-checkbox') + + mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': self.intervention.id}) + response_mobile = self.client_editor.get(mobile_url) + self.assertEqual(response_mobile.status_code, 200) + self.assertIn('material_stats', response_mobile.context) + self.assertContains(response_mobile, 'id="section-stock"') + self.assertContains(response_mobile, 'material-used-checkbox') diff --git a/loko/interventions/urls.py b/loko/interventions/urls.py index 95178ed..50529a6 100644 --- a/loko/interventions/urls.py +++ b/loko/interventions/urls.py @@ -23,6 +23,8 @@ urlpatterns = [ path('/toggle-subscription/', views.toggle_intervention_subscription, name='toggle_intervention_subscription'), path('/toggle-checked/', views.toggle_intervention_checked, name='toggle_intervention_checked'), path('/toggle-follow-up-performed/', views.toggle_follow_up_performed, name='toggle_follow_up_performed'), + path('/material//toggle-used/', views.toggle_material_used, name='toggle_material_used'), + path('/material/toggle-all-used/', views.toggle_all_material_used, name='toggle_all_material_used'), path('/update-correction-reason/', views.update_correction_reason, name='update_correction_reason'), path('/update-correction-details/', views.update_correction_details, name='update_correction_details'), path('/add-note/', views.intervention_add_note, name='intervention_add_note'), diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index e569c03..628b61a 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -512,7 +512,13 @@ def intervention_detail(request, intervention_id): or (can_manage_links and user_config.can_manage_preparation_orders_any()) ) ) - preparation_orders = intervention.preparation_orders.all().order_by('-date') if hasattr(intervention, 'preparation_orders') else [] + preparation_orders = ( + intervention.preparation_orders.all() + .prefetch_related('preparationorderitem_set__product', 'other_items') + .order_by('-date') + if hasattr(intervention, 'preparation_orders') else [] + ) + material_stats = intervention.get_material_stats() # Config desktop pour le modal de liaison d'assets (même mécanique que mobile) desktop_assets_config = None @@ -725,6 +731,7 @@ def intervention_detail(request, intervention_id): # Bons de préparation 'can_create_preparation_order': can_create_preparation_order, 'preparation_orders': preparation_orders, + 'material_stats': material_stats, # Config modal liaison assets (desktop) 'desktop_assets_enabled': can_manage_links, 'desktop_assets_config': desktop_assets_config, @@ -3779,3 +3786,110 @@ def create_operations_from_selected_template(request, intervention_id): 'error': str(e) }, status=500) + +@login_required +@require_POST +def toggle_material_used(request, intervention_id, item_id): + """ + Bascule l'état is_used d'un article de matériel (catalogue ou autre item) lié à l'intervention. + """ + from interventions.permissions import can_edit_intervention + from stock.models import PreparationOrderItem, PreparationOrderOtherItem + + intervention = get_object_or_404(Intervention, pk=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) + + is_other = request.POST.get('is_other') == 'true' or request.GET.get('is_other') == 'true' + set_value = request.POST.get('is_used') + + order_ids = intervention.preparation_orders.values_list('id', flat=True) + + if is_other: + try: + item = PreparationOrderOtherItem.objects.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) + except PreparationOrderItem.DoesNotExist: + return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404) + + if set_value is not None: + item.is_used = (set_value in ('true', '1', True)) + else: + item.is_used = not item.is_used + + item.save(update_fields=['is_used']) + + stats = intervention.get_material_stats() + order = item.preparation_order + order_all_used = order.all_items_used + + return JsonResponse({ + 'success': True, + 'item_id': item.id, + 'is_other': is_other, + 'is_used': item.is_used, + 'order_id': order.id, + 'order_all_used': order_all_used, + 'used_count': stats['used'], + 'total_count': stats['total'], + 'all_used': stats['all_used'], + }) + + +@login_required +@require_POST +def toggle_all_material_used(request, intervention_id): + """ + Coche ou décoche l'ensemble du matériel de l'intervention (ou d'un bon de préparation spécifique). + Paramètres optionnels dans POST : + - order_id : si spécifié, applique uniquement au bon indiqué + - used : booléen optionnel ('true'/'false'). Si absent, bascule : + si tous sont utilisés -> passe à False, sinon -> passe à True. + """ + from interventions.permissions import can_edit_intervention + from stock.models import PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem + + intervention = get_object_or_404(Intervention, pk=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') + 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) + + order_ids = list(orders.values_list('id', flat=True)) + std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids) + other_items = PreparationOrderOtherItem.objects.filter(preparation_order_id__in=order_ids) + + 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: + target_used_count = std_items.filter(is_used=True).count() + other_items.filter(is_used=True).count() + target_state = not (target_used_count == total_target) + + std_items.update(is_used=target_state) + other_items.update(is_used=target_state) + + stats = intervention.get_material_stats() + + return JsonResponse({ + 'success': True, + 'all_used': target_state, + 'intervention_all_used': stats['all_used'], + 'used_count': stats['used'], + 'total_count': stats['total'], + 'order_id': int(order_id) if order_id else None, + }) + diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 34d4b32..96e5a19 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -1092,6 +1092,7 @@ def intervention_detail_mobile(request, intervention_id): .order_by('-date') if hasattr(intervention, 'preparation_orders') else [] ) + material_stats = intervention.get_material_stats() sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes) @@ -1099,6 +1100,7 @@ def intervention_detail_mobile(request, intervention_id): 'intervention': intervention, 'can_create_preparation_order': can_create_preparation_order, 'preparation_orders': preparation_orders, + 'material_stats': material_stats, 'sequenced_blocks': sequenced_data['blocks'], 'document_blocks': sequenced_data['blocks'], 'all_sequenced_documents': sequenced_data['all_documents'], diff --git a/loko/interventions/views/views_pdf_reporting.py b/loko/interventions/views/views_pdf_reporting.py index 0cde934..cc051d0 100644 --- a/loko/interventions/views/views_pdf_reporting.py +++ b/loko/interventions/views/views_pdf_reporting.py @@ -1811,12 +1811,12 @@ class InterventionReportPDF(FPDF): self.ln(3) # ========== STOCK ========== - self._add_section_title(_("Stock")) + self._add_section_title(_("Stock / Matériel")) preparation_orders = ( intervention.preparation_orders .all() .order_by("-date") - .prefetch_related("preparationorderitem_set__product") + .prefetch_related("preparationorderitem_set__product", "other_items") ) if preparation_orders.exists(): @@ -1838,14 +1838,25 @@ class InterventionReportPDF(FPDF): self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) order_items = order.preparationorderitem_set.all() - if order_items.exists(): + other_items = order.other_items.all() + if order_items.exists() or other_items.exists(): for item in order_items: product_name = item.product.name if item.product else _("Produit inconnu") quantity = item.quantity if item.quantity is not None else 0 unit = item.product.get_unit_display() if item.product else "" + used_str = _("Utilisé") if item.is_used else _("Non utilisé") item_line = f" • {product_name}: {quantity}" if unit: item_line += f" {unit}" + item_line += f" [{used_str}]" + self.set_x(12) + self.multi_cell(0, 7, item_line) + self.ln(1) + for item in other_items: + desc = item.description or _("Article hors catalogue") + quantity = item.quantity if item.quantity is not None else 0 + used_str = _("Utilisé") if item.is_used else _("Non utilisé") + item_line = f" • {desc}: {quantity} ({_('Hors catalogue')}) [{used_str}]" self.set_x(12) self.multi_cell(0, 7, item_line) self.ln(1) diff --git a/loko/stock/admin.py b/loko/stock/admin.py index 2b5db12..d4b9d0b 100644 --- a/loko/stock/admin.py +++ b/loko/stock/admin.py @@ -4,6 +4,7 @@ from django.utils.html import format_html from .models import ( Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement, PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem, + PreparationOrderOtherItem, ProductionOrder, SerializedItem, SerializedItemEvent, ) @@ -84,22 +85,37 @@ class PreparationOrderItemInline(admin.TabularInline): model = PreparationOrderItem extra = 1 + +class PreparationOrderOtherItemInline(admin.TabularInline): + model = PreparationOrderOtherItem + extra = 1 + + @admin.register(PreparationOrder) class PreparationOrderAdmin(admin.ModelAdmin): list_display = ('order_number', 'date', 'status', 'priority') search_fields = ('order_number',) list_filter = ('status', 'date', 'priority') - inlines = [PreparationOrderItemInline] + inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline] def get_readonly_fields(self, request, obj=None): if obj: # editing an existing object return self.readonly_fields + ('priority',) return self.readonly_fields + @admin.register(PreparationOrderItem) class PreparationOrderItemAdmin(admin.ModelAdmin): - list_display = ('preparation_order', 'product', 'quantity') + list_display = ('preparation_order', 'product', 'quantity', 'is_prepared', 'is_used') search_fields = ('preparation_order__order_number', 'product__name') + list_filter = ('is_prepared', 'is_used') + + +@admin.register(PreparationOrderOtherItem) +class PreparationOrderOtherItemAdmin(admin.ModelAdmin): + list_display = ('preparation_order', 'description', 'quantity', 'is_prepared', 'is_used') + search_fields = ('preparation_order__order_number', 'description') + list_filter = ('is_prepared', 'is_used') # class ProductDesignComponentInline(admin.TabularInline): diff --git a/loko/stock/migrations/0002_preparationorderitem_is_used_and_more.py b/loko/stock/migrations/0002_preparationorderitem_is_used_and_more.py new file mode 100644 index 0000000..c1c18d6 --- /dev/null +++ b/loko/stock/migrations/0002_preparationorderitem_is_used_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-09-11 08:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='preparationorderitem', + name='is_used', + field=models.BooleanField(default=False, help_text="Indique si le matériel a été utilisé lors de l'intervention.", verbose_name='Utilisé'), + ), + migrations.AddField( + model_name='preparationorderotheritem', + name='is_used', + field=models.BooleanField(default=False, help_text="Indique si le matériel a été utilisé lors de l'intervention.", verbose_name='Utilisé'), + ), + ] diff --git a/loko/stock/models.py b/loko/stock/models.py index e626bf0..b885f42 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -1481,6 +1481,49 @@ class PreparationOrder(models.Model): return list(gross_locs)[0] return None + def get_all_items(self): + """Retourne la liste combinée des articles catalogue et autres articles avec leurs informations d'utilisation.""" + items = [] + for it in self.preparationorderitem_set.select_related('product').all(): + items.append({ + 'id': it.id, + 'is_other': False, + 'name': it.product.name, + 'code': it.product.code, + 'quantity': it.quantity, + 'unit': it.product.get_unit_display() if hasattr(it.product, 'get_unit_display') else '', + 'is_prepared': it.is_prepared, + 'is_used': it.is_used, + 'item_obj': it, + }) + for oi in self.other_items.all(): + items.append({ + 'id': oi.id, + 'is_other': True, + 'name': oi.description, + 'code': '', + 'quantity': oi.quantity, + 'unit': '', + 'is_prepared': oi.is_prepared, + 'is_used': oi.is_used, + 'item_obj': oi, + }) + return items + + @property + def all_items_used(self): + items = self.get_all_items() + return bool(items) and all(it['is_used'] for it in items) + + @property + def used_items_count(self): + items = self.get_all_items() + return sum(1 for it in items if it['is_used']) + + @property + def total_items_count(self): + return self.preparationorderitem_set.count() + self.other_items.count() + class PreparationOrderItem(models.Model): @@ -1488,6 +1531,11 @@ class PreparationOrderItem(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.IntegerField() # Quantité demandée initialement (0 si ajouté pendant le traitement) is_prepared = models.BooleanField(default=False) + is_used = models.BooleanField( + default=False, + verbose_name=_("Utilisé"), + help_text=_("Indique si le matériel a été utilisé lors de l'intervention."), + ) quantity_modified = models.BooleanField( default=False, verbose_name=_("Quantité modifiée"), @@ -1651,6 +1699,15 @@ class PreparationOrderOtherItem(models.Model): verbose_name=_("Type de panneau"), ) is_prepared = models.BooleanField(default=False, verbose_name=_("Préparé")) + is_used = models.BooleanField( + default=False, + verbose_name=_("Utilisé"), + help_text=_("Indique si le matériel a été utilisé lors de l'intervention."), + ) + + @property + def name(self): + return self.description def __str__(self): return _("{quantity} x {description} dans le bon {order}").format( diff --git a/loko/stock/tests.py b/loko/stock/tests.py index 61f9971..ace42df 100644 --- a/loko/stock/tests.py +++ b/loko/stock/tests.py @@ -1716,9 +1716,10 @@ class PreparationOrderAutocompleteTests(TestCase): b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po) StockMovement.objects.create(batch=b_in, product=prod, quantity=2) + from django.utils.translation import gettext as _ response = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk])) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Restitué au stock") + self.assertContains(response, _("Restitué au stock")) self.assertNotContains(response, "à prélever") def test_create_return_button_and_prefilled_entry_form(self):