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')
})