diff --git a/loko/mobile/tests.py b/loko/mobile/tests.py index 6b10ce9..047aac4 100644 --- a/loko/mobile/tests.py +++ b/loko/mobile/tests.py @@ -337,4 +337,57 @@ class MobilePurchaseOrderFromPdfTests(TestCase): self.assertEqual(po.documents.first().name, "Photo_mobile_1.jpg") self.assertFalse(os.path.exists(tmp_p)) + def test_mobile_purchase_order_from_pdf_suggests_product_default_location(self): + from unittest.mock import patch + from django.core.files.uploadedfile import SimpleUploadedFile + from stock.models import WarehouseLocation + + loc_mob_specific = WarehouseLocation.objects.create( + warehouse=self.warehouse, + name="Zone Rayon Spécifique Mob", + code="LOC-MOB-SPECIFIC", + is_default=False, + ) + self.product.authorized_locations.add(loc_mob_specific) + + url = reverse("mobile:purchase_order_from_pdf_mobile") + file1 = SimpleUploadedFile("photo_mob.jpg", b"\xff\xd8\xff\xe0mockjpg", content_type="image/jpeg") + + mock_res = { + 'is_ocr': True, + 'is_image': True, + 'is_multi_image': False, + 'image_count': 1, + 'total_items': 1, + 'metadata': {'supplier_name': 'Fournisseur Mobile Test', 'quote_number': 'MOB-OF-99'}, + 'matched_supplier': self.supplier, + 'items': [{ + 'reference': 'REF-1', + 'name': 'Mètre Ruban Test Mobile', + 'quantity': 2, + 'unit': 'piece', + 'raw_unit': 'Piece', + 'gross_unit_price': 15.50, + 'discount': '0%', + 'unit_price': 15.50, + 'total_amount': 31.00, + 'vat_rate': 21.0, + 'matched_product_id': self.product.id, + 'matched_product_name': self.product.name, + 'matched_product_code': self.product.code, + }], + } + + with patch("stock.pdf_parser.PdfQuoteParser.parse", return_value=mock_res): + response = self.client.post(url, { + "action": "parse_pdf", + "warehouse": self.warehouse.pk, + "pdf_file": [file1], + }) + + self.assertEqual(response.status_code, 200) + items = response.context["parse_result"]["items"] + self.assertEqual(items[0]["suggested_location_id"], loc_mob_specific.id) + self.assertIn(f'value="{loc_mob_specific.id}" selected', response.content.decode()) + diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 92cfda2..9376adb 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -1271,7 +1271,13 @@ def purchase_order_from_pdf_mobile(request): location_id = request.POST.get(f'item_{i}_location') location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first() if not location: - location = warehouse.locations.filter(is_default=True).first() or warehouse.locations.first() + from stock.models import get_product_default_locations_map + prod_locs = get_product_default_locations_map(warehouse, [product.id]) + def_loc_id = prod_locs.get(product.id) + if def_loc_id: + location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=def_loc_id).first() + if not location: + location = warehouse.locations.filter(is_default=True).first() or warehouse.locations.first() PurchaseOrderItem.objects.create( purchase_order=order, @@ -1332,9 +1338,32 @@ def purchase_order_from_pdf_mobile(request): locations = [] default_location = None + product_locations_map = {} + product_locations_json = "{}" if warehouse: + from stock.models import get_product_default_locations_map locations = warehouse.locations.all().order_by('name') default_location = warehouse.locations.filter(is_default=True).first() or locations.first() + product_locations_map = get_product_default_locations_map(warehouse) + product_locations_json = json.dumps(product_locations_map) + + if parse_result and 'items' in parse_result: + for item in parse_result['items']: + matched_pid = item.get('matched_product_id') + if not matched_pid and item.get('matched_product'): + matched_obj = item['matched_product'] + matched_pid = getattr(matched_obj, 'id', None) + item['matched_product_id'] = matched_pid + if 'matched_product_name' not in item: + item['matched_product_name'] = getattr(matched_obj, 'name', '') + if 'matched_product_code' not in item: + item['matched_product_code'] = getattr(matched_obj, 'code', '') + if matched_pid and matched_pid in product_locations_map: + item['suggested_location_id'] = product_locations_map[matched_pid] + elif default_location: + item['suggested_location_id'] = default_location.id + else: + item['suggested_location_id'] = None return render(request, 'stock/mobile/purchase_order_from_pdf_mobile.html', { 'step': step, @@ -1342,6 +1371,8 @@ def purchase_order_from_pdf_mobile(request): 'selected_warehouse': warehouse, 'locations': locations, 'default_location': default_location, + 'product_locations_map': product_locations_map, + 'product_locations_json': product_locations_json, 'suppliers': suppliers, 'categories': categories, 'products': products, diff --git a/loko/stock/models.py b/loko/stock/models.py index 9529dbe..cffb905 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -367,6 +367,18 @@ class Product(models.Model): product=self, purchase_order__status__in=["pending", "ordered"] ).exists() + def get_default_location(self, warehouse): + """ + Retourne l'emplacement de stockage par défaut pour ce produit dans un entrepôt donné. + """ + if not warehouse or not self.pk: + return None + loc_map = get_product_default_locations_map(warehouse, product_ids=[self.pk]) + loc_id = loc_map.get(self.pk) + if loc_id: + return warehouse.locations.filter(pk=loc_id).first() + return None + def notify_supervisors_pending_product(product, created_by=None): """Notifie les superviseurs / admins qu'un nouveau produit attend validation.""" @@ -441,6 +453,97 @@ class Stock(models.Model): self.product.update_total_stock() +def get_product_default_locations_map(warehouse, product_ids=None): + """ + Retourne un dictionnaire {product_id: location_id} pour les produits donnés + (ou tous les produits actifs si non spécifié) dans l'entrepôt spécifié. + + Règles de détermination de l'emplacement par défaut : + 1. Si le produit possède des emplacements autorisés dans cet entrepôt : + a. L'emplacement autorisé marqué is_default=True pour l'entrepôt s'il existe. + b. L'emplacement autorisé ayant le plus de stock (> 0). + c. Le premier emplacement autorisé (ordre alphabétique). + 2. Si le produit n'a pas d'emplacement autorisé restreint dans cet entrepôt : + a. L'emplacement de cet entrepôt où le produit a déjà du stock (> 0, quantité max). + b. L'emplacement par défaut de l'entrepôt (is_default=True). + c. Le premier emplacement de l'entrepôt. + """ + if not warehouse: + return {} + + locations = list(warehouse.locations.all().order_by('name')) + if not locations: + return {} + + wh_default_loc = next((l for l in locations if l.is_default), locations[0]) + wh_default_loc_id = wh_default_loc.id if wh_default_loc else None + wh_loc_ids = {l.id for l in locations} + loc_is_default = {l.id: l.is_default for l in locations} + + # 1. Emplacements autorisés par produit pour cet entrepôt + auth_m2m = Product.authorized_locations.through.objects.filter( + warehouselocation_id__in=wh_loc_ids + ) + if product_ids is not None: + auth_m2m = auth_m2m.filter(product_id__in=product_ids) + + product_auth_locs = {} + for entry in auth_m2m.values('product_id', 'warehouselocation_id'): + p_id = entry['product_id'] + l_id = entry['warehouselocation_id'] + product_auth_locs.setdefault(p_id, []).append(l_id) + + # 2. Stocks existants dans cet entrepôt + stock_qs = Stock.objects.filter( + location__warehouse=warehouse, + quantity__gt=0 + ) + if product_ids is not None: + stock_qs = stock_qs.filter(product_id__in=product_ids) + + # Organiser les stocks par produit triés par quantité décroissante + stock_by_product = {} + for s in stock_qs.values('product_id', 'location_id', 'quantity').order_by('-quantity'): + stock_by_product.setdefault(s['product_id'], []).append((s['location_id'], s['quantity'])) + + # 3. Liste des identifiants produits cibles + if product_ids is not None: + target_pids = list(product_ids) + else: + target_pids = list(Product.objects.filter(is_active=True).values_list('id', flat=True)) + + result_map = {} + for p_id in target_pids: + auth_ids = product_auth_locs.get(p_id) + if auth_ids: + # 1a. Emplacement autorisé avec is_default + def_auth = next((l_id for l_id in auth_ids if loc_is_default.get(l_id)), None) + if def_auth: + result_map[p_id] = def_auth + continue + + # 1b. Emplacement autorisé avec stock > 0 + stocks = stock_by_product.get(p_id, []) + stock_auth = next((l_id for (l_id, qty) in stocks if l_id in auth_ids), None) + if stock_auth: + result_map[p_id] = stock_auth + continue + + # 1c. Premier emplacement autorisé + result_map[p_id] = auth_ids[0] + else: + # 2a. Emplacement de cet entrepôt avec stock > 0 + stocks = stock_by_product.get(p_id, []) + if stocks: + result_map[p_id] = stocks[0][0] + continue + + # 2b / 2c. Emplacement par défaut de l'entrepôt + result_map[p_id] = wh_default_loc_id + + return result_map + + class StockMovementBatch(models.Model): """Regroupe un ensemble de mouvements de stock créés simultanément.""" TYPE_IN = "in" diff --git a/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html b/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html index 96c5803..223fc4b 100644 --- a/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html +++ b/loko/stock/templates/stock/mobile/purchase_order_from_pdf_mobile.html @@ -348,9 +348,9 @@