refactor: enhance preparation order detail UI to display return statuses and update autocomplete to support intervention search

This commit is contained in:
kdeterme 2026-08-12 10:43:24 +02:00
parent ea2bb4817c
commit 91149d2597
6 changed files with 178 additions and 37 deletions

View file

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

View file

@ -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):

View file

@ -272,18 +272,31 @@
<!-- Delta / Restitution info -->
{% if has_executed_outputs %}
<div class="mt-2" style="font-size:0.82rem;">
{% if item_data.executed_qty > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark"><i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.executed_qty }})</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark"><i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à restituer" %}</span>
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.executed_qty }})</small>
{% if preparation_order.status == 'processed' %}
{% if item_data.gross_in > 0 and item_data.executed_qty == 0 %}
<span class="badge bg-info text-dark"><i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué au stock" %} ({{ item_data.gross_in }})</span>
{% elif item_data.gross_in > 0 %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% translate "Sorti net" %} ({{ item_data.executed_qty }})</span>
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.gross_out }}, {% translate "Restitué" %}: {{ item_data.gross_in }})</small>
{% elif item_data.executed_qty > 0 %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% translate "Sorti" %} ({{ item_data.executed_qty }})</span>
{% else %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})</span>
<span class="badge bg-secondary">{% translate "Non sorti" %}</span>
{% endif %}
{% else %}
<span class="badge bg-primary"><i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
{% if item_data.executed_qty > 0 or item_data.gross_out > 0 or item_data.gross_in > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark"><i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
<small class="text-muted ms-1">({% translate "Sorti net" %}: {{ item_data.executed_qty }})</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark"><i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à restituer" %}</span>
<small class="text-muted ms-1">({% translate "Sorti net" %}: {{ item_data.executed_qty }})</small>
{% else %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})</span>
{% endif %}
{% else %}
<span class="badge bg-primary"><i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
{% endif %}
{% endif %}
</div>
{% endif %}

View file

@ -297,26 +297,51 @@
</td>
{% if has_executed_outputs %}
<td class="text-center">
{% if item_data.executed_qty > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
<i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {% translate "à prélever" %}
{% if preparation_order.status == 'processed' %}
{% if item_data.gross_in > 0 and item_data.executed_qty == 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Tous les articles ont été restitués au stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué au stock" %} ({{ item_data.gross_in }})
</span>
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {% translate "à restituer" %}
{% elif item_data.gross_in > 0 %}
<span class="badge bg-success fs-6">
<i class="bi bi-check-circle me-1"></i>{% translate "Sorti net" %} ({{ item_data.executed_qty }})
</span>
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.gross_out }}, {% translate "Restitué" %}: {{ item_data.gross_in }}</small>
{% elif item_data.executed_qty > 0 %}
<span class="badge bg-success fs-6">
<i class="bi bi-check-circle me-1"></i>{% translate "Sorti" %} ({{ item_data.executed_qty }})
</span>
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
{% else %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})
<span class="badge bg-secondary fs-6">
{% translate "Non sorti" %}
</span>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {% translate "à prélever" %}
</span>
{% if item_data.executed_qty > 0 or item_data.gross_out > 0 or item_data.gross_in > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
<i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {% translate "à prélever" %}
</span>
{% if item_data.executed_qty > 0 %}
<br><small class="text-muted">{% translate "Déjà sorti" %}: {{ item_data.executed_qty }}</small>
{% elif item_data.gross_in > 0 %}
<br><small class="text-muted">{% translate "Restitué" %}: {{ item_data.gross_in }}</small>
{% endif %}
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {% translate "à restituer" %}
</span>
<br><small class="text-muted">{% translate "Sorti net" %}: {{ item_data.executed_qty }}</small>
{% else %}
<span class="badge bg-success fs-6">
<i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})
</span>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {% translate "à prélever" %}
</span>
{% endif %}
{% endif %}
</td>
{% endif %}

View file

@ -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")

View file

@ -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,7 +3823,10 @@ 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({
@ -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')
})