feat: add stock return workflow from preparation orders with automated field prefilling and enhanced search capabilities

This commit is contained in:
kdeterme 2026-08-12 11:05:22 +02:00
parent 91149d2597
commit 37500e1fbc
10 changed files with 217 additions and 13 deletions

View file

@ -414,15 +414,27 @@ def stock_serialized_item_detail_mobile(request, pk):
@stock_access_required
def stock_preparation_orders_mobile(request):
from stock.models import PreparationOrder
from django.db import models
selected_status = request.GET.get('status', '').strip()
selected_priority = request.GET.get('priority', '').strip()
search_q = request.GET.get('q', '').strip()
preparation_orders = PreparationOrder.objects.all()
preparation_orders = PreparationOrder.objects.select_related('intervention').all()
if selected_status:
preparation_orders = preparation_orders.filter(status=selected_status)
if selected_priority:
preparation_orders = preparation_orders.filter(priority=selected_priority)
if search_q:
q_filter = (
models.Q(order_number__icontains=search_q) |
models.Q(intervention__code__icontains=search_q) |
models.Q(intervention__title__icontains=search_q) |
models.Q(requester__icontains=search_q)
)
if search_q.isdigit():
q_filter |= models.Q(intervention__id=int(search_q))
preparation_orders = preparation_orders.filter(q_filter)
preparation_orders = preparation_orders.prefetch_related(
'preparationorderitem_set__product'
@ -431,6 +443,7 @@ def stock_preparation_orders_mobile(request):
'preparation_orders': preparation_orders,
'selected_status': selected_status,
'selected_priority': selected_priority,
'search_q': search_q,
'preparation_order_status_choices': [
('to_process', _("A traiter")),
('pending', _("En attente")),
@ -1028,10 +1041,17 @@ def stock_preparation_order_detail_mobile(request, pk):
can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any()
can_cancel = user_config.can_manage_preparation_orders_any()
can_manage_purchase = user_config.can_manage_purchase_orders_any()
can_manage_stock = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.has_role('stock_manager')
or user_config.get_accessible_warehouses(can_manage=True).exists()
)
except UserConfig.DoesNotExist:
can_manage = False
can_cancel = False
can_manage_purchase = False
can_manage_stock = False
other_items_with_info = []
for oi in preparation_order.other_items.all():
@ -1050,6 +1070,7 @@ def stock_preparation_order_detail_mobile(request, pk):
'can_manage': can_manage,
'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase,
'can_manage_stock': can_manage_stock,
'exit_batches': exit_batches,
'return_batches': return_batches,
'has_executed_outputs': has_executed_outputs,

View file

@ -1454,6 +1454,29 @@ class PreparationOrder(models.Model):
summary[p_id]['net'] -= move.quantity
return summary
def get_default_return_location_id(self):
"""
Si tous les produits sortis pour ce bon proviennent du même emplacement unique,
retourne l'ID de cet emplacement (pour préremplir l'emplacement de destination lors d'un retour).
"""
executed_by_loc = self.get_executed_outputs_by_product_and_location()
loc_set = set()
for p_id, loc_dict in executed_by_loc.items():
for loc_id, qty in loc_dict.items():
if qty > 0:
loc_set.add(loc_id)
if len(loc_set) == 1:
return list(loc_set)[0]
gross_locs = set()
for batch in self.stock_batches.filter(movement_type='out'):
for move in batch.movements.all():
if move.source_location_id:
gross_locs.add(move.source_location_id)
if len(gross_locs) == 1:
return list(gross_locs)[0]
return None
class PreparationOrderItem(models.Model):

View file

@ -167,7 +167,7 @@
<option value="">-- {% translate "Choisir un sous-type" %} --</option>
{% if movement_type == 'in' %}
<option value="purchase">{% translate "Achat" %}</option>
<option value="return">{% translate "Retour" %}</option>
<option value="return" {% if preselected_po %}selected{% endif %}>{% translate "Retour" %}</option>
<option value="confection">{% translate "Confection" %}</option>
<option value="correction" style="display: none;">{% translate "Correction (Administrateur uniquement)" %}</option>
{% elif movement_type == 'out' %}
@ -721,6 +721,15 @@ const bpResults = document.getElementById('bp-results');
const bpLoadBtn = document.getElementById('bp-load-btn');
const bpInfo = document.getElementById('bp-info');
{% if preselected_po %}
if (bpSearchInput && bpHiddenId && bpLoadBtn) {
bpHiddenId.value = '{{ preselected_po.pk }}';
bpSearchInput.value = '{{ preselected_po.order_number }}{% if preselected_po.intervention %} (Int. {{ preselected_po.intervention.code|default:preselected_po.intervention.id }}){% endif %}';
bpLoadBtn.disabled = false;
setTimeout(() => { bpLoadBtn.click(); }, 150);
}
{% endif %}
let bpTimeout = null;
if (bpSearchInput) {
@ -779,6 +788,14 @@ if (bpLoadBtn) {
bpInfo.innerHTML = `{% translate "Chargement de" %} <strong>${data.items.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>`;
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
bpInfo.style.display = '';
// Pré-remplir l'emplacement de destination si unique
if (data.default_location_id) {
const globalDestSelect = document.getElementById('global_dest_location_id');
if (globalDestSelect) {
globalDestSelect.value = data.default_location_id;
globalDestSelect.dispatchEvent(new Event('change'));
}
}
// Effacer les lignes existantes
document.getElementById('lines-container').innerHTML = '';
updateNoLinesPlaceholder();

View file

@ -456,6 +456,19 @@
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler le bon" %}
</button>
{% endif %}
{% elif preparation_order.status == 'processed' %}
{% if can_manage_stock %}
<a href="{% url 'mobile:new_stock_entry_mobile' %}?preparation_order={{ preparation_order.pk }}" class="btn btn-info text-white">
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Créer un retour de stock" %}
</a>
{% endif %}
<form method="post" action="{% url 'stock:preparation_order_reopen' preparation_order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir réouvrir ce bon de préparation ?" %}');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réouvrir le bon" %}
</button>
</form>
{% endif %}
</div>
{% endif %}

View file

@ -40,6 +40,13 @@
<div class="card mb-3 shadow-sm bg-light border-0">
<div class="card-body p-2">
<form method="get" class="row g-2">
<div class="col-12">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" name="q" value="{{ search_q }}" placeholder="{% translate 'N° bon, code/titre d\'intervention...' %}">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
</div>
</div>
<div class="col-6">
<select class="form-select form-select-sm" name="status" onchange="this.form.submit()">
<option value="">-- {% translate "Tous statuts" %} --</option>
@ -56,7 +63,7 @@
{% endfor %}
</select>
</div>
{% if selected_status or selected_priority %}
{% if selected_status or selected_priority or search_q %}
<div class="col-12 text-center mt-1">
<a href="?" class="btn btn-sm btn-outline-secondary py-0" style="font-size:0.75rem;">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer les filtres" %}
@ -87,7 +94,7 @@
</div>
{% if order.intervention %}
<div class="list-card-meta mt-1">
<i class="bi bi-wrench me-1"></i>{% translate "Intervention" %} #{{ order.intervention.id }}
<i class="bi bi-wrench me-1 text-primary"></i><strong>{% if order.intervention.code %}{{ order.intervention.code }}{% else %}#{{ order.intervention.id }}{% endif %}</strong>{% if order.intervention.title %} — {{ order.intervention.title }}{% endif %}
</div>
{% endif %}
</a>

View file

@ -183,7 +183,7 @@
<option value="">-- {% translate "Choisir un sous-type" %} --</option>
{% if movement_type == 'in' %}
<option value="purchase">{% translate "Achat" %}</option>
<option value="return">{% translate "Retour" %}</option>
<option value="return" {% if preselected_po %}selected{% endif %}>{% translate "Retour" %}</option>
<option value="confection">{% translate "Confection" %}</option>
<option value="correction" style="display: none;">{% translate "Correction (Administrateur uniquement)" %}</option>
{% elif movement_type == 'out' %}
@ -915,6 +915,15 @@ const bpResults = document.getElementById('bp-results');
const bpLoadBtn = document.getElementById('bp-load-btn');
const bpInfo = document.getElementById('bp-info');
{% if preselected_po %}
if (bpSearchInput && bpHiddenId && bpLoadBtn) {
bpHiddenId.value = '{{ preselected_po.pk }}';
bpSearchInput.value = '{{ preselected_po.order_number }}{% if preselected_po.intervention %} (Int. {{ preselected_po.intervention.code|default:preselected_po.intervention.id }}){% endif %}';
bpLoadBtn.disabled = false;
setTimeout(() => { bpLoadBtn.click(); }, 150);
}
{% endif %}
let bpTimeout = null;
if (bpSearchInput) {
@ -973,6 +982,14 @@ if (bpLoadBtn) {
bpInfo.innerHTML = `{% translate "Chargement de" %} <strong>${data.items.length}</strong> {% translate "produit(s) depuis le bon" %} <strong>${escHtml(data.order_number)}</strong>`;
bpInfo.className = 'alert alert-info mt-2 mb-0 py-2 small';
bpInfo.style.display = '';
// Pré-remplir l'emplacement de destination si unique
if (data.default_location_id) {
const globalDestSelect = document.getElementById('global_dest_location_id');
if (globalDestSelect) {
globalDestSelect.value = data.default_location_id;
globalDestSelect.dispatchEvent(new Event('change'));
}
}
// Effacer les lignes existantes
document.getElementById('lines-container').innerHTML = '';
updateNoLinesPlaceholder();

View file

@ -647,6 +647,21 @@
</div>
{% endif %}
{% elif preparation_order.status == 'processed' %}
{% if can_manage_stock %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-info">
<div class="card-body text-center">
<i class="bi bi-box-arrow-in-down text-info" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Créer un retour de stock" %}</h6>
<p class="text-muted small">{% translate "Enregistrer la réintégration de matériel en stock préremplie depuis ce bon" %}</p>
<a href="{% url 'stock:new_stock_entry' %}?preparation_order={{ preparation_order.pk }}" class="btn btn-info text-white">
<i class="bi bi-arrow-return-left me-1"></i> {% translate "Créer un retour" %}
</a>
</div>
</div>
</div>
{% endif %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-warning">
<div class="card-body text-center">

View file

@ -50,6 +50,14 @@
<input type="hidden" name="tab" value="preparation-orders">
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" name="q" value="{{ search_q }}" placeholder="{% translate 'N° bon, code ou titre d\'intervention...' %}">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
</div>
</div>
<div class="col-md-3">
<div class="input-group input-group-sm">
<label class="input-group-text" for="filter-status"><i class="bi bi-funnel me-1"></i>{% translate "Statut" %}</label>
<select class="form-select" id="filter-status" name="status" onchange="this.form.submit()">
@ -61,7 +69,7 @@
</div>
</div>
<div class="col-md-4">
<div class="col-md-3">
<div class="input-group input-group-sm">
<label class="input-group-text" for="filter-priority"><i class="bi bi-exclamation-triangle me-1"></i>{% translate "Priorité" %}</label>
<select class="form-select" id="filter-priority" name="priority" onchange="this.form.submit()">
@ -73,16 +81,16 @@
</div>
</div>
<div class="col-md-4 d-flex justify-content-between align-items-center gap-2">
<div class="col-md-2 d-flex justify-content-between align-items-center gap-1">
<div>
{% if selected_status or selected_priority %}
<a href="?tab=preparation-orders" class="btn btn-sm btn-outline-secondary">
{% if selected_status or selected_priority or search_q %}
<a href="?tab=preparation-orders" class="btn btn-sm btn-outline-secondary" title="{% translate 'Effacer les filtres' %}">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer" %}
</a>
{% endif %}
</div>
{% if can_create_preparation_order %}
<a href="{% url 'stock:new_preparation_order' %}" class="btn btn-sm btn-primary">
<a href="{% url 'stock:new_preparation_order' %}" class="btn btn-sm btn-primary text-nowrap">
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouveau bon" %}
</a>
{% endif %}
@ -110,6 +118,12 @@
{{ order.order_number }}
</a>
{% if order.intervention %}
<a href="{% url 'interventions:intervention_detail' order.intervention.id %}" class="badge bg-light text-dark border text-decoration-none me-1" title="{% translate 'Intervention liée' %}">
<i class="bi bi-wrench me-1 text-primary"></i>{% if order.intervention.code %}{{ order.intervention.code }}{% else %}#{{ order.intervention.id }}{% endif %}{% if order.intervention.title %} — {{ order.intervention.title }}{% endif %}
</a>
{% endif %}
{% if order.status == 'to_process' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ order.get_status_display }}</span>
{% elif order.status == 'pending' %}

View file

@ -1719,6 +1719,52 @@ class PreparationOrderAutocompleteTests(TestCase):
self.assertContains(response, "Restitué au stock")
self.assertNotContains(response, "à prélever")
def test_create_return_button_and_prefilled_entry_form(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem
from common.models import UserConfig, Role
self.user.is_superuser = True
self.user.save()
user_cfg, _ = UserConfig.objects.get_or_create(user=self.user)
admin_role, _ = Role.objects.get_or_create(name='admin')
user_cfg.roles.add(admin_role)
prod = Product.objects.create(name="Produit Retour Test", code="PROD-RET-2", total_stock=5)
po = PreparationOrder.objects.create(order_number="BP-RET-200", status="processed")
PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=3)
# 1. Vérifier la présence du bouton 'Créer un retour' sur la page détail du BP
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response_detail.status_code, 200)
self.assertContains(response_detail, f"preparation_order={po.pk}")
self.assertContains(response_detail, "Créer un retour")
# 2. Accéder à la page de création d'entrée de stock avec la QueryParam preparation_order
url_entry = reverse("stock:new_stock_entry") + f"?preparation_order={po.pk}"
response_entry = self.client.get(url_entry)
self.assertEqual(response_entry.status_code, 200)
self.assertContains(response_entry, "BP-RET-200")
def test_unique_location_return_prefilled_in_api(self):
from stock.models import Product, PreparationOrder, PreparationOrderItem, StockMovementBatch, StockMovement, Warehouse, WarehouseLocation
wh = Warehouse.objects.create(name="Entrepôt Unique", code="WH-U")
loc = WarehouseLocation.objects.create(warehouse=wh, name="Rayon A", code="LOC-RA")
prod = Product.objects.create(name="Produit Emplacement Unique", code="PROD-LOC-U", total_stock=10)
po = PreparationOrder.objects.create(order_number="BP-LOC-100", status="processed")
PreparationOrderItem.objects.create(preparation_order=po, product=prod, quantity=5)
# Mouvement de sortie depuis loc
b_out = StockMovementBatch.objects.create(movement_type="out", preparation_order=po)
StockMovement.objects.create(batch=b_out, product=prod, quantity=5, source_location=loc)
# Vérifier que get_default_return_location_id renvoie loc.id
self.assertEqual(po.get_default_return_location_id(), loc.id)
# Vérifier que l'API preparation_order_items_api renvoie default_location_id
url = reverse("stock:preparation_order_items_api", args=[po.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["default_location_id"], loc.id)

View file

@ -429,7 +429,12 @@ def preparation_order_items_api(request, pk):
'quantity': item.quantity,
'unit': item.product.unit or '',
})
return JsonResponse({'items': items, 'order_number': po.order_number})
default_loc_id = po.get_default_return_location_id()
return JsonResponse({
'items': items,
'order_number': po.order_number,
'default_location_id': default_loc_id,
})
@login_required
@ -555,12 +560,23 @@ def sign_panel_types_api(request):
def stock_movements(request):
selected_status = request.GET.get('status', '').strip()
selected_priority = request.GET.get('priority', '').strip()
search_q = request.GET.get('q', '').strip()
preparation_orders = PreparationOrder.objects.all()
preparation_orders = PreparationOrder.objects.select_related('intervention').all()
if selected_status:
preparation_orders = preparation_orders.filter(status=selected_status)
if selected_priority:
preparation_orders = preparation_orders.filter(priority=selected_priority)
if search_q:
q_filter = (
models.Q(order_number__icontains=search_q) |
models.Q(intervention__code__icontains=search_q) |
models.Q(intervention__title__icontains=search_q) |
models.Q(requester__icontains=search_q)
)
if search_q.isdigit():
q_filter |= models.Q(intervention__id=int(search_q))
preparation_orders = preparation_orders.filter(q_filter)
preparation_orders = preparation_orders.order_by("-date")[:1000]
production_orders = ProductionOrder.objects.all().select_related(
@ -654,6 +670,7 @@ def stock_movements(request):
"contracts": contracts,
"selected_status": selected_status,
"selected_priority": selected_priority,
"search_q": search_q,
"preparation_order_status_choices": [
('to_process', _("A traiter")),
('pending', _("En attente")),
@ -1166,6 +1183,11 @@ def _new_stock_batch_view(request, movement_type,
return success_redirect_fn(request, batch)
return redirect('stock:stock_movement_batch_detail', pk=batch.pk)
preselected_po_id = request.GET.get('preparation_order')
preselected_po = None
if preselected_po_id and str(preselected_po_id).isdigit():
preselected_po = PreparationOrder.objects.filter(pk=int(preselected_po_id)).select_related('intervention').first()
type_labels = {
'in': _("Nouvelle entrée de stock"),
'out': _("Nouvelle sortie de stock"),
@ -1181,6 +1203,7 @@ def _new_stock_batch_view(request, movement_type,
"companies": companies,
"contracts": contracts,
"cancel_url_name": cancel_url_name,
"preselected_po": preselected_po,
})
@ -3870,10 +3893,17 @@ def preparation_order_detail(request, pk):
can_manage = request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any()
can_cancel = user_config.can_manage_preparation_orders_any()
can_manage_purchase = user_config.can_manage_purchase_orders_any()
can_manage_stock = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.has_role('stock_manager')
or user_config.get_accessible_warehouses(can_manage=True).exists()
)
except UserConfig.DoesNotExist:
can_manage = False
can_cancel = False
can_manage_purchase = False
can_manage_stock = False
# Enrichir les autres produits avec leur bon de confection éventuel
other_items_with_info = []
@ -3895,6 +3925,7 @@ def preparation_order_detail(request, pk):
'can_manage': can_manage,
'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase,
'can_manage_stock': can_manage_stock,
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
'exit_batches': exit_batches,
'return_batches': return_batches,