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)
|
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
|
||||||
|
|
||||||
from stock.models import PreparationOrderItem, WarehouseLocation
|
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_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()}
|
executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()}
|
||||||
has_executed_outputs = bool(executed_totals)
|
has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values())
|
||||||
is_reopened = preparation_order.status == 'to_process' and has_executed_outputs
|
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
|
# 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
|
available_stock = product.total_stock
|
||||||
reserved_stock = product.reserved_quantity
|
reserved_stock = product.reserved_quantity
|
||||||
prod_order = item.production_orders.first()
|
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
|
delta = item.quantity - executed_qty
|
||||||
|
|
||||||
items_with_stock.append({
|
items_with_stock.append({
|
||||||
|
|
@ -995,6 +999,8 @@ def stock_preparation_order_detail_mobile(request, pk):
|
||||||
'is_sufficient': available_stock >= item.quantity,
|
'is_sufficient': available_stock >= item.quantity,
|
||||||
'production_order': prod_order,
|
'production_order': prod_order,
|
||||||
'executed_qty': executed_qty,
|
'executed_qty': executed_qty,
|
||||||
|
'gross_out': gross_out,
|
||||||
|
'gross_in': gross_in,
|
||||||
'delta': delta,
|
'delta': delta,
|
||||||
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse'),
|
'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()
|
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()}
|
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):
|
class PreparationOrderItem(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -272,18 +272,31 @@
|
||||||
<!-- Delta / Restitution info -->
|
<!-- Delta / Restitution info -->
|
||||||
{% if has_executed_outputs %}
|
{% if has_executed_outputs %}
|
||||||
<div class="mt-2" style="font-size:0.82rem;">
|
<div class="mt-2" style="font-size:0.82rem;">
|
||||||
{% if item_data.executed_qty > 0 %}
|
{% if preparation_order.status == 'processed' %}
|
||||||
{% if item_data.delta > 0 %}
|
{% if item_data.gross_in > 0 and item_data.executed_qty == 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>
|
<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>
|
||||||
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.executed_qty }})</small>
|
{% elif item_data.gross_in > 0 %}
|
||||||
{% elif item_data.delta < 0 %}
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% translate "Sorti net" %} ({{ item_data.executed_qty }})</span>
|
||||||
<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.gross_out }}, {% translate "Restitué" %}: {{ item_data.gross_in }})</small>
|
||||||
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.executed_qty }})</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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -297,26 +297,51 @@
|
||||||
</td>
|
</td>
|
||||||
{% if has_executed_outputs %}
|
{% if has_executed_outputs %}
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if item_data.executed_qty > 0 %}
|
{% if preparation_order.status == 'processed' %}
|
||||||
{% if item_data.delta > 0 %}
|
{% if item_data.gross_in > 0 and item_data.executed_qty == 0 %}
|
||||||
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
|
<span class="badge bg-info text-dark fs-6" title="{% translate 'Tous les articles ont été restitués au stock' %}">
|
||||||
<i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {% translate "à prélever" %}
|
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué au stock" %} ({{ item_data.gross_in }})
|
||||||
</span>
|
</span>
|
||||||
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
|
{% elif item_data.gross_in > 0 %}
|
||||||
{% elif item_data.delta < 0 %}
|
<span class="badge bg-success fs-6">
|
||||||
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
|
<i class="bi bi-check-circle me-1"></i>{% translate "Sorti net" %} ({{ item_data.executed_qty }})
|
||||||
<i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {% translate "à restituer" %}
|
</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>
|
</span>
|
||||||
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-secondary fs-6">
|
||||||
<i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})
|
{% translate "Non sorti" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-primary fs-6">
|
{% if item_data.executed_qty > 0 or item_data.gross_out > 0 or item_data.gross_in > 0 %}
|
||||||
<i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {% translate "à prélever" %}
|
{% if item_data.delta > 0 %}
|
||||||
</span>
|
<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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1662,6 +1662,64 @@ class MaterialLoanTests(TestCase):
|
||||||
self.assertEqual(self.stock.quantity, 50) # Restitution complète des 15 unités
|
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
|
@login_required
|
||||||
def preparation_orders_autocomplete_api(request):
|
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).
|
Utilisé lors de la création d'une entrée de stock (retour).
|
||||||
"""
|
"""
|
||||||
q = request.GET.get('q', '').strip()
|
q = request.GET.get('q', '').strip()
|
||||||
qs = PreparationOrder.objects.all()
|
qs = PreparationOrder.objects.select_related('intervention').all()
|
||||||
if q:
|
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]
|
qs = qs.order_by('-date')[:20]
|
||||||
results = [
|
results = []
|
||||||
{'id': p.pk, 'text': p.order_number, 'status': p.get_status_display()}
|
for p in qs:
|
||||||
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})
|
return JsonResponse({'results': results})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3786,9 +3800,10 @@ def preparation_order_detail(request, pk):
|
||||||
return redirect('stock:preparation_order_detail', pk=pk)
|
return redirect('stock:preparation_order_detail', pk=pk)
|
||||||
|
|
||||||
# Calculer les informations sur les produits
|
# 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_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()}
|
executed_totals = {p_id: info['net'] for p_id, info in executed_summary.items()}
|
||||||
has_executed_outputs = bool(executed_totals)
|
has_executed_outputs = any(info['out'] > 0 for info in executed_summary.values())
|
||||||
is_reopened = preparation_order.status == 'to_process' and has_executed_outputs
|
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
|
# 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
|
reserved_stock = product.reserved_quantity
|
||||||
available_stock = raw_available
|
available_stock = raw_available
|
||||||
prod_order = item.production_orders.first()
|
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
|
delta = item.quantity - executed_qty
|
||||||
|
|
||||||
items_with_stock.append({
|
items_with_stock.append({
|
||||||
|
|
@ -3820,6 +3838,8 @@ def preparation_order_detail(request, pk):
|
||||||
'is_below_threshold': available_stock < product.threshold,
|
'is_below_threshold': available_stock < product.threshold,
|
||||||
'production_order': prod_order,
|
'production_order': prod_order,
|
||||||
'executed_qty': executed_qty,
|
'executed_qty': executed_qty,
|
||||||
|
'gross_out': gross_out,
|
||||||
|
'gross_in': gross_in,
|
||||||
'delta': delta,
|
'delta': delta,
|
||||||
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse')
|
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue