feat: add stock batch views, templates, and comprehensive test suite
This commit is contained in:
parent
25a64b824a
commit
001bee8ca9
4 changed files with 600 additions and 378 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue