refactor: enhance preparation order detail UI to display return statuses and update autocomplete to support intervention search
This commit is contained in:
parent
ea2bb4817c
commit
91149d2597
6 changed files with 178 additions and 37 deletions
|
|
@ -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'),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue