feat: suggest product default locations when parsing purchase orders from PDF
This commit is contained in:
parent
1d212194ad
commit
03fa3adaf6
7 changed files with 347 additions and 9 deletions
|
|
@ -337,4 +337,57 @@ class MobilePurchaseOrderFromPdfTests(TestCase):
|
||||||
self.assertEqual(po.documents.first().name, "Photo_mobile_1.jpg")
|
self.assertEqual(po.documents.first().name, "Photo_mobile_1.jpg")
|
||||||
self.assertFalse(os.path.exists(tmp_p))
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1271,7 +1271,13 @@ def purchase_order_from_pdf_mobile(request):
|
||||||
location_id = request.POST.get(f'item_{i}_location')
|
location_id = request.POST.get(f'item_{i}_location')
|
||||||
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
|
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
|
||||||
if not location:
|
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(
|
PurchaseOrderItem.objects.create(
|
||||||
purchase_order=order,
|
purchase_order=order,
|
||||||
|
|
@ -1332,9 +1338,32 @@ def purchase_order_from_pdf_mobile(request):
|
||||||
|
|
||||||
locations = []
|
locations = []
|
||||||
default_location = None
|
default_location = None
|
||||||
|
product_locations_map = {}
|
||||||
|
product_locations_json = "{}"
|
||||||
if warehouse:
|
if warehouse:
|
||||||
|
from stock.models import get_product_default_locations_map
|
||||||
locations = warehouse.locations.all().order_by('name')
|
locations = warehouse.locations.all().order_by('name')
|
||||||
default_location = warehouse.locations.filter(is_default=True).first() or locations.first()
|
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', {
|
return render(request, 'stock/mobile/purchase_order_from_pdf_mobile.html', {
|
||||||
'step': step,
|
'step': step,
|
||||||
|
|
@ -1342,6 +1371,8 @@ def purchase_order_from_pdf_mobile(request):
|
||||||
'selected_warehouse': warehouse,
|
'selected_warehouse': warehouse,
|
||||||
'locations': locations,
|
'locations': locations,
|
||||||
'default_location': default_location,
|
'default_location': default_location,
|
||||||
|
'product_locations_map': product_locations_map,
|
||||||
|
'product_locations_json': product_locations_json,
|
||||||
'suppliers': suppliers,
|
'suppliers': suppliers,
|
||||||
'categories': categories,
|
'categories': categories,
|
||||||
'products': products,
|
'products': products,
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,18 @@ class Product(models.Model):
|
||||||
product=self, purchase_order__status__in=["pending", "ordered"]
|
product=self, purchase_order__status__in=["pending", "ordered"]
|
||||||
).exists()
|
).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):
|
def notify_supervisors_pending_product(product, created_by=None):
|
||||||
"""Notifie les superviseurs / admins qu'un nouveau produit attend validation."""
|
"""Notifie les superviseurs / admins qu'un nouveau produit attend validation."""
|
||||||
|
|
@ -441,6 +453,97 @@ class Stock(models.Model):
|
||||||
self.product.update_total_stock()
|
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):
|
class StockMovementBatch(models.Model):
|
||||||
"""Regroupe un ensemble de mouvements de stock créés simultanément."""
|
"""Regroupe un ensemble de mouvements de stock créés simultanément."""
|
||||||
TYPE_IN = "in"
|
TYPE_IN = "in"
|
||||||
|
|
|
||||||
|
|
@ -348,9 +348,9 @@
|
||||||
|
|
||||||
<!-- Rapprochement produit du catalogue -->
|
<!-- Rapprochement produit du catalogue -->
|
||||||
<div class="mb-2">
|
<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 }}">
|
<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>
|
</div>
|
||||||
{% else %}
|
{% 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 }}">
|
<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 }})">
|
<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>
|
<option value="">{% translate "— Choisir un produit du catalogue —" %}</option>
|
||||||
{% for p in products %}
|
{% 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 }})
|
{{ p.name }} ({{ p.code }})
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -384,9 +384,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 mt-1">
|
<div class="col-12 mt-1">
|
||||||
<label class="form-label text-muted mb-0" style="font-size: 0.75rem;">{% translate "Emplacement de stockage" %}</label>
|
<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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -419,6 +419,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// ── GESTION DE L'UPLOAD EN ÉTAPE 1 ──
|
// ── GESTION DE L'UPLOAD EN ÉTAPE 1 ──
|
||||||
const dropZone = document.getElementById('drop-zone');
|
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.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;
|
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 {
|
} else {
|
||||||
if (card) card.classList.add('unassociated');
|
if (card) card.classList.add('unassociated');
|
||||||
if (label) {
|
if (label) {
|
||||||
|
|
|
||||||
|
|
@ -476,9 +476,9 @@
|
||||||
|
|
||||||
<!-- Emplacement de stock -->
|
<!-- Emplacement de stock -->
|
||||||
<td>
|
<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 %}
|
{% 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 %}
|
{{ loc.name }} {% if loc.is_default %}(par défaut){% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|
@ -528,6 +528,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PRODUCT_LOCATIONS_MAP = {{ product_locations_json|default:'{}'|safe }};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Zone drag & drop
|
// Zone drag & drop
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
|
@ -641,6 +643,14 @@ function handleProductSelection(idx) {
|
||||||
select.classList.remove('is-invalid', 'border-warning');
|
select.classList.remove('is-invalid', 'border-warning');
|
||||||
if (row) row.classList.remove('table-warning-subtle');
|
if (row) row.classList.remove('table-warning-subtle');
|
||||||
if (help) help.classList.add('d-none');
|
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 {
|
} else {
|
||||||
if (cb) cb.checked = false;
|
if (cb) cb.checked = false;
|
||||||
select.classList.add('border-warning');
|
select.classList.add('border-warning');
|
||||||
|
|
|
||||||
|
|
@ -2938,3 +2938,103 @@ class PurchaseOrderFromDocumentTests(TestCase):
|
||||||
self.assertEqual(response.context["parse_result"]["image_count"], 2)
|
self.assertEqual(response.context["parse_result"]["image_count"], 2)
|
||||||
self.assertEqual(len(response.context["saved_temp_files"]), 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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1811,7 +1811,13 @@ def purchase_order_from_pdf(request):
|
||||||
location_id = request.POST.get(f'item_{i}_location')
|
location_id = request.POST.get(f'item_{i}_location')
|
||||||
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
|
location = WarehouseLocation.objects.filter(warehouse=warehouse, pk=location_id).first()
|
||||||
if not location:
|
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
|
# Créer la ligne de commande
|
||||||
PurchaseOrderItem.objects.create(
|
PurchaseOrderItem.objects.create(
|
||||||
|
|
@ -1875,9 +1881,32 @@ def purchase_order_from_pdf(request):
|
||||||
|
|
||||||
locations = []
|
locations = []
|
||||||
default_location = None
|
default_location = None
|
||||||
|
product_locations_map = {}
|
||||||
|
product_locations_json = "{}"
|
||||||
if warehouse:
|
if warehouse:
|
||||||
|
from .models import get_product_default_locations_map
|
||||||
locations = warehouse.locations.all().order_by('name')
|
locations = warehouse.locations.all().order_by('name')
|
||||||
default_location = warehouse.locations.filter(is_default=True).first() or locations.first()
|
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', {
|
return render(request, 'stock/purchase_order_from_pdf.html', {
|
||||||
'step': step,
|
'step': step,
|
||||||
|
|
@ -1885,6 +1914,8 @@ def purchase_order_from_pdf(request):
|
||||||
'selected_warehouse': warehouse,
|
'selected_warehouse': warehouse,
|
||||||
'locations': locations,
|
'locations': locations,
|
||||||
'default_location': default_location,
|
'default_location': default_location,
|
||||||
|
'product_locations_map': product_locations_map,
|
||||||
|
'product_locations_json': product_locations_json,
|
||||||
'suppliers': suppliers,
|
'suppliers': suppliers,
|
||||||
'categories': categories,
|
'categories': categories,
|
||||||
'products': products,
|
'products': products,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue