feat: suggest product default locations when parsing purchase orders from PDF

This commit is contained in:
kdeterme 2026-10-05 09:24:22 +02:00
parent 1d212194ad
commit 03fa3adaf6
7 changed files with 347 additions and 9 deletions

View file

@ -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())

View file

@ -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,

View file

@ -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"

View file

@ -348,9 +348,9 @@
<!-- Rapprochement produit du catalogue -->
<div class="mb-2">
{% if item.matched_product %}
{% if item.matched_product_id %}
<div class="badge bg-success-subtle text-success border border-success-subtle mb-1 w-100 text-start text-truncate" id="matched-label-{{ idx }}">
<i class="bi bi-check-circle me-1"></i>{% translate "Associé à :" %} {{ item.matched_product.name }}
<i class="bi bi-check-circle me-1"></i>{% translate "Associé à :" %} {{ item.matched_product_name }}
</div>
{% else %}
<div class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle mb-1 w-100 text-start text-truncate" id="matched-label-{{ idx }}">
@ -361,7 +361,7 @@
<select name="item_{{ idx }}_product_id" id="item_{{ idx }}_product_id" class="form-select form-select-sm" data-idx="{{ idx }}" onchange="handleMobileProductChange({{ idx }})">
<option value="">{% translate "— Choisir un produit du catalogue —" %}</option>
{% for p in products %}
<option value="{{ p.id }}" {% if item.matched_product and item.matched_product.id == p.id %}selected{% endif %}>
<option value="{{ p.id }}" {% if item.matched_product_id == p.id %}selected{% endif %}>
{{ p.name }} ({{ p.code }})
</option>
{% endfor %}
@ -384,9 +384,9 @@
</div>
<div class="col-12 mt-1">
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "Emplacement de stockage" %}</label>
<select name="item_{{ idx }}_location" class="form-select form-select-sm">
<select name="item_{{ idx }}_location" id="item_{{ idx }}_location" class="form-select form-select-sm">
{% for loc in locations %}
<option value="{{ loc.id }}" {% if default_location and default_location.id == loc.id %}selected{% endif %}>{{ loc.name }}</option>
<option value="{{ loc.id }}" {% if loc.id == item.suggested_location_id or not item.suggested_location_id and default_location and default_location.id == loc.id %}selected{% endif %}>{{ loc.name }}</option>
{% endfor %}
</select>
</div>
@ -419,6 +419,8 @@
{% endif %}
<script>
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
document.addEventListener('DOMContentLoaded', function() {
// ── GESTION DE L'UPLOAD EN ÉTAPE 1 ──
const dropZone = document.getElementById('drop-zone');
@ -521,6 +523,14 @@ function handleMobileProductChange(idx) {
label.className = 'badge bg-success-subtle text-success border border-success-subtle mb-1 w-100 text-start text-truncate';
label.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Associé à :" %} ' + selectedText;
}
// Adapter automatiquement l'emplacement de destination au produit sélectionné
const pid = parseInt(select.value, 10);
const targetLocId = PRODUCT_LOCATIONS_MAP[pid];
const locSelect = document.getElementById('item_' + idx + '_location');
if (locSelect && targetLocId) {
locSelect.value = targetLocId;
}
} else {
if (card) card.classList.add('unassociated');
if (label) {

View file

@ -476,9 +476,9 @@
<!-- Emplacement de stock -->
<td>
<select name="item_{{ idx }}_location" class="form-select form-select-sm">
<select name="item_{{ idx }}_location" id="item_{{ idx }}_location" class="form-select form-select-sm">
{% for loc in locations %}
<option value="{{ loc.id }}" {% if loc.id == default_location.id %}selected{% endif %}>
<option value="{{ loc.id }}" {% if loc.id == item.suggested_location_id or not item.suggested_location_id and loc.id == default_location.id %}selected{% endif %}>
{{ loc.name }} {% if loc.is_default %}(par défaut){% endif %}
</option>
{% empty %}
@ -528,6 +528,8 @@
</div>
<script>
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
document.addEventListener('DOMContentLoaded', function() {
// Zone drag & drop
const dropZone = document.getElementById('drop-zone');
@ -641,6 +643,14 @@ function handleProductSelection(idx) {
select.classList.remove('is-invalid', 'border-warning');
if (row) row.classList.remove('table-warning-subtle');
if (help) help.classList.add('d-none');
// Adapter automatiquement l'emplacement de destination au produit sélectionné
const pid = parseInt(select.value, 10);
const targetLocId = PRODUCT_LOCATIONS_MAP[pid];
const locSelect = document.getElementById('item_' + idx + '_location');
if (locSelect && targetLocId) {
locSelect.value = targetLocId;
}
} else {
if (cb) cb.checked = false;
select.classList.add('border-warning');

View file

@ -2938,3 +2938,103 @@ class PurchaseOrderFromDocumentTests(TestCase):
self.assertEqual(response.context["parse_result"]["image_count"], 2)
self.assertEqual(len(response.context["saved_temp_files"]), 2)
def test_get_product_default_locations_map_and_model_method(self):
from .models import WarehouseLocation, Stock, get_product_default_locations_map
# loc1 est l'emplacement par défaut de l'entrepôt (self.location)
# Créer loc2 (non par défaut)
loc2 = WarehouseLocation.objects.create(
warehouse=self.warehouse,
name="Rayon B Étagère 2",
code="LOC-WH-DOC-2",
is_default=False,
)
# Créer loc3 (non par défaut)
loc3 = WarehouseLocation.objects.create(
warehouse=self.warehouse,
name="Rayon C Étagère 3",
code="LOC-WH-DOC-3",
is_default=False,
)
# Produit 1 : a un emplacement autorisé spécifique (loc2)
self.product.authorized_locations.add(loc2)
loc_map = get_product_default_locations_map(self.warehouse, [self.product.id])
self.assertEqual(loc_map[self.product.id], loc2.id)
self.assertEqual(self.product.get_default_location(self.warehouse), loc2)
# Produit 2 : sans emplacement autorisé mais avec du stock existant en loc3
from .models import Product
prod2 = Product.objects.create(
code="PROD-DOC-STOCK",
name="Forets SDS",
sku="SKU-DOC-SDS",
)
Stock.objects.create(product=prod2, location=loc3, quantity=40)
loc_map2 = get_product_default_locations_map(self.warehouse, [prod2.id])
self.assertEqual(loc_map2[prod2.id], loc3.id)
self.assertEqual(prod2.get_default_location(self.warehouse), loc3)
# Produit 3 : sans emplacement autorisé et sans stock -> fallback vers l'emplacement par défaut de l'entrepôt
prod3 = Product.objects.create(
code="PROD-DOC-NONE",
name="Scie Égoïne",
sku="SKU-DOC-SAW",
)
loc_map3 = get_product_default_locations_map(self.warehouse, [prod3.id])
self.assertEqual(loc_map3[prod3.id], self.location.id)
self.assertEqual(prod3.get_default_location(self.warehouse), self.location)
def test_view_parse_pdf_suggests_product_default_location(self):
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from .models import WarehouseLocation
loc_specific = WarehouseLocation.objects.create(
warehouse=self.warehouse,
name="Zone Outillage Spécial",
code="LOC-SPECIFIC",
is_default=False,
)
self.product.authorized_locations.add(loc_specific)
self.client.force_login(self.superuser)
url = reverse("stock:purchase_order_from_pdf")
file1 = SimpleUploadedFile("devis.pdf", b"%PDF-1.4...", content_type="application/pdf")
mock_parse_result = {
'is_ocr': False,
'is_image': False,
'total_items': 1,
'metadata': {'supplier_name': 'Test Sup', 'quote_number': 'OF999'},
'matched_supplier': self.supplier,
'items': [{
'reference': 'ZE 45525',
'name': 'Mètre ruban',
'quantity': 3,
'unit': 'piece',
'raw_unit': 'Piece',
'gross_unit_price': 9.28,
'discount': '0%',
'unit_price': 9.28,
'total_amount': 27.84,
'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_parse_result):
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_specific.id)
self.assertIn(f'value="{loc_specific.id}" selected', response.content.decode())

View file

@ -1811,7 +1811,13 @@ def purchase_order_from_pdf(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 .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()
# Créer la ligne de commande
PurchaseOrderItem.objects.create(
@ -1875,9 +1881,32 @@ def purchase_order_from_pdf(request):
locations = []
default_location = None
product_locations_map = {}
product_locations_json = "{}"
if warehouse:
from .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/purchase_order_from_pdf.html', {
'step': step,
@ -1885,6 +1914,8 @@ def purchase_order_from_pdf(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,