feat: add stock batch views, templates, and comprehensive test suite

This commit is contained in:
Karl Determe 2026-09-29 15:20:03 +02:00
parent 25a64b824a
commit 001bee8ca9
4 changed files with 600 additions and 378 deletions

View file

@ -70,7 +70,7 @@
<div class="card-body p-2">
{% if movement_type == 'in' %}
<!-- Entrée : Source = Personne/Entreprise | Destination = Emplacement -->
<!-- Entrée : Source = Personne/Entreprise -->
<div class="border-start border-secondary border-3 ps-2 mb-2">
<div class="small fw-semibold text-secondary mb-2"><i class="bi bi-person-fill me-1"></i>{% translate "Source" %}</div>
<div class="mb-2">
@ -92,32 +92,8 @@
</datalist>
</div>
</div>
<div class="border-start border-success border-3 ps-2 mb-2">
<div class="small fw-semibold text-success mb-2"><i class="bi bi-box-arrow-in-down-right me-1"></i>{% translate "Destination" %}</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement" %} <span class="text-danger">*</span></label>
<select name="global_destination_location_id" id="global_dest_location_id" class="form-select form-select-sm" required>
<option value="">-- {% translate "Emplacement destination" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% elif movement_type == 'out' %}
<!-- Sortie : Source = Emplacement | Destination = Personne/Entreprise -->
<div class="border-start border-danger border-3 ps-2 mb-2">
<div class="small fw-semibold text-danger mb-2"><i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Source" %}</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement" %} <span class="text-danger">*</span></label>
<select name="global_source_location_id" id="global_source_location_id" class="form-select form-select-sm" required>
<option value="">-- {% translate "Emplacement source" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Sortie : Destination = Personne/Entreprise -->
<div class="border-start border-secondary border-3 ps-2 mb-2">
<div class="small fw-semibold text-secondary mb-2"><i class="bi bi-person-fill me-1"></i>{% translate "Destination" %}</div>
<div class="mb-2">
@ -139,26 +115,6 @@
</datalist>
</div>
</div>
{% else %}
<!-- Transfert : Source = Emplacement | Destination = Emplacement -->
<div class="mb-2">
<label class="form-label small fw-semibold mb-1"><i class="bi bi-box-arrow-up-right text-danger me-1"></i>{% translate "Source" %} <span class="text-danger">*</span></label>
<select name="global_source_location_id" id="global_source_location_id" class="form-select form-select-sm" required>
<option value="">-- {% translate "Emplacement source" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1"><i class="bi bi-box-arrow-in-down-right text-success me-1"></i>{% translate "Destination" %} <span class="text-danger">*</span></label>
<select name="global_destination_location_id" id="global_dest_location_id" class="form-select form-select-sm" required>
<option value="">-- {% translate "Emplacement destination" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% comment %}<div class="mb-2">
@ -179,12 +135,12 @@
<option value="purchase">{% translate "Achat" %}</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>
<option value="correction" {% if not can_administer_any %}style="display: none;" disabled{% endif %}>{% translate "Correction (Administrateur uniquement)" %}</option>
{% elif movement_type == 'out' %}
<option value="epi">{% translate "EPI" %}</option>
<option value="loan">{% translate "Emprunt" %}</option>
<option value="worksite">{% translate "Chantier" %}</option>
<option value="correction" style="display: none;">{% translate "Correction (Administrateur uniquement)" %}</option>
<option value="correction" {% if not can_administer_any %}style="display: none;" disabled{% endif %}>{% translate "Correction (Administrateur uniquement)" %}</option>
{% endif %}
</select>
</div>
@ -302,8 +258,39 @@
<input type="number" name="quantity" class="form-control form-control-sm" min="1" value="1" required>
</div>
<input type="hidden" name="source_location_id" value="">
<input type="hidden" name="destination_location_id" value="">
<!-- Emplacement(s) -->
{% if movement_type == 'in' %}
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement destination" %} <span class="text-danger">*</span></label>
<select name="destination_location_id" class="form-select form-select-sm destination-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text destination-stock-hint text-muted small" style="display:none; font-size:0.75rem;"></div>
</div>
{% elif movement_type == 'out' %}
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement source" %} <span class="text-danger">*</span></label>
<select name="source_location_id" class="form-select form-select-sm source-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text source-stock-hint text-muted small" style="display:none; font-size:0.75rem;"></div>
</div>
{% elif movement_type == 'transfer' %}
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement source" %} <span class="text-danger">*</span></label>
<select name="source_location_id" class="form-select form-select-sm source-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text source-stock-hint text-muted small" style="display:none; font-size:0.75rem;"></div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">{% translate "Emplacement destination" %} <span class="text-danger">*</span></label>
<select name="destination_location_id" class="form-select form-select-sm destination-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text destination-stock-hint text-muted small" style="display:none; font-size:0.75rem;"></div>
</div>
{% endif %}
<!-- Articles tracés (produit sérialisé) -->
<div class="serialized-items-section mt-2" style="display:none;">
@ -341,6 +328,7 @@
{% block extra_scripts %}
<script>
const PRODUCT_AUTOCOMPLETE_URL = '{% url "stock:products_autocomplete_api" %}';
const PRODUCT_LOCATIONS_API_BASE = '{% url "stock:product_locations_api" 0 %}'.replace('/0/', '/{id}/');
const SERIALIZED_ITEMS_API_BASE = '{% url "stock:serialized_items_for_product_api" 0 %}'.replace('/0/', '/{id}/');
const MOVEMENT_TYPE = '{{ movement_type }}';
@ -350,6 +338,139 @@ function escHtml(str) {
return d.innerHTML;
}
// ── Emplacements dynamiques par produit ──────────────────────
function fetchLocationsForProduct(lineEl, productId, preferredLocationId = null) {
const url = PRODUCT_LOCATIONS_API_BASE.replace('{id}', productId);
const sourceSelect = lineEl.querySelector('.source-location-select');
const destSelect = lineEl.querySelector('.destination-location-select');
const sourceHint = lineEl.querySelector('.source-stock-hint');
const destHint = lineEl.querySelector('.destination-stock-hint');
// Indicateur de chargement
[sourceSelect, destSelect].forEach(sel => {
if (sel) {
sel.disabled = true;
sel.innerHTML = '<option value="">-- {% translate "Chargement des emplacements…" %} --</option>';
}
});
fetch(url)
.then(r => r.json())
.then(data => {
const locations = data.locations || [];
if (sourceSelect) {
const prevVal = preferredLocationId || sourceSelect.dataset.selectedVal || '';
sourceSelect.innerHTML = '';
// Emplacements avec stock en priorité pour la source
const withStock = locations.filter(loc => loc.quantity > 0);
const candidates = withStock.length > 0 ? withStock : locations;
if (!candidates.length) {
sourceSelect.innerHTML = '<option value="">-- {% translate "Aucun emplacement disponible" %} --</option>';
sourceSelect.disabled = true;
} else {
candidates.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name} (${loc.quantity} {% translate "en stock" %})`;
if (String(loc.id) === String(prevVal)) opt.selected = true;
sourceSelect.appendChild(opt);
});
// Si plusieurs emplacements sont possibles, le premier est choisi si aucun n'est déjà sélectionné
if (!sourceSelect.value && candidates.length > 0) {
sourceSelect.value = candidates[0].id;
}
sourceSelect.disabled = false;
}
if (sourceHint) {
if (withStock.length > 0) {
sourceHint.textContent = `${withStock.length} {% translate "emplacement(s) avec stock" %}`;
sourceHint.className = 'form-text source-stock-hint text-muted small';
} else {
sourceHint.textContent = '{% translate "Aucun stock dans les emplacements autorisés." %}';
sourceHint.className = 'form-text source-stock-hint text-danger small';
}
sourceHint.style.display = '';
}
}
if (destSelect) {
const prevVal = preferredLocationId || destSelect.dataset.selectedVal || '';
destSelect.innerHTML = '';
if (!locations.length) {
destSelect.innerHTML = '<option value="">-- {% translate "Aucun emplacement disponible" %} --</option>';
destSelect.disabled = true;
} else {
locations.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
const qteLabel = loc.quantity > 0 ? ` (${loc.quantity} {% translate "en stock" %})` : '';
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name}${qteLabel}`;
if (String(loc.id) === String(prevVal)) opt.selected = true;
destSelect.appendChild(opt);
});
// Si pas encore sélectionné :
if (!destSelect.value) {
if (MOVEMENT_TYPE === 'transfer' && sourceSelect && sourceSelect.value) {
// En transfert, choisir par défaut un emplacement différent de la source si possible
const diffLoc = locations.find(loc => String(loc.id) !== String(sourceSelect.value));
destSelect.value = diffLoc ? diffLoc.id : locations[0].id;
} else if (locations.length > 0) {
// Le premier est choisi
destSelect.value = locations[0].id;
}
}
destSelect.disabled = false;
}
if (destHint) {
destHint.textContent = `${locations.length} {% translate "emplacement(s) autorisé(s)" %}`;
destHint.className = 'form-text destination-stock-hint text-muted small';
destHint.style.display = '';
}
}
// En transfert, si la source change et devient identique à la destination, réajuster
if (sourceSelect && destSelect && MOVEMENT_TYPE === 'transfer') {
sourceSelect.onchange = function () {
if (sourceSelect.value && destSelect.value && sourceSelect.value === destSelect.value) {
const diffLoc = locations.find(loc => String(loc.id) !== String(sourceSelect.value));
if (diffLoc) destSelect.value = diffLoc.id;
}
};
}
})
.catch(() => {
[sourceSelect, destSelect].forEach(sel => {
if (sel) {
sel.disabled = false;
sel.innerHTML = '<option value="">-- {% translate "Erreur de chargement" %} --</option>';
}
});
});
}
function resetLocationSelects(lineEl) {
const lineSource = lineEl.querySelector('.source-location-select');
const lineDest = lineEl.querySelector('.destination-location-select');
if (lineSource) {
lineSource.innerHTML = '<option value="">-- {% translate "Sélectionnez d\'abord un produit" %} --</option>';
lineSource.disabled = true;
}
if (lineDest) {
lineDest.innerHTML = '<option value="">-- {% translate "Sélectionnez d\'abord un produit" %} --</option>';
lineDest.disabled = true;
}
const sHint = lineEl.querySelector('.source-stock-hint');
if (sHint) sHint.style.display = 'none';
const dHint = lineEl.querySelector('.destination-stock-hint');
if (dHint) dHint.style.display = 'none';
}
function initProductAutocomplete(lineEl) {
const searchInput = lineEl.querySelector('.product-search-input');
const resultsBox = lineEl.querySelector('.product-results');
@ -359,9 +480,13 @@ function initProductAutocomplete(lineEl) {
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
const q = this.value.trim();
hiddenInput.value = '';
if (hiddenInput.value) {
hiddenInput.value = '';
delete lineEl.dataset.authorizedLocations;
resetLocationSelects(lineEl);
resetSerializedSection(lineEl);
}
searchInput.classList.remove('is-invalid');
resetSerializedSection(lineEl);
if (q.length < 2) { resultsBox.style.display = 'none'; return; }
searchTimeout = setTimeout(() => {
fetch(`${PRODUCT_AUTOCOMPLETE_URL}?q=${encodeURIComponent(q)}`)
@ -381,9 +506,11 @@ function initProductAutocomplete(lineEl) {
const selectProduct = (e) => {
e.preventDefault();
hiddenInput.value = p.id;
lineEl.dataset.authorizedLocations = JSON.stringify(p.authorized_location_ids || []);
searchInput.value = p.text + (p.code ? ' (' + p.code + ')' : '');
searchInput.classList.remove('is-invalid');
resultsBox.style.display = 'none';
fetchLocationsForProduct(lineEl, p.id);
if (p.is_serialized) {
fetchSerializedItems(lineEl, p.id);
} else {
@ -496,24 +623,6 @@ document.getElementById('batch-form').addEventListener('submit', function (e) {
return;
}
}
if (MOVEMENT_TYPE === 'transfer' || MOVEMENT_TYPE === 'out') {
const srcEl = document.getElementById('global_source_location_id');
if (srcEl && !srcEl.value) {
e.preventDefault();
srcEl.classList.add('is-invalid');
alert("{% translate 'Emplacement source obligatoire.' %}");
return;
}
}
if (MOVEMENT_TYPE === 'transfer' || MOVEMENT_TYPE === 'in') {
const dstEl = document.getElementById('global_dest_location_id');
if (dstEl && !dstEl.value) {
e.preventDefault();
dstEl.classList.add('is-invalid');
alert("{% translate 'Emplacement destination obligatoire.' %}");
return;
}
}
const lines = document.querySelectorAll('#lines-container .product-line');
if (lines.length === 0) {
e.preventDefault();
@ -521,16 +630,57 @@ document.getElementById('batch-form').addEventListener('submit', function (e) {
return;
}
let invalid = false;
let missingLocation = false;
let transferSameLoc = false;
lines.forEach(line => {
const pid = line.querySelector('.product-id-input').value;
const searchInput = line.querySelector('.product-search-input');
if (!pid) {
line.querySelector('.product-search-input').classList.add('is-invalid');
searchInput.classList.add('is-invalid');
invalid = true;
}
const srcSelect = line.querySelector('.source-location-select');
const dstSelect = line.querySelector('.destination-location-select');
if (MOVEMENT_TYPE === 'out' || MOVEMENT_TYPE === 'transfer') {
if (srcSelect && !srcSelect.value) {
srcSelect.classList.add('is-invalid');
missingLocation = true;
} else if (srcSelect) {
srcSelect.classList.remove('is-invalid');
}
}
if (MOVEMENT_TYPE === 'in' || MOVEMENT_TYPE === 'transfer') {
if (dstSelect && !dstSelect.value) {
dstSelect.classList.add('is-invalid');
missingLocation = true;
} else if (dstSelect) {
dstSelect.classList.remove('is-invalid');
}
}
if (MOVEMENT_TYPE === 'transfer' && srcSelect && dstSelect && srcSelect.value && dstSelect.value) {
if (srcSelect.value === dstSelect.value) {
srcSelect.classList.add('is-invalid');
dstSelect.classList.add('is-invalid');
transferSameLoc = true;
}
}
});
if (invalid) {
e.preventDefault();
alert("{% translate 'Veuillez sélectionner un produit dans la liste pour chaque ligne.' %}");
return;
}
if (missingLocation) {
e.preventDefault();
alert("{% translate 'Veuillez indiquer un emplacement pour chaque produit.' %}");
return;
}
if (transferSameLoc) {
e.preventDefault();
alert("{% translate 'Les emplacements source et destination doivent être différents pour chaque produit.' %}");
return;
}
});
@ -802,15 +952,6 @@ if (bpLoadBtn) {
return;
}
// 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'));
}
}
// Séparer les articles à réintégrer (non utilisés, qty > 0) et les articles marqués comme utilisés
const itemsToReturn = data.items.filter(item => item.quantity > 0);
const usedItems = data.items.filter(item => data.has_intervention && item.is_used);
@ -830,6 +971,9 @@ if (bpLoadBtn) {
if (searchInput) searchInput.value = item.product_name + (item.product_code ? ' (' + item.product_code + ')' : '');
if (hiddenInput) hiddenInput.value = item.product_id;
if (qtyInput) qtyInput.value = item.quantity;
if (typeof fetchLocationsForProduct === 'function') {
fetchLocationsForProduct(lineEl, item.product_id, data.default_location_id || null);
}
});
// Construction du message d'information bpInfo
@ -880,6 +1024,9 @@ if (bpLoadBtn) {
if (searchInput) searchInput.value = prodName + (prodCode ? ' (' + prodCode + ')' : '');
if (hiddenInput) hiddenInput.value = prodId;
if (qtyInput) qtyInput.value = prodQty;
if (typeof fetchLocationsForProduct === 'function') {
fetchLocationsForProduct(lineEl, prodId, data.default_location_id || null);
}
this.disabled = true;
this.classList.add('opacity-50');
});
@ -894,41 +1041,11 @@ if (bpLoadBtn) {
}
{% endif %}
// --- Restriction du sous-type "Correction" et gestion de la date de retour des prêts ---
// --- Gestion de la date de retour des prêts ---
document.addEventListener('DOMContentLoaded', function() {
const subtypeSelect = document.getElementById('movement_subtype');
if (!subtypeSelect) return;
// elements location existants
const sourceLocSelect = document.getElementById('global_source_location_id');
const destLocSelect = document.getElementById('global_dest_location_id');
const activeLocSelect = sourceLocSelect || destLocSelect;
function updateCorrectionVisibility() {
if (!activeLocSelect) return;
const selectedOption = activeLocSelect.options[activeLocSelect.selectedIndex];
const canAdminister = selectedOption && selectedOption.getAttribute('data-can-administer') === 'true';
const correctionOption = subtypeSelect.querySelector('option[value="correction"]');
if (correctionOption) {
if (canAdminister) {
correctionOption.style.display = '';
correctionOption.disabled = false;
} else {
correctionOption.style.display = 'none';
correctionOption.disabled = true;
if (subtypeSelect.value === 'correction') {
subtypeSelect.value = '';
}
}
}
}
if (activeLocSelect) {
activeLocSelect.addEventListener('change', updateCorrectionVisibility);
updateCorrectionVisibility();
}
// Gestion de l'affichage de la date de retour attendue pour le sous-type Emprunt (loan)
const returnDateWrapper = document.getElementById('estimated_return_date_wrapper');
const returnDateInput = document.getElementById('estimated_return_date');

View file

@ -63,15 +63,15 @@
<div class="row g-3">
{% if movement_type == 'in' %}
<!-- Entrée : Source = Personne/Entreprise | Destination = Emplacement -->
<div class="col-md-6">
<div class="card border-secondary h-100">
<!-- Entrée : Source = Personne / Entreprise -->
<div class="col-12">
<div class="card border-secondary">
<div class="card-header bg-secondary text-white py-2 fw-semibold small">
<i class="bi bi-person-fill me-1"></i>{% translate "Source" %}
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-12">
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-person me-1"></i>{% translate "Personne interne" %}</label>
<select name="person_id" id="person_id" class="form-select">
<option value="">-- {% translate "Aucune" %} --</option>
@ -81,7 +81,7 @@
</select>
<div class="form-text">{% translate "Optionnel" %}</div>
</div>
<div class="col-12">
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-building me-1"></i>{% translate "Entreprise / Organisation" %}</label>
<input type="text" name="company_name" id="company_name" list="companies-list" class="form-control" autocomplete="off" placeholder="{% translate 'Sélectionnez ou saisissez...' %}" onchange="onCompanyInputChange()" oninput="onCompanyInputChange()">
<datalist id="companies-list">
@ -95,50 +95,16 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-success h-100">
<div class="card-header bg-success text-white py-2 fw-semibold small">
<i class="bi bi-box-arrow-in-down-right me-1"></i>{% translate "Destination" %}
</div>
<div class="card-body">
<label class="form-label fw-semibold">{% translate "Emplacement" %} <span class="text-danger">*</span></label>
<select name="global_destination_location_id" id="global_dest_location_id" class="form-select" required>
<option value="">-- {% translate "Emplacement destination" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
<div class="form-text text-danger">{% translate "Obligatoire" %}</div>
</div>
</div>
</div>
{% elif movement_type == 'out' %}
<!-- Sortie : Source = Emplacement | Destination = Personne/Entreprise -->
<div class="col-md-6">
<div class="card border-danger h-100">
<div class="card-header bg-danger text-white py-2 fw-semibold small">
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Source" %}
</div>
<div class="card-body">
<label class="form-label fw-semibold">{% translate "Emplacement" %} <span class="text-danger">*</span></label>
<select name="global_source_location_id" id="global_source_location_id" class="form-select" required>
<option value="">-- {% translate "Emplacement source" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
<div class="form-text text-danger">{% translate "Obligatoire" %}</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-secondary h-100">
<!-- Sortie : Destination = Personne / Entreprise -->
<div class="col-12">
<div class="card border-secondary">
<div class="card-header bg-secondary text-white py-2 fw-semibold small">
<i class="bi bi-person-fill me-1"></i>{% translate "Destination" %}
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-12">
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-person me-1"></i>{% translate "Personne interne" %}</label>
<select name="person_id" id="person_id" class="form-select">
<option value="">-- {% translate "Aucune" %} --</option>
@ -148,7 +114,7 @@
</select>
<div class="form-text">{% translate "Optionnel" %}</div>
</div>
<div class="col-12">
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-building me-1"></i>{% translate "Entreprise / Organisation" %}</label>
<input type="text" name="company_name" id="company_name" list="companies-list" class="form-control" autocomplete="off" placeholder="{% translate 'Sélectionnez ou saisissez...' %}" onchange="onCompanyInputChange()" oninput="onCompanyInputChange()">
<datalist id="companies-list">
@ -162,28 +128,6 @@
</div>
</div>
</div>
{% else %}
<!-- Transfert : Source = Emplacement | Destination = Emplacement -->
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-box-arrow-up-right text-danger me-1"></i>{% translate "Source" %} <span class="text-danger">*</span></label>
<select name="global_source_location_id" id="global_source_location_id" class="form-select" required>
<option value="">-- {% translate "Emplacement source" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
<div class="form-text text-danger">{% translate "Obligatoire" %}</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold"><i class="bi bi-box-arrow-in-down-right text-success me-1"></i>{% translate "Destination" %} <span class="text-danger">*</span></label>
<select name="global_destination_location_id" id="global_dest_location_id" class="form-select" required>
<option value="">-- {% translate "Emplacement destination" %} --</option>
{% for loc in locations %}
<option value="{{ loc.id }}" data-can-administer="{% if loc.can_administer %}true{% else %}false{% endif %}">{{ loc.warehouse.name }} — {{ loc.name }}</option>
{% endfor %}
</select>
<div class="form-text text-danger">{% translate "Obligatoire" %}</div>
</div>
{% endif %}
{% if movement_type != 'transfer' %}
@ -196,12 +140,12 @@
<option value="purchase">{% translate "Achat" %}</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>
<option value="correction" {% if not can_administer_any %}style="display: none;" disabled{% endif %}>{% translate "Correction (Administrateur uniquement)" %}</option>
{% elif movement_type == 'out' %}
<option value="epi">{% translate "EPI" %}</option>
<option value="loan">{% translate "Emprunt" %}</option>
<option value="worksite">{% translate "Chantier" %}</option>
<option value="correction" style="display: none;">{% translate "Correction (Administrateur uniquement)" %}</option>
<option value="correction" {% if not can_administer_any %}style="display: none;" disabled{% endif %}>{% translate "Correction (Administrateur uniquement)" %}</option>
{% endif %}
</select>
<div class="form-text text-danger">{% translate "Obligatoire" %}</div>
@ -324,10 +268,10 @@
<i class="bi bi-trash3"></i>
</button>
</div>
<div class="row g-2">
<div class="row g-2 align-items-start">
<!-- Produit (autocomplete) -->
<div class="col-md-8">
<div class="{% if movement_type == 'transfer' %}col-md-4{% else %}col-md-5{% endif %}">
<label class="form-label small fw-semibold">{% translate "Produit" %} <span class="text-danger">*</span></label>
<div class="position-relative">
<input type="text"
@ -339,15 +283,50 @@
<input type="hidden" name="product_id" class="product-id-input">
</div>
{% if movement_type == 'in' %}
<!-- Emplacement destination -->
<div class="col-md-4">
<label class="form-label small fw-semibold">{% translate "Emplacement destination" %} <span class="text-danger">*</span></label>
<select name="destination_location_id" class="form-select destination-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text destination-stock-hint text-muted small" style="display:none;"></div>
</div>
<input type="hidden" name="source_location_id" value="">
{% elif movement_type == 'out' %}
<!-- Emplacement source -->
<div class="col-md-4">
<label class="form-label small fw-semibold">{% translate "Emplacement source" %} <span class="text-danger">*</span></label>
<select name="source_location_id" class="form-select source-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text source-stock-hint text-muted small" style="display:none;"></div>
</div>
<input type="hidden" name="destination_location_id" value="">
{% else %}
<!-- Transfert : Emplacement source et Emplacement destination -->
<div class="col-md-3">
<label class="form-label small fw-semibold">{% translate "Emplacement source" %} <span class="text-danger">*</span></label>
<select name="source_location_id" class="form-select source-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text source-stock-hint text-muted small" style="display:none;"></div>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">{% translate "Emplacement destination" %} <span class="text-danger">*</span></label>
<select name="destination_location_id" class="form-select destination-location-select" required disabled>
<option value="">-- {% translate "Sélectionnez d'abord un produit" %} --</option>
</select>
<div class="form-text destination-stock-hint text-muted small" style="display:none;"></div>
</div>
{% endif %}
<!-- Quantité -->
<div class="col-md-2">
<div class="{% if movement_type == 'transfer' %}col-md-2{% else %}col-md-3{% endif %}">
<label class="form-label small fw-semibold">{% translate "Quantité" %} <span class="text-danger">*</span></label>
<input type="number" name="quantity" class="form-control" min="1" value="1" required>
</div>
<input type="hidden" name="source_location_id" value="">
<input type="hidden" name="destination_location_id" value="">
</div>
<!-- Articles tracés (produit sérialisé) -->
@ -462,14 +441,20 @@ function escHtml(str) {
}
// ── Emplacements dynamiques par produit ──────────────────────
function fetchLocationsForProduct(lineEl, productId) {
function fetchLocationsForProduct(lineEl, productId, preferredLocationId = null) {
const url = PRODUCT_LOCATIONS_API_BASE.replace('{id}', productId);
const sourceSelect = lineEl.querySelector('.source-location-select');
const destSelect = lineEl.querySelector('.destination-location-select');
const sourceHint = lineEl.querySelector('.source-stock-hint');
const destHint = lineEl.querySelector('.destination-stock-hint');
// Indicateur de chargement
[sourceSelect, destSelect].forEach(sel => { if (sel) sel.disabled = true; });
[sourceSelect, destSelect].forEach(sel => {
if (sel) {
sel.disabled = true;
sel.innerHTML = '<option value="">-- {% translate "Chargement des emplacements…" %} --</option>';
}
});
fetch(url)
.then(r => r.json())
@ -477,64 +462,115 @@ function fetchLocationsForProduct(lineEl, productId) {
const locations = data.locations || [];
if (sourceSelect) {
const prevVal = sourceSelect.value;
sourceSelect.innerHTML = '<option value="">-- {% translate "Emplacement" %} --</option>';
const prevVal = preferredLocationId || sourceSelect.dataset.selectedVal || '';
sourceSelect.innerHTML = '';
// Emplacements avec stock en priorité pour la source
const withStock = locations.filter(loc => loc.quantity > 0);
withStock.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name} (qté\u00a0: ${loc.quantity})`;
if (String(loc.id) === prevVal) opt.selected = true;
sourceSelect.appendChild(opt);
});
// Auto-sélection si un seul emplacement disponible
if (withStock.length === 1 && !prevVal) {
sourceSelect.value = withStock[0].id;
const candidates = withStock.length > 0 ? withStock : locations;
if (!candidates.length) {
sourceSelect.innerHTML = '<option value="">-- {% translate "Aucun emplacement disponible" %} --</option>';
sourceSelect.disabled = true;
} else {
candidates.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name} (${loc.quantity} {% translate "en stock" %})`;
if (String(loc.id) === String(prevVal)) opt.selected = true;
sourceSelect.appendChild(opt);
});
// Si plusieurs emplacements sont possibles, le premier est choisi si aucun n'est déjà sélectionné
if (!sourceSelect.value && candidates.length > 0) {
sourceSelect.value = candidates[0].id;
}
sourceSelect.disabled = false;
}
sourceSelect.disabled = false;
if (sourceHint) {
const total = withStock.length;
sourceHint.textContent = total > 0
? `{% translate "Emplacements avec stock disponible" %}\u00a0: ${total}`
: '{% translate "Aucun emplacement ne dispose de stock pour ce produit." %}';
if (withStock.length > 0) {
sourceHint.textContent = `${withStock.length} {% translate "emplacement(s) avec stock" %}`;
sourceHint.className = 'form-text source-stock-hint text-muted small';
} else {
sourceHint.textContent = '{% translate "Aucun stock dans les emplacements autorisés." %}';
sourceHint.className = 'form-text source-stock-hint text-danger small';
}
sourceHint.style.display = '';
sourceHint.className = 'form-text source-stock-hint ' + (total > 0 ? 'text-muted' : 'text-danger');
}
}
if (destSelect) {
const prevVal = destSelect.value;
destSelect.innerHTML = '<option value="">-- {% translate "Emplacement" %} --</option>';
locations.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
const qteLabel = loc.quantity > 0 ? ` (qté\u00a0: ${loc.quantity})` : ' ({% translate "vide" %})';
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name}${qteLabel}`;
if (String(loc.id) === prevVal) opt.selected = true;
destSelect.appendChild(opt);
});
// Auto-sélection si un seul emplacement disponible
if (locations.length === 1 && !prevVal) {
destSelect.value = locations[0].id;
const prevVal = preferredLocationId || destSelect.dataset.selectedVal || '';
destSelect.innerHTML = '';
if (!locations.length) {
destSelect.innerHTML = '<option value="">-- {% translate "Aucun emplacement disponible" %} --</option>';
destSelect.disabled = true;
} else {
locations.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.id;
const qteLabel = loc.quantity > 0 ? ` (${loc.quantity} {% translate "en stock" %})` : '';
opt.textContent = `${loc.warehouse_name} \u2014 ${loc.name}${qteLabel}`;
if (String(loc.id) === String(prevVal)) opt.selected = true;
destSelect.appendChild(opt);
});
// Si pas encore sélectionné :
if (!destSelect.value) {
if (MOVEMENT_TYPE === 'transfer' && sourceSelect && sourceSelect.value) {
// En transfert, choisir par défaut un emplacement différent de la source si possible
const diffLoc = locations.find(loc => String(loc.id) !== String(sourceSelect.value));
destSelect.value = diffLoc ? diffLoc.id : locations[0].id;
} else if (locations.length > 0) {
// Le premier est choisi
destSelect.value = locations[0].id;
}
}
destSelect.disabled = false;
}
destSelect.disabled = false;
if (destHint) {
destHint.textContent = `${locations.length} {% translate "emplacement(s) autorisé(s)" %}`;
destHint.className = 'form-text destination-stock-hint text-muted small';
destHint.style.display = '';
}
}
// En transfert, si la source change et devient identique à la destination, réajuster
if (sourceSelect && destSelect && MOVEMENT_TYPE === 'transfer') {
sourceSelect.onchange = function () {
if (sourceSelect.value && destSelect.value && sourceSelect.value === destSelect.value) {
const diffLoc = locations.find(loc => String(loc.id) !== String(sourceSelect.value));
if (diffLoc) destSelect.value = diffLoc.id;
}
};
}
})
.catch(() => {
[sourceSelect, destSelect].forEach(sel => { if (sel) sel.disabled = false; });
[sourceSelect, destSelect].forEach(sel => {
if (sel) {
sel.disabled = false;
sel.innerHTML = '<option value="">-- {% translate "Erreur de chargement" %} --</option>';
}
});
});
}
function resetLocationSelects(lineEl) {
const tmpl = document.getElementById('line-template');
const tmplSource = tmpl.content.querySelector('.source-location-select');
const tmplDest = tmpl.content.querySelector('.destination-location-select');
const lineSource = lineEl.querySelector('.source-location-select');
const lineDest = lineEl.querySelector('.destination-location-select');
if (tmplSource && lineSource) { lineSource.innerHTML = tmplSource.innerHTML; lineSource.disabled = false; }
if (tmplDest && lineDest) { lineDest.innerHTML = tmplDest.innerHTML; lineDest.disabled = false; }
const hint = lineEl.querySelector('.source-stock-hint');
if (hint) hint.style.display = 'none';
if (lineSource) {
lineSource.innerHTML = '<option value="">-- {% translate "Sélectionnez d\'abord un produit" %} --</option>';
lineSource.disabled = true;
}
if (lineDest) {
lineDest.innerHTML = '<option value="">-- {% translate "Sélectionnez d\'abord un produit" %} --</option>';
lineDest.disabled = true;
}
const sHint = lineEl.querySelector('.source-stock-hint');
if (sHint) sHint.style.display = 'none';
const dHint = lineEl.querySelector('.destination-stock-hint');
if (dHint) dHint.style.display = 'none';
}
function resetSerializedSection(lineEl) {
@ -800,24 +836,6 @@ document.getElementById('batch-form').addEventListener('submit', function (e) {
return;
}
}
if (MOVEMENT_TYPE === 'transfer' || MOVEMENT_TYPE === 'out') {
const srcEl = document.getElementById('global_source_location_id');
if (srcEl && !srcEl.value) {
e.preventDefault();
srcEl.classList.add('is-invalid');
alert("{% translate 'Emplacement source obligatoire.' %}");
return;
}
}
if (MOVEMENT_TYPE === 'transfer' || MOVEMENT_TYPE === 'in') {
const dstEl = document.getElementById('global_dest_location_id');
if (dstEl && !dstEl.value) {
e.preventDefault();
dstEl.classList.add('is-invalid');
alert("{% translate 'Emplacement destination obligatoire.' %}");
return;
}
}
const lines = document.querySelectorAll('#lines-container .product-line');
if (lines.length === 0) {
e.preventDefault();
@ -825,56 +843,57 @@ document.getElementById('batch-form').addEventListener('submit', function (e) {
return;
}
let invalid = false;
let missingLocation = false;
let transferSameLoc = false;
lines.forEach((line) => {
const pid = line.querySelector('.product-id-input').value;
const searchInput = line.querySelector('.product-search-input');
if (!pid) {
line.querySelector('.product-search-input').classList.add('is-invalid');
searchInput.classList.add('is-invalid');
invalid = true;
}
const srcSelect = line.querySelector('.source-location-select');
const dstSelect = line.querySelector('.destination-location-select');
if (MOVEMENT_TYPE === 'out' || MOVEMENT_TYPE === 'transfer') {
if (srcSelect && !srcSelect.value) {
srcSelect.classList.add('is-invalid');
missingLocation = true;
} else if (srcSelect) {
srcSelect.classList.remove('is-invalid');
}
}
if (MOVEMENT_TYPE === 'in' || MOVEMENT_TYPE === 'transfer') {
if (dstSelect && !dstSelect.value) {
dstSelect.classList.add('is-invalid');
missingLocation = true;
} else if (dstSelect) {
dstSelect.classList.remove('is-invalid');
}
}
if (MOVEMENT_TYPE === 'transfer' && srcSelect && dstSelect && srcSelect.value && dstSelect.value) {
if (srcSelect.value === dstSelect.value) {
srcSelect.classList.add('is-invalid');
dstSelect.classList.add('is-invalid');
transferSameLoc = true;
}
}
});
if (invalid) {
e.preventDefault();
alert("{% translate 'Veuillez sélectionner un produit dans la liste pour chaque ligne.' %}");
return;
}
// Validation des emplacements autorisés par produit (frontend check)
const globalSrcVal = document.getElementById('global_source_location_id') ? document.getElementById('global_source_location_id').value : null;
const globalDstVal = document.getElementById('global_dest_location_id') ? document.getElementById('global_dest_location_id').value : null;
let locationValidationError = false;
lines.forEach((line, index) => {
const authLocsStr = line.dataset.authorizedLocations;
if (authLocsStr) {
const authLocs = JSON.parse(authLocsStr);
if (authLocs && authLocs.length > 0) {
const prodName = line.querySelector('.product-search-input').value;
if (MOVEMENT_TYPE === 'transfer') {
if (globalSrcVal && !authLocs.includes(parseInt(globalSrcVal))) {
alert(`{% translate "Ligne" %} ${index + 1} (${prodName}) : {% translate "L'emplacement source sélectionné n'est pas autorisé pour ce produit." %}`);
locationValidationError = true;
}
if (globalDstVal && !authLocs.includes(parseInt(globalDstVal))) {
alert(`{% translate "Ligne" %} ${index + 1} (${prodName}) : {% translate "L'emplacement de destination sélectionné n'est pas autorisé pour ce produit." %}`);
locationValidationError = true;
}
} else if (MOVEMENT_TYPE === 'in') {
if (globalDstVal && !authLocs.includes(parseInt(globalDstVal))) {
alert(`{% translate "Ligne" %} ${index + 1} (${prodName}) : {% translate "L'emplacement de destination sélectionné n'est pas autorisé pour ce produit." %}`);
locationValidationError = true;
}
} else if (MOVEMENT_TYPE === 'out') {
if (globalSrcVal && !authLocs.includes(parseInt(globalSrcVal))) {
alert(`{% translate "Ligne" %} ${index + 1} (${prodName}) : {% translate "L'emplacement source sélectionné n'est pas autorisé pour ce produit." %}`);
locationValidationError = true;
}
}
}
}
});
if (locationValidationError) {
if (missingLocation) {
e.preventDefault();
alert("{% translate 'Veuillez indiquer un emplacement pour chaque produit.' %}");
return;
}
if (transferSameLoc) {
e.preventDefault();
alert("{% translate 'Les emplacements source et destination doivent être différents pour chaque produit.' %}");
return;
}
});
@ -991,19 +1010,6 @@ if (bpLoadBtn) {
return;
}
// 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'));
}
}
// Séparer les articles à réintégrer (non utilisés, qty > 0) et les articles marqués comme utilisés
const itemsToReturn = data.items.filter(item => item.quantity > 0);
const usedItems = data.items.filter(item => data.has_intervention && item.is_used);
// Effacer les lignes existantes
document.getElementById('lines-container').innerHTML = '';
updateNoLinesPlaceholder();
@ -1019,8 +1025,8 @@ if (bpLoadBtn) {
if (searchInput) searchInput.value = item.product_name + (item.product_code ? ' (' + item.product_code + ')' : '');
if (hiddenInput) hiddenInput.value = item.product_id;
if (qtyInput) qtyInput.value = item.quantity;
// Charger les emplacements pour ce produit
fetchLocationsForProduct(lineEl, item.product_id);
// Charger les emplacements pour ce produit avec l'emplacement de retour par défaut
fetchLocationsForProduct(lineEl, item.product_id, data.default_location_id);
});
// Construction du message d'information bpInfo
@ -1088,41 +1094,11 @@ if (bpLoadBtn) {
}
{% endif %}
// --- Restriction du sous-type "Correction" et gestion de la date de retour des prêts ---
// --- Gestion de la date de retour des prêts ---
document.addEventListener('DOMContentLoaded', function() {
const subtypeSelect = document.getElementById('movement_subtype');
if (!subtypeSelect) return;
// elements location existants
const sourceLocSelect = document.getElementById('global_source_location_id');
const destLocSelect = document.getElementById('global_dest_location_id');
const activeLocSelect = sourceLocSelect || destLocSelect;
function updateCorrectionVisibility() {
if (!activeLocSelect) return;
const selectedOption = activeLocSelect.options[activeLocSelect.selectedIndex];
const canAdminister = selectedOption && selectedOption.getAttribute('data-can-administer') === 'true';
const correctionOption = subtypeSelect.querySelector('option[value="correction"]');
if (correctionOption) {
if (canAdminister) {
correctionOption.style.display = '';
correctionOption.disabled = false;
} else {
correctionOption.style.display = 'none';
correctionOption.disabled = true;
if (subtypeSelect.value === 'correction') {
subtypeSelect.value = '';
}
}
}
}
if (activeLocSelect) {
activeLocSelect.addEventListener('change', updateCorrectionVisibility);
updateCorrectionVisibility();
}
// Gestion de l'affichage de la date de retour attendue pour le sous-type Emprunt (loan)
const returnDateWrapper = document.getElementById('estimated_return_date_wrapper');
const returnDateInput = document.getElementById('estimated_return_date');

View file

@ -2121,6 +2121,148 @@ class InventoryValidationNotificationTests(TestCase):
self.assertEqual(get_unread_count(self.val2), 1)
class StockMovementMultiLocationBatchTests(TestCase):
def setUp(self):
from django.contrib.auth.models import User
from common.models import UserConfig
from .models import Warehouse, WarehouseLocation, Product, Stock
self.user = User.objects.create_superuser(username="admin_multiloc", password="password")
UserConfig.objects.create(user=self.user)
self.client.force_login(self.user)
self.wh1 = Warehouse.objects.create(code="WH-M1", name="Entrepôt 1")
self.loc1 = WarehouseLocation.objects.create(warehouse=self.wh1, name="Rayon A", code="LOC-M1")
self.wh2 = Warehouse.objects.create(code="WH-M2", name="Entrepôt 2")
self.loc2 = WarehouseLocation.objects.create(warehouse=self.wh2, name="Rayon B", code="LOC-M2")
self.wh3 = Warehouse.objects.create(code="WH-M3", name="Entrepôt 3")
self.loc3 = WarehouseLocation.objects.create(warehouse=self.wh3, name="Rayon C", code="LOC-M3")
self.prod1 = Product.objects.create(code="P-ML1", name="Produit 1", sku="SKU-ML1")
self.prod2 = Product.objects.create(code="P-ML2", name="Produit 2", sku="SKU-ML2")
def test_new_stock_entry_multi_location(self):
from .models import StockMovementBatch, Stock
response = self.client.post(
reverse("stock:new_stock_entry"),
{
"movement_subtype": "purchase",
"product_id": [self.prod1.id, self.prod2.id],
"quantity": [10, 5],
"destination_location_id": [self.loc1.id, self.loc2.id],
}
)
self.assertEqual(response.status_code, 302)
batch = StockMovementBatch.objects.filter(movement_type="in").latest("pk")
movements = list(batch.movements.all().order_by("pk"))
self.assertEqual(len(movements), 2)
self.assertEqual(movements[0].product, self.prod1)
self.assertEqual(movements[0].destination, self.loc1)
self.assertEqual(movements[0].quantity, 10)
self.assertEqual(movements[1].product, self.prod2)
self.assertEqual(movements[1].destination, self.loc2)
self.assertEqual(movements[1].quantity, 5)
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 10)
self.assertEqual(Stock.objects.get(product=self.prod2, location=self.loc2).quantity, 5)
def test_new_stock_exit_multi_location(self):
from .models import StockMovementBatch, Stock
Stock.objects.create(product=self.prod1, location=self.loc1, quantity=15)
Stock.objects.create(product=self.prod2, location=self.loc2, quantity=8)
response = self.client.post(
reverse("stock:new_stock_exit"),
{
"movement_subtype": "worksite",
"product_id": [self.prod1.id, self.prod2.id],
"quantity": [4, 3],
"source_location_id": [self.loc1.id, self.loc2.id],
}
)
self.assertEqual(response.status_code, 302)
batch = StockMovementBatch.objects.filter(movement_type="out").latest("pk")
movements = list(batch.movements.all().order_by("pk"))
self.assertEqual(len(movements), 2)
self.assertEqual(movements[0].product, self.prod1)
self.assertEqual(movements[0].source, self.loc1)
self.assertEqual(movements[0].quantity, 4)
self.assertEqual(movements[1].product, self.prod2)
self.assertEqual(movements[1].source, self.loc2)
self.assertEqual(movements[1].quantity, 3)
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 11)
self.assertEqual(Stock.objects.get(product=self.prod2, location=self.loc2).quantity, 5)
def test_new_stock_transfer_multi_location(self):
from .models import StockMovementBatch, Stock
Stock.objects.create(product=self.prod1, location=self.loc1, quantity=20)
Stock.objects.create(product=self.prod2, location=self.loc2, quantity=10)
response = self.client.post(
reverse("stock:new_stock_transfer"),
{
"product_id": [self.prod1.id, self.prod2.id],
"quantity": [5, 2],
"source_location_id": [self.loc1.id, self.loc2.id],
"destination_location_id": [self.loc2.id, self.loc3.id],
}
)
self.assertEqual(response.status_code, 302)
batch = StockMovementBatch.objects.filter(movement_type="transfer").latest("pk")
movements = list(batch.movements.all().order_by("pk"))
self.assertEqual(len(movements), 2)
self.assertEqual(movements[0].product, self.prod1)
self.assertEqual(movements[0].source, self.loc1)
self.assertEqual(movements[0].destination, self.loc2)
self.assertEqual(movements[0].quantity, 5)
self.assertEqual(movements[1].product, self.prod2)
self.assertEqual(movements[1].source, self.loc2)
self.assertEqual(movements[1].destination, self.loc3)
self.assertEqual(movements[1].quantity, 2)
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc1).quantity, 15)
self.assertEqual(Stock.objects.get(product=self.prod1, location=self.loc2).quantity, 5)
self.assertEqual(Stock.objects.get(product=self.prod2, location=self.loc2).quantity, 8)
self.assertEqual(Stock.objects.get(product=self.prod2, location=self.loc3).quantity, 2)
def test_product_locations_api(self):
from .models import Stock
Stock.objects.create(product=self.prod1, location=self.loc2, quantity=12)
self.prod1.default_location = self.loc1
self.prod1.save()
response = self.client.get(reverse("stock:product_locations_api", args=[self.prod1.id]))
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
locations = data["locations"]
# Loc2 has stock=12, so it should be prioritized first
self.assertEqual(locations[0]["id"], self.loc2.id)
self.assertEqual(locations[0]["quantity"], 12)
# Loc1 has default=True, stock=0, so it should have is_default=True
loc1_data = next(l for l in locations if l["id"] == self.loc1.id)
self.assertTrue(loc1_data["is_default"])

View file

@ -497,10 +497,15 @@ def product_locations_api(request, product_id):
'name': loc.name,
'warehouse_name': loc.warehouse.name,
'quantity': stock_map.get(loc.id, 0),
'is_default': loc.is_default,
}
for loc in locations
]
# Mettre en premier les emplacements disposant de stock pour ce produit,
# puis les emplacements par défaut, puis par nom d'entrepôt et de lieu
data.sort(key=lambda x: (-x['quantity'], not x['is_default'], x['warehouse_name'], x['name']))
return JsonResponse({'locations': data})
@ -1006,64 +1011,27 @@ def _new_stock_batch_view(request, movement_type,
quantities = request.POST.getlist('quantity')
global_src_id = request.POST.get('global_source_location_id') or None
global_dst_id = request.POST.get('global_destination_location_id') or None
posted_src_ids = request.POST.getlist('source_location_id')
posted_dst_ids = request.POST.getlist('destination_location_id')
# Si des emplacements par ligne sont soumis, on les utilise en priorité
if movement_type == 'transfer':
source_ids = [global_src_id] * len(product_ids)
dest_ids = [global_dst_id] * len(product_ids)
source_ids = posted_src_ids if any(posted_src_ids) else [global_src_id] * len(product_ids)
dest_ids = posted_dst_ids if any(posted_dst_ids) else [global_dst_id] * len(product_ids)
elif movement_type == 'out':
source_ids = [global_src_id] * len(product_ids)
source_ids = posted_src_ids if any(posted_src_ids) else [global_src_id] * len(product_ids)
dest_ids = [None] * len(product_ids)
elif movement_type == 'in':
source_ids = [None] * len(product_ids)
dest_ids = [global_dst_id] * len(product_ids)
dest_ids = posted_dst_ids if any(posted_dst_ids) else [global_dst_id] * len(product_ids)
else:
source_ids = request.POST.getlist('source_location_id')
dest_ids = request.POST.getlist('destination_location_id')
source_ids = posted_src_ids
dest_ids = posted_dst_ids
serialized_ids_list = request.POST.getlist('serialized_item_ids')
serialized_new_serials_list = request.POST.getlist('serialized_new_serials')
has_subtype_permission = True
if movement_subtype == 'correction':
selected_loc_id = global_dst_id if movement_type == 'in' else global_src_id
if selected_loc_id:
try:
selected_loc = WarehouseLocation.objects.get(id=selected_loc_id)
from stock.permissions import user_can_administer_warehouse
if not user_can_administer_warehouse(request.user, selected_loc.warehouse):
has_subtype_permission = False
except WarehouseLocation.DoesNotExist:
has_subtype_permission = False
else:
has_subtype_permission = False
from stock.permissions import user_can_manage_warehouse
has_manage_permission = True
if global_src_id:
try:
src_loc = WarehouseLocation.objects.get(id=global_src_id)
if not user_can_manage_warehouse(request.user, src_loc.warehouse):
has_manage_permission = False
except WarehouseLocation.DoesNotExist:
has_manage_permission = False
if global_dst_id:
try:
dst_loc = WarehouseLocation.objects.get(id=global_dst_id)
if not user_can_manage_warehouse(request.user, dst_loc.warehouse):
has_manage_permission = False
except WarehouseLocation.DoesNotExist:
has_manage_permission = False
if not movement_subtype and movement_type != 'transfer':
messages.error(request, _("Le sous-type est obligatoire."))
elif movement_subtype == 'correction' and not has_subtype_permission:
messages.error(request, _("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt."))
elif not has_manage_permission:
messages.error(request, _("Vous n'avez pas la permission de gérer l'entrepôt impliqué dans ce mouvement."))
elif movement_type == 'in' and not global_dst_id:
messages.error(request, _("L'emplacement destination est obligatoire pour une entrée."))
elif movement_type == 'out' and not global_src_id:
messages.error(request, _("L'emplacement source est obligatoire pour une sortie."))
elif movement_type == 'transfer' and (not global_src_id or not global_dst_id):
messages.error(request, _("Les emplacements source et destination sont obligatoires pour un transfert."))
elif not product_ids:
messages.error(request, _("Veuillez ajouter au moins une ligne produit."))
else:
@ -1090,9 +1058,26 @@ def _new_stock_batch_view(request, movement_type,
if movement_type == 'out' and not src:
errors.append(_("Ligne %(n)s : emplacement source requis pour une sortie.") % {'n': i + 1})
continue
if movement_type == 'transfer' and (not src or not dst):
errors.append(_("Ligne %(n)s : emplacements source et destination requis pour un transfert.") % {'n': i + 1})
if movement_type == 'transfer':
if not src or not dst:
errors.append(_("Ligne %(n)s : emplacements source et destination requis pour un transfert.") % {'n': i + 1})
continue
if src.pk == dst.pk:
errors.append(_("Ligne %(n)s : les emplacements source et destination doivent être différents.") % {'n': i + 1})
continue
from stock.permissions import user_can_manage_warehouse, user_can_administer_warehouse
if src and not user_can_manage_warehouse(request.user, src.warehouse):
errors.append(_("Ligne %(n)s : vous n'avez pas la permission de gérer l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': src.warehouse.name})
continue
if dst and not user_can_manage_warehouse(request.user, dst.warehouse):
errors.append(_("Ligne %(n)s : vous n'avez pas la permission de gérer l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': dst.warehouse.name})
continue
if movement_subtype == 'correction':
check_loc = dst if movement_type == 'in' else src
if check_loc and not user_can_administer_warehouse(request.user, check_loc.warehouse):
errors.append(_("Ligne %(n)s : le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt '%(wh)s'.") % {'n': i + 1, 'wh': check_loc.warehouse.name})
continue
try:
product_obj = Product.objects.prefetch_related('authorized_locations').get(pk=pid)
@ -1231,11 +1216,13 @@ def _new_stock_batch_view(request, movement_type,
'out': _("Nouvelle sortie de stock"),
'transfer': _("Nouveau transfert de stock"),
}
can_administer_any = any(getattr(loc, 'can_administer', False) for loc in locations)
return render(request, template_name, {
"movement_type": movement_type,
"movement_type_label": type_labels.get(movement_type, ""),
"products": products,
"locations": locations,
"can_administer_any": can_administer_any,
"categories": categories,
"company_members": company_members,
"companies": companies,