From 91149d2597362a4412ed2c3c8fdd426a711fc86e Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 12 Aug 2026 10:43:24 +0200 Subject: [PATCH] refactor: enhance preparation order detail UI to display return statuses and update autocomplete to support intervention search --- streetup/mobile/views.py | 12 +++- streetup/stock/models.py | 19 ++++++ ...stock_preparation_order_detail_mobile.html | 31 +++++++--- .../stock/preparation_order_detail.html | 53 ++++++++++++----- streetup/stock/tests.py | 58 +++++++++++++++++++ streetup/stock/views.py | 42 ++++++++++---- 6 files changed, 178 insertions(+), 37 deletions(-) diff --git a/streetup/mobile/views.py b/streetup/mobile/views.py index 7d4fc98..2051bdf 100644 --- a/streetup/mobile/views.py +++ b/streetup/mobile/views.py @@ -964,9 +964,10 @@ def stock_preparation_order_detail_mobile(request, pk): return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk) from stock.models import PreparationOrderItem, WarehouseLocation + executed_summary = preparation_order.get_executed_output_summary() executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location() - executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()} - has_executed_outputs = bool(executed_totals) + executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()} + has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values()) is_reopened = preparation_order.status == 'to_process' and has_executed_outputs # S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon @@ -985,7 +986,10 @@ def stock_preparation_order_detail_mobile(request, pk): available_stock = product.total_stock reserved_stock = product.reserved_quantity prod_order = item.production_orders.first() - executed_qty = executed_totals.get(product.id, 0) + prod_summary = executed_summary.get(product.id, {'out': 0, 'in': 0, 'net': 0}) + executed_qty = prod_summary['net'] + gross_out = prod_summary['out'] + gross_in = prod_summary['in'] delta = item.quantity - executed_qty items_with_stock.append({ @@ -995,6 +999,8 @@ def stock_preparation_order_detail_mobile(request, pk): 'is_sufficient': available_stock >= item.quantity, 'production_order': prod_order, 'executed_qty': executed_qty, + 'gross_out': gross_out, + 'gross_in': gross_in, 'delta': delta, 'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse'), }) diff --git a/streetup/stock/models.py b/streetup/stock/models.py index ba3f3be..57c3454 100644 --- a/streetup/stock/models.py +++ b/streetup/stock/models.py @@ -1435,6 +1435,25 @@ class PreparationOrder(models.Model): outputs_by_loc = self.get_executed_outputs_by_product_and_location() return {p_id: sum(loc_dict.values()) for p_id, loc_dict in outputs_by_loc.items()} + def get_executed_output_summary(self): + """ + Retourne un dictionnaire récapitulatif par produit des mouvements d'entrée et sortie liés. + Structure : { product_id: { 'out': gross_out, 'in': gross_in, 'net': net_qty } } + """ + summary = {} + for batch in self.stock_batches.all(): + for move in batch.movements.all(): + p_id = move.product_id + if p_id not in summary: + summary[p_id] = {'out': 0, 'in': 0, 'net': 0} + if batch.movement_type == 'out': + summary[p_id]['out'] += move.quantity + summary[p_id]['net'] += move.quantity + elif batch.movement_type == 'in': + summary[p_id]['in'] += move.quantity + summary[p_id]['net'] -= move.quantity + return summary + class PreparationOrderItem(models.Model): diff --git a/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html b/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html index 062f305..a115e1d 100644 --- a/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html +++ b/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html @@ -272,18 +272,31 @@ {% if has_executed_outputs %}
- {% if item_data.executed_qty > 0 %} - {% if item_data.delta > 0 %} - +{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à prélever" %} - ({% translate "Sorti" %}: {{ item_data.executed_qty }}) - {% elif item_data.delta < 0 %} - {{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à restituer" %} - ({% translate "Sorti" %}: {{ item_data.executed_qty }}) + {% if preparation_order.status == 'processed' %} + {% if item_data.gross_in > 0 and item_data.executed_qty == 0 %} + {% translate "Restitué au stock" %} ({{ item_data.gross_in }}) + {% elif item_data.gross_in > 0 %} + {% translate "Sorti net" %} ({{ item_data.executed_qty }}) + ({% translate "Sorti" %}: {{ item_data.gross_out }}, {% translate "Restitué" %}: {{ item_data.gross_in }}) + {% elif item_data.executed_qty > 0 %} + {% translate "Sorti" %} ({{ item_data.executed_qty }}) {% else %} - {% translate "Inchangé" %} ({{ item_data.executed_qty }}) + {% translate "Non sorti" %} {% endif %} {% else %} - {{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "à prélever" %} + {% if item_data.executed_qty > 0 or item_data.gross_out > 0 or item_data.gross_in > 0 %} + {% if item_data.delta > 0 %} + +{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à prélever" %} + ({% translate "Sorti net" %}: {{ item_data.executed_qty }}) + {% elif item_data.delta < 0 %} + {{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à restituer" %} + ({% translate "Sorti net" %}: {{ item_data.executed_qty }}) + {% else %} + {% translate "Inchangé" %} ({{ item_data.executed_qty }}) + {% endif %} + {% else %} + {{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "à prélever" %} + {% endif %} {% endif %}
{% endif %} diff --git a/streetup/stock/templates/stock/preparation_order_detail.html b/streetup/stock/templates/stock/preparation_order_detail.html index d05bc88..e717cdb 100644 --- a/streetup/stock/templates/stock/preparation_order_detail.html +++ b/streetup/stock/templates/stock/preparation_order_detail.html @@ -297,26 +297,51 @@ {% if has_executed_outputs %} - {% if item_data.executed_qty > 0 %} - {% if item_data.delta > 0 %} - - +{{ item_data.delta }} {% translate "à prélever" %} + {% if preparation_order.status == 'processed' %} + {% if item_data.gross_in > 0 and item_data.executed_qty == 0 %} + + {% translate "Restitué au stock" %} ({{ item_data.gross_in }}) -
{% translate "Sorti" %}: {{ item_data.executed_qty }} - {% elif item_data.delta < 0 %} - - {{ item_data.delta }} {% translate "à restituer" %} + {% elif item_data.gross_in > 0 %} + + {% translate "Sorti net" %} ({{ item_data.executed_qty }}) + +
{% translate "Sorti" %}: {{ item_data.gross_out }}, {% translate "Restitué" %}: {{ item_data.gross_in }} + {% elif item_data.executed_qty > 0 %} + + {% translate "Sorti" %} ({{ item_data.executed_qty }}) -
{% translate "Sorti" %}: {{ item_data.executed_qty }} {% else %} - - {% translate "Inchangé" %} ({{ item_data.executed_qty }}) + + {% translate "Non sorti" %} {% endif %} {% else %} - - {{ item_data.item.quantity }} {% translate "à prélever" %} - + {% if item_data.executed_qty > 0 or item_data.gross_out > 0 or item_data.gross_in > 0 %} + {% if item_data.delta > 0 %} + + +{{ item_data.delta }} {% translate "à prélever" %} + + {% if item_data.executed_qty > 0 %} +
{% translate "Déjà sorti" %}: {{ item_data.executed_qty }} + {% elif item_data.gross_in > 0 %} +
{% translate "Restitué" %}: {{ item_data.gross_in }} + {% endif %} + {% elif item_data.delta < 0 %} + + {{ item_data.delta }} {% translate "à restituer" %} + +
{% translate "Sorti net" %}: {{ item_data.executed_qty }} + {% else %} + + {% translate "Inchangé" %} ({{ item_data.executed_qty }}) + + {% endif %} + {% else %} + + {{ item_data.item.quantity }} {% translate "à prélever" %} + + {% endif %} {% endif %} {% endif %} diff --git a/streetup/stock/tests.py b/streetup/stock/tests.py index 0a1442f..d07ec96 100644 --- a/streetup/stock/tests.py +++ b/streetup/stock/tests.py @@ -1662,6 +1662,64 @@ class MaterialLoanTests(TestCase): self.assertEqual(self.stock.quantity, 50) # Restitution complète des 15 unités +class PreparationOrderAutocompleteTests(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + from interventions.models import Intervention + from stock.models import PreparationOrder + + User = get_user_model() + self.user = User.objects.create_user(username="testuser_auto", password="password123") + self.client.login(username="testuser_auto", password="password123") + + self.intervention = Intervention.objects.create( + code="INT-2026-9999", + title="Réparation Panneau Signalisation", + ) + self.po = PreparationOrder.objects.create( + order_number="BP-AUTO-001", + intervention=self.intervention, + status="to_process", + ) + + def test_autocomplete_search_by_intervention_code(self): + url = reverse("stock:preparation_orders_autocomplete_api") + response = self.client.get(url, {"q": "INT-2026-9999"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], self.po.pk) + self.assertIn("INT-2026-9999", data["results"][0]["text"]) + + def test_autocomplete_search_by_bp_number(self): + url = reverse("stock:preparation_orders_autocomplete_api") + response = self.client.get(url, {"q": "BP-AUTO-001"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], self.po.pk) + self.assertIn("INT-2026-9999", data["results"][0]["text"]) + + def test_full_return_status_display_on_processed_order(self): + from stock.models import Product, PreparationOrder, PreparationOrderItem, StockMovementBatch, StockMovement + prod = Product.objects.create(name="Panneau Restitué", code="PROD-RET-1", total_stock=10) + po = PreparationOrder.objects.create(order_number="BP-RET-100", status="processed") + PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=2) + + # Mouvement de sortie (out) + b_out = StockMovementBatch.objects.create(movement_type="out", preparation_order=po) + StockMovement.objects.create(batch=b_out, product=prod, quantity=2) + + # Mouvement de retour complet (in) + b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po) + StockMovement.objects.create(batch=b_in, product=prod, quantity=2) + + 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.assertNotContains(response, "à prélever") + + diff --git a/streetup/stock/views.py b/streetup/stock/views.py index 033baf5..c650475 100644 --- a/streetup/stock/views.py +++ b/streetup/stock/views.py @@ -384,18 +384,32 @@ def products_autocomplete_api(request): @login_required def preparation_orders_autocomplete_api(request): """ - API endpoint pour l'autocomplete des bons de préparation par numéro. + API endpoint pour l'autocomplete des bons de préparation par numéro ou par intervention (code/numéro/titre). Utilisé lors de la création d'une entrée de stock (retour). """ q = request.GET.get('q', '').strip() - qs = PreparationOrder.objects.all() + qs = PreparationOrder.objects.select_related('intervention').all() if q: - qs = qs.filter(order_number__icontains=q) + q_filter = ( + models.Q(order_number__icontains=q) | + models.Q(intervention__code__icontains=q) | + models.Q(intervention__title__icontains=q) + ) + if q.isdigit(): + q_filter |= models.Q(intervention__id=int(q)) + qs = qs.filter(q_filter) qs = qs.order_by('-date')[:20] - results = [ - {'id': p.pk, 'text': p.order_number, 'status': p.get_status_display()} - for p in qs - ] + results = [] + for p in qs: + interv_info = "" + if p.intervention: + interv_code = p.intervention.code or f"#{p.intervention.id}" + interv_info = f" (Int. {interv_code})" + results.append({ + 'id': p.pk, + 'text': f"{p.order_number}{interv_info}", + 'status': p.get_status_display() + }) return JsonResponse({'results': results}) @@ -3786,9 +3800,10 @@ def preparation_order_detail(request, pk): return redirect('stock:preparation_order_detail', pk=pk) # Calculer les informations sur les produits + executed_summary = preparation_order.get_executed_output_summary() executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location() - executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()} - has_executed_outputs = bool(executed_totals) + executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()} + has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values()) is_reopened = preparation_order.status == 'to_process' and has_executed_outputs # S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon @@ -3808,9 +3823,12 @@ def preparation_order_detail(request, pk): reserved_stock = product.reserved_quantity available_stock = raw_available prod_order = item.production_orders.first() - executed_qty = executed_totals.get(product.id, 0) + prod_summary = executed_summary.get(product.id, {'out': 0, 'in': 0, 'net': 0}) + executed_qty = prod_summary['net'] + gross_out = prod_summary['out'] + gross_in = prod_summary['in'] delta = item.quantity - executed_qty - + items_with_stock.append({ 'item': item, 'available_stock': available_stock, @@ -3820,6 +3838,8 @@ def preparation_order_detail(request, pk): 'is_below_threshold': available_stock < product.threshold, 'production_order': prod_order, 'executed_qty': executed_qty, + 'gross_out': gross_out, + 'gross_in': gross_in, 'delta': delta, 'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse') })