diff --git a/streetup/mobile/views.py b/streetup/mobile/views.py index 2051bdf..c52cbc5 100644 --- a/streetup/mobile/views.py +++ b/streetup/mobile/views.py @@ -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, diff --git a/streetup/stock/models.py b/streetup/stock/models.py index 57c3454..c5766d2 100644 --- a/streetup/stock/models.py +++ b/streetup/stock/models.py @@ -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): diff --git a/streetup/stock/templates/stock/mobile/new_stock_batch_mobile.html b/streetup/stock/templates/stock/mobile/new_stock_batch_mobile.html index c600c3e..8b8639f 100644 --- a/streetup/stock/templates/stock/mobile/new_stock_batch_mobile.html +++ b/streetup/stock/templates/stock/mobile/new_stock_batch_mobile.html @@ -167,7 +167,7 @@ {% if movement_type == 'in' %} - + {% 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" %} ${data.items.length} {% translate "produit(s) depuis le bon" %} ${escHtml(data.order_number)}`; 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(); 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 a115e1d..408582d 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 @@ -456,6 +456,19 @@ {% translate "Annuler le bon" %} {% endif %} + {% elif preparation_order.status == 'processed' %} + {% if can_manage_stock %} + + {% translate "Créer un retour de stock" %} + + {% endif %} +
+ {% csrf_token %} + +
{% endif %} {% endif %} diff --git a/streetup/stock/templates/stock/mobile/stock_preparation_orders_mobile.html b/streetup/stock/templates/stock/mobile/stock_preparation_orders_mobile.html index d41e00b..4ed6225 100644 --- a/streetup/stock/templates/stock/mobile/stock_preparation_orders_mobile.html +++ b/streetup/stock/templates/stock/mobile/stock_preparation_orders_mobile.html @@ -40,6 +40,13 @@
+
+
+ + + +
+
- {% if selected_status or selected_priority %} + {% if selected_status or selected_priority or search_q %} {% if order.intervention %}
- {% translate "Intervention" %} #{{ order.intervention.id }} + {% if order.intervention.code %}{{ order.intervention.code }}{% else %}#{{ order.intervention.id }}{% endif %}{% if order.intervention.title %} — {{ order.intervention.title }}{% endif %}
{% endif %}
diff --git a/streetup/stock/templates/stock/new_stock_batch.html b/streetup/stock/templates/stock/new_stock_batch.html index 484f738..14a8a09 100644 --- a/streetup/stock/templates/stock/new_stock_batch.html +++ b/streetup/stock/templates/stock/new_stock_batch.html @@ -183,7 +183,7 @@ {% if movement_type == 'in' %} - + {% 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" %} ${data.items.length} {% translate "produit(s) depuis le bon" %} ${escHtml(data.order_number)}`; 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(); diff --git a/streetup/stock/templates/stock/preparation_order_detail.html b/streetup/stock/templates/stock/preparation_order_detail.html index e717cdb..a8b04b8 100644 --- a/streetup/stock/templates/stock/preparation_order_detail.html +++ b/streetup/stock/templates/stock/preparation_order_detail.html @@ -647,6 +647,21 @@
{% endif %} {% elif preparation_order.status == 'processed' %} + {% if can_manage_stock %} +
+
+
+ +
{% translate "Créer un retour de stock" %}
+

{% translate "Enregistrer la réintégration de matériel en stock préremplie depuis ce bon" %}

+ + {% translate "Créer un retour" %} + +
+
+
+ {% endif %} +
diff --git a/streetup/stock/templates/stock/stock_movements.html b/streetup/stock/templates/stock/stock_movements.html index 0aa0e90..696a5ea 100644 --- a/streetup/stock/templates/stock/stock_movements.html +++ b/streetup/stock/templates/stock/stock_movements.html @@ -48,8 +48,16 @@ - +
+
+ + + +
+
+ +
@@ -73,16 +81,16 @@
-
+
- {% if selected_status or selected_priority %} - + {% if selected_status or selected_priority or search_q %} + {% translate "Effacer" %} {% endif %}
{% if can_create_preparation_order %} - + {% translate "Nouveau bon" %} {% endif %} @@ -110,6 +118,12 @@ {{ order.order_number }} + {% if order.intervention %} + + {% if order.intervention.code %}{{ order.intervention.code }}{% else %}#{{ order.intervention.id }}{% endif %}{% if order.intervention.title %} — {{ order.intervention.title }}{% endif %} + + {% endif %} + {% if order.status == 'to_process' %} {{ order.get_status_display }} {% elif order.status == 'pending' %} diff --git a/streetup/stock/tests.py b/streetup/stock/tests.py index d07ec96..c183639 100644 --- a/streetup/stock/tests.py +++ b/streetup/stock/tests.py @@ -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) + diff --git a/streetup/stock/views.py b/streetup/stock/views.py index c650475..fd04ff1 100644 --- a/streetup/stock/views.py +++ b/streetup/stock/views.py @@ -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,