feat: add reopen functionality to preparation orders and display delta quantities for adjustments

This commit is contained in:
kdeterme 2026-08-12 10:17:38 +02:00
parent 7767856cb5
commit d5a404037c
6 changed files with 865 additions and 110 deletions

View file

@ -1387,12 +1387,55 @@ class PreparationOrder(models.Model):
self.save()
self.update_reserved_quantities()
def reopen(self):
"""Réouvre un bon de préparation traité pour modification (statut : to_process)."""
if self.status == "processed":
self.status = "to_process"
self.save()
self.update_reserved_quantities()
def cancel(self):
"""Annule le bon et libère les quantités réservées"""
self.status = "cancelled"
self.save()
self.update_reserved_quantities()
def get_executed_outputs_by_product_and_location(self):
"""
Retourne un dictionnaire des mouvements de sortie nets déjà exécutés pour ce bon.
Structure : { product_id: { location_id: net_quantity } }
Calcul : sorties (TYPE_OUT) moins entrées/retours (TYPE_IN).
"""
executed = {}
for batch in self.stock_batches.all():
for move in batch.movements.all():
prod_id = move.product_id
if prod_id not in executed:
executed[prod_id] = {}
if batch.movement_type == "out" and move.source_location_id:
loc_id = move.source_location_id
executed[prod_id][loc_id] = executed[prod_id].get(loc_id, 0) + move.quantity
elif batch.movement_type == "in" and move.destination_location_id:
loc_id = move.destination_location_id
executed[prod_id][loc_id] = executed[prod_id].get(loc_id, 0) - move.quantity
res = {}
for p_id, loc_dict in executed.items():
filtered_locs = {l_id: qty for l_id, qty in loc_dict.items() if qty > 0}
if filtered_locs:
res[p_id] = filtered_locs
return res
def get_executed_output_quantities(self):
"""
Retourne la quantité totale nette sortie par produit pour ce bon.
Structure : { product_id: net_total_quantity }
"""
outputs_by_loc = self.get_executed_outputs_by_product_and_location()
return {p_id: sum(loc_dict.values()) for p_id, loc_dict in outputs_by_loc.items()}
class PreparationOrderItem(models.Model):
preparation_order = models.ForeignKey(PreparationOrder, on_delete=models.CASCADE)

View file

@ -21,7 +21,11 @@
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<!-- Statut -->
{% if preparation_order.status == 'to_process' %}
{% if is_reopened %}
<span class="badge bg-warning text-dark fs-5" title="{% translate 'Bon de préparation réouvert pour modification' %}">
<i class="bi bi-arrow-counterclockwise me-1"></i> {% translate "Réouvert" %}
</span>
{% elif preparation_order.status == 'to_process' %}
<span class="badge bg-warning text-dark fs-5">
<i class="bi bi-hourglass-split"></i> {{ preparation_order.get_status_display }}
</span>
@ -60,6 +64,18 @@
</div>
</div>
{% if is_reopened %}
<div class="alert alert-warning border-warning shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center">
<i class="bi bi-arrow-counterclockwise text-warning me-3 fs-2"></i>
<div>
<h5 class="alert-heading mb-1"><i class="bi bi-exclamation-circle-fill me-1"></i> {% translate "Bon de préparation réouvert pour modification" %}</h5>
<p class="mb-0 small">{% translate "Ce bon a déjà fait l'objet d'un traitement initial. Les modifications apportées seront prises en compte lors du prochain traitement par le magasinier (restitutions et sorties complémentaires automatiques)." %}</p>
</div>
</div>
</div>
{% endif %}
<!-- Intervention liée -->
{% if preparation_order.intervention %}
<div class="card mb-3 shadow-sm">
@ -232,19 +248,23 @@
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>{% translate "Produit" %}</th>
<th>{% translate "Code" %}</th>
<th>{% translate "Catégorie" %}</th>
<th class="text-center">{% translate "Quantité demandée" %}</th>
{% if has_executed_outputs %}
<th class="text-center">{% translate "À prélever / à restituer" %}</th>
{% endif %}
{% if preparation_order.status == 'to_process' or preparation_order.status == 'pending' %}
<th class="text-center">{% translate "Quantité réservée" %}</th>
<th class="text-center">{% translate "Stock disponible" %}</th>
<th>{% translate "Emplacements" %}</th>
{% endif %}
<th>{% translate "Bon de confection" %}</th>
<th class="text-center" style="width:70px;">{% translate "Préparé" %}</th>
</tr>
</thead>
<tbody>
@ -268,13 +288,38 @@
</td>
<td class="text-center">
{% if item_data.item.quantity > 0 %}
<span class="badge bg-primary">{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</span>
<span class="badge bg-primary fs-6">{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</span>
{% else %}
<span class="badge bg-secondary" title="{% translate 'Produit ajouté pendant le traitement' %}">
<i class="bi bi-plus-circle"></i> {% translate "Ajouté" %}
</span>
{% endif %}
</td>
{% if has_executed_outputs %}
<td class="text-center">
{% if item_data.executed_qty > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
<i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {% translate "à prélever" %}
</span>
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {% translate "à restituer" %}
</span>
<br><small class="text-muted">{% translate "Sorti" %}: {{ item_data.executed_qty }}</small>
{% else %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>{% translate "Inchangé" %} ({{ item_data.executed_qty }})
</span>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {% translate "à prélever" %}
</span>
{% endif %}
</td>
{% endif %}
{% if preparation_order.status == 'to_process' or preparation_order.status == 'pending' %}
<td class="text-center">
<span class="badge bg-info">
@ -316,11 +361,67 @@
<span class="text-muted">—</span>
{% endif %}
</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input prepare-toggle-input"
type="checkbox"
role="switch"
data-item-id="{{ item_data.item.id }}"
data-is-other="false"
{% if item_data.item.is_prepared %}checked{% endif %}
{% if not can_manage %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if deleted_items_to_return %}
<div class="alert alert-info border-info shadow-sm mt-3 mb-0">
<h6 class="alert-heading text-dark mb-2">
<i class="bi bi-arrow-return-left me-1"></i> {% translate "Articles supprimés du bon (restitution automatique au stock)" %} :
</h6>
<div class="table-responsive bg-white rounded p-2">
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<th>{% translate "Produit" %}</th>
<th>{% translate "Emplacement(s) d'origine" %}</th>
<th class="text-center">{% translate "Quantité à restituer" %}</th>
<th class="text-center" style="width:70px;">{% translate "Préparé" %}</th>
</tr>
</thead>
<tbody>
{% for del in deleted_items_to_return %}
<tr>
<td><strong>{{ del.product.name }}</strong> (<code>{{ del.product.code }}</code>)</td>
<td><small class="text-muted">{{ del.location_info }}</small></td>
<td class="text-center">
<span class="badge bg-info text-dark">+{{ del.executed_qty }} {{ del.product.unit }} {% translate "à restituer" %}</span>
</td>
<td class="text-center">
{% if del.item %}
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input prepare-toggle-input"
type="checkbox"
role="switch"
data-item-id="{{ del.item.id }}"
data-is-other="false"
{% if del.item.is_prepared %}checked{% endif %}
{% if not can_manage %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}>
</div>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
@ -335,7 +436,7 @@
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>{% translate "Description" %}</th>
@ -343,6 +444,7 @@
<th>{% translate "Type (Signalisation)" %}</th>
<th class="text-center">{% translate "Quantité" %}</th>
{% if can_manage %}<th></th>{% endif %}
<th class="text-center" style="width:70px;">{% translate "Préparé" %}</th>
</tr>
</thead>
<tbody>
@ -384,6 +486,17 @@
{% endif %}
</td>
{% endif %}
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input prepare-toggle-input"
type="checkbox"
role="switch"
data-item-id="{{ oi_data.item.id }}"
data-is-other="true"
{% if oi_data.item.is_prepared %}checked{% endif %}
{% if not can_manage %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}>
</div>
</td>
</tr>
{% endfor %}
</tbody>
@ -449,11 +562,11 @@
<div class="card h-100 border-success">
<div class="card-body text-center">
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Traiter le bon de préparation" %}</h6>
<p class="text-muted small">{% translate "Sortir les produits du stock et marquer comme traité" %}</p>
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-success">
<i class="bi bi-arrow-right-circle"></i> {% translate "Traiter" %}
</a>
<h6 class="mt-2">{% translate "Signaler comme traité" %}</h6>
<p class="text-muted small">{% translate "Valider la préparation et enregistrer les mouvements de stock" %}</p>
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#confirmProcessModal">
<i class="bi bi-check2-circle me-1"></i> {% translate "Signaler comme traité" %}
</button>
</div>
</div>
</div>
@ -508,6 +621,42 @@
</div>
</div>
{% endif %}
{% elif preparation_order.status == 'processed' %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-warning">
<div class="card-body text-center">
<i class="bi bi-arrow-counterclockwise text-warning" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Réouvrir le bon de préparation" %}</h6>
<p class="text-muted small">{% translate "Permet de modifier les articles du bon (repasse au statut À traiter)" %}</p>
<form method="post" action="{% url 'stock:preparation_order_reopen' preparation_order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir réouvrir ce bon de préparation ?" %}');">
{% csrf_token %}
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Réouvrir le bon" %}
</button>
</form>
</div>
</div>
</div>
{% if can_cancel %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-danger">
<div class="card-body text-center">
<i class="bi bi-x-circle text-danger" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Annuler le bon de préparation" %}</h6>
<p class="text-muted small">{% translate "Annuler le bon et remettre tous les produits sortis en stock" %}</p>
<form method="post" action="{% url 'stock:preparation_order_cancel' preparation_order.pk %}"
onsubmit="return confirm('{% translate "Êtes-vous sûr de vouloir annuler ce bon déjà traité ? Les produits sortis seront automatiquement réintégrés en stock." %}');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> {% translate "Annuler" %}
</button>
</form>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
@ -674,9 +823,6 @@
</div>
<div class="modal-footer flex-shrink-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="closeEditModal">{% translate "Fermer" %}</button>
<button type="button" class="btn btn-warning" id="reloadPageBtn" style="display:none;" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> {% translate "Rafraîchir la page" %}
</button>
</div>
</div>
</div>
@ -705,7 +851,6 @@
}
function markChanged() {
hasChanges = true;
document.getElementById('reloadPageBtn').style.display = 'inline-block';
}
function buildRow(item) {
@ -719,12 +864,7 @@
<td>${item.product_name} ${lockBadge}</td>
<td><code>${item.product_code}</code></td>
<td class="text-center">
<div class="input-group input-group-sm justify-content-center">
<input type="number" class="form-control qty-input" data-item-id="${item.id}" value="${item.quantity}" min="1" style="max-width:80px" ${locked ? 'disabled' : ''}>
<button class="btn btn-outline-secondary btn-save-qty" data-item-id="${item.id}" title="Enregistrer" ${locked ? 'disabled' : ''}>
<i class="bi bi-check2"></i>
</button>
</div>
<input type="number" class="form-control form-control-sm qty-input mx-auto text-center" data-item-id="${item.id}" value="${item.quantity}" min="1" style="max-width:90px" ${locked ? 'disabled' : ''}>
</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-remove" data-item-id="${item.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
@ -748,12 +888,7 @@
<td>${catLabel}</td>
<td><small class="text-muted">${signLabel}</small></td>
<td class="text-center">
<div class="input-group input-group-sm justify-content-center">
<input type="number" class="form-control other-qty-input" data-other-id="${oi.id}" value="${oi.quantity}" min="1" style="max-width:70px" ${locked ? 'disabled' : ''}>
<button class="btn btn-outline-secondary btn-save-other-qty" data-other-id="${oi.id}" title="Enregistrer" ${locked ? 'disabled' : ''}>
<i class="bi bi-check2"></i>
</button>
</div>
<input type="number" class="form-control form-control-sm other-qty-input mx-auto text-center" data-other-id="${oi.id}" value="${oi.quantity}" min="1" style="max-width:80px" ${locked ? 'disabled' : ''}>
</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-remove-other" data-other-id="${oi.id}" title="${locked ? 'Suppression impossible : bon de confection en cours' : 'Supprimer'}" ${locked ? 'disabled' : ''}>
@ -793,34 +928,57 @@
document.getElementById('editPreparationOrderModal')?.addEventListener('show.bs.modal', function () {
hideError();
hasChanges = false;
document.getElementById('reloadPageBtn').style.display = 'none';
loadItems();
});
document.getElementById('closeEditModal')?.addEventListener('click', function () {
if (hasChanges) location.reload();
});
document.getElementById('editPreparationOrderModal')?.addEventListener('hidden.bs.modal', function () {
if (hasChanges) location.reload();
function reloadIfNeeded() {
if (hasChanges) {
const url = new URL(window.location.href);
url.searchParams.delete('open_edit');
window.location.href = url.pathname + (url.search ? url.search : '');
}
}
document.getElementById('closeEditModal')?.addEventListener('click', reloadIfNeeded);
document.getElementById('editPreparationOrderModal')?.addEventListener('hidden.bs.modal', reloadIfNeeded);
// ---- Auto-sauvegarde des quantités au changement (Ajax) ----
document.getElementById('editItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.qty-input');
if (!input) return;
hideError();
const itemId = input.dataset.itemId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_quantity', item_id: itemId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
// ---- Regular items ----
document.getElementById('editOtherItemsBody')?.addEventListener('change', function (e) {
const input = e.target.closest('.other-qty-input');
if (!input) return;
hideError();
const otherId = input.dataset.otherId;
const qty = parseInt(input.value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_other_item', item_id: otherId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
input.value = data.quantity;
input.classList.add('is-valid');
setTimeout(() => input.classList.remove('is-valid'), 1200);
markChanged();
});
});
// ---- Suppressions d'articles dans la modale ----
document.getElementById('editItemsBody')?.addEventListener('click', function (e) {
hideError();
const saveBtn = e.target.closest('.btn-save-qty');
const removeBtn = e.target.closest('.btn-remove');
if (saveBtn) {
const itemId = saveBtn.dataset.itemId;
const qty = parseInt(document.querySelector(`.qty-input[data-item-id="${itemId}"]`).value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_quantity', item_id: itemId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
document.querySelector(`.qty-input[data-item-id="${itemId}"]`).value = data.quantity;
markChanged();
});
}
if (removeBtn) {
const itemId = removeBtn.dataset.itemId;
if (!confirm('{% translate "Supprimer cet article du bon ?" %}')) return;
@ -836,23 +994,9 @@
}
});
// ---- Other items ----
document.getElementById('editOtherItemsBody')?.addEventListener('click', function (e) {
hideError();
const saveBtn = e.target.closest('.btn-save-other-qty');
const removeBtn = e.target.closest('.btn-remove-other');
if (saveBtn) {
const otherId = saveBtn.dataset.otherId;
const qty = parseInt(document.querySelector(`.other-qty-input[data-other-id="${otherId}"]`).value);
if (!qty || qty < 1) { showError('{% translate "La quantité doit être supérieure à 0." %}'); return; }
apiPost({action: 'update_other_item', item_id: otherId, quantity: qty}).then(data => {
if (data.error) { showError(data.error); return; }
document.querySelector(`.other-qty-input[data-other-id="${otherId}"]`).value = data.quantity;
markChanged();
});
}
if (removeBtn) {
const otherId = removeBtn.dataset.otherId;
if (!confirm('{% translate "Supprimer cette ligne ?" %}')) return;
@ -868,6 +1012,48 @@
}
});
// ---- Bascule interactive l'état "Préparé" depuis le tableau principal ----
document.addEventListener('change', function (e) {
const toggle = e.target.closest('.prepare-toggle-input');
if (!toggle) return;
const itemId = toggle.dataset.itemId;
const isOther = toggle.dataset.isOther === 'true';
const url = isOther
? `/stock/preparation-order-other-items/${itemId}/toggle-prepared/`
: `/stock/preparation-order-items/${itemId}/toggle-prepared/`;
fetch(url, {
method: 'POST',
headers: {'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest'},
})
.then(r => r.json())
.then(data => {
if (!data.success) {
toggle.checked = !toggle.checked;
alert(data.error || 'Erreur lors de la mise à jour');
}
})
.catch(() => {
toggle.checked = !toggle.checked;
});
});
// ---- Ouverture automatique de la modale post-réouverture ----
document.addEventListener('DOMContentLoaded', function () {
const url = new URL(window.location.href);
if (url.searchParams.get('open_edit') === '1') {
const editModal = document.getElementById('editPreparationOrderModal');
if (editModal) {
const modalInstance = bootstrap.Modal.getOrCreateInstance(editModal);
modalInstance.show();
}
// Nettoyer l'URL du navigateur sans recharger la page
url.searchParams.delete('open_edit');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : ''));
}
});
// ---- Catégorie signalisation toggle ----
document.getElementById('addOtherCategory')?.addEventListener('change', function () {
const catId = parseInt(this.value) || 0;
@ -1283,6 +1469,85 @@
</div>
</div>
<!-- Modale de confirmation du traitement -->
<div class="modal fade" id="confirmProcessModal" tabindex="-1" aria-labelledby="confirmProcessModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="confirmProcessModalLabel">
<i class="bi bi-check-circle me-2"></i>{% translate "Confirmation du traitement" %} — #{{ preparation_order.order_number }}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{% url 'stock:preparation_order_process' preparation_order.pk %}">
{% csrf_token %}
<input type="hidden" name="mark_as_processed" value="1">
<div class="modal-body">
<p class="fw-bold text-success mb-3">
<i class="bi bi-info-circle me-1"></i>
{% translate "Êtes-vous sûr de vouloir signaler ce bon de préparation comme TRAITÉ ?" %}
</p>
<div class="card mb-3 border shadow-sm">
<div class="card-header bg-light py-2">
<strong><i class="bi bi-box-arrow-right me-1"></i>{% translate "Récapitulatif des mouvements de stock qui seront générés" %} :</strong>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Produit" %}</th>
<th class="text-center">{% translate "Mouvement de stock" %}</th>
</tr>
</thead>
<tbody>
{% for item_data in items_with_stock %}
<tr>
<td><strong>{{ item_data.item.product.name }}</strong> (<code>{{ item_data.item.product.code }}</code>)</td>
<td class="text-center">
{% if has_executed_outputs %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark">+{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "sorti complémentaire" %}</span>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark">{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "restitué au stock" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Inchangé (déjà sorti)" %}</span>
{% endif %}
{% else %}
<span class="badge bg-success">-{{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "sorti du stock" %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% for del in deleted_items_to_return %}
<tr>
<td><strong>{{ del.product.name }}</strong> (<code>{{ del.product.code }}</code>) <span class="badge bg-danger">{% translate "Supprimé" %}</span></td>
<td class="text-center">
<span class="badge bg-info text-dark">+{{ del.executed_qty }} {{ del.product.unit }} {% translate "restitué au stock" %}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted small mb-0">
<i class="bi bi-shield-check me-1"></i>
{% translate "Les mouvements de stock seront automatiquement enregistrés et le bon passera au statut Traité." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i> {% translate "Confirmer et marquer comme traité" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modalIsLoan = document.getElementById('modal_is_loan');

View file

@ -27,23 +27,66 @@
</div>
{% endif %}
{% if deleted_items_to_return %}
<div class="card mb-3 shadow-sm border-info">
<div class="card-header bg-info text-dark">
<h5 class="mb-0 text-dark">
<i class="bi bi-arrow-return-left"></i> {% translate "Articles supprimés du bon (restitution automatique au stock)" %}
</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-2">
{% translate "Les articles ci-dessous avaient fait l'objet d'une sortie lors du traitement précédent mais ont été retirés du bon par le gestionnaire. Lors de la validation, le stock sera automatiquement recrédité." %}
</p>
<ul class="list-group">
{% for del_item in deleted_items_to_return %}
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<strong>{{ del_item.product.name }}</strong> <span class="text-muted">({{ del_item.product.code }})</span>
<br>
<small class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Emplacement(s) d'origine" %} : {{ del_item.location_info }}</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-info text-dark fs-6">
<i class="bi bi-arrow-return-left me-1"></i>+{{ del_item.executed_qty }} {{ del_item.product.unit }} {% translate "à restituer" %}
</span>
{% if del_item.item %}
<button type="button"
class="btn btn-sm {% if del_item.item.is_prepared %}btn-outline-success{% else %}btn-outline-secondary{% endif %} prepare-btn"
onclick="togglePrepared({{ del_item.item.id }})"
id="prepare_btn_{{ del_item.item.id }}">
{% if del_item.item.is_prepared %}
<i class="bi bi-check-square-fill"></i> {% translate "Restitué" %}
{% else %}
<i class="bi bi-square"></i> {% translate "Restitué" %}
{% endif %}
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Liste des produits à traiter -->
<div class="card mb-3 shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0 text-white">
<i class="bi bi-list-check"></i> {% translate "Produits à sortir" %}
<i class="bi bi-list-check"></i> {% translate "Produits à sortir / préparer" %}
</h5>
</div>
<div class="card-body">
{% for item_data in items_with_locations %}
<div class="card mb-3 {% if item_data.item.is_prepared %}border-success{% else %}border{% endif %} {% if item_data.item.quantity_modified %}border-warning{% endif %} product-card"
<div class="card mb-3 {% if item_data.item.is_prepared %}border-success{% else %}border{% endif %} {% if item_data.item.quantity_modified or item_data.delta != 0 %}border-warning{% endif %} product-card"
id="card_{{ item_data.item.id }}"
data-prepared="{% if item_data.item.is_prepared %}true{% else %}false{% endif %}"
{% if item_data.item.is_prepared %}style="border-width: 2px;"{% endif %}
{% if item_data.item.quantity_modified %}style="border-width: 3px; border-color: #ffc107 !important;"{% endif %}>
{% if item_data.item.quantity_modified or item_data.delta != 0 %}style="border-width: 3px;"{% endif %}>
<div class="card-header {% if item_data.item.quantity_modified %}bg-warning text-dark{% else %}bg-light{% endif %}">
<div class="row align-items-center">
<div class="col-md-6">
<div class="col-md-5">
<h6 class="mb-0">
{% if item_data.item.quantity_modified %}
<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>
@ -58,16 +101,29 @@
</small>
{% endif %}
</div>
<div class="col-md-6 text-end">
{% if item_data.item.quantity > 0 %}
<div class="col-md-7 text-end">
{% if item_data.executed_qty > 0 %}
<span class="badge bg-secondary" title="{% translate 'Quantité déjà sortie précédemment' %}">
<i class="bi bi-history"></i> {% translate "Déjà sorti" %}: {{ item_data.executed_qty }}
</span>
{% endif %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
<i class="bi bi-plus-circle"></i> +{{ item_data.delta }} {% translate "à prélever" %}
</span>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
<i class="bi bi-arrow-return-left"></i> {{ item_data.delta }} {% translate "à réintégrer" %}
</span>
{% elif item_data.executed_qty > 0 %}
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> {% translate "Inchangé" %}
</span>
{% endif %}
<span class="badge bg-primary">
<i class="bi bi-box"></i> {% translate "Demandé" %}: {{ item_data.item.quantity }} {{ item_data.item.product.unit }}
</span>
{% else %}
<span class="badge bg-secondary">
<i class="bi bi-plus-circle"></i> {% translate "Ajouté" %}
</span>
{% endif %}
<span class="badge bg-info" title="{% translate 'Quantité réservée' %}">
<i class="bi bi-bookmark"></i> {% translate "Réservé" %}: {{ item_data.reserved }}
</span>
@ -83,7 +139,6 @@
data-product-code="{{ item_data.item.product.code }}">
<i class="bi bi-box-seam"></i> {% translate "Disponible" %}: <span id="stock_display_{{ item_data.item.id }}">{{ item_data.available }}</span>
</span>
</div>
</div>
</div>
@ -91,7 +146,7 @@
{% if item_data.stocks %}
{% with first_stock=item_data.stocks.0 %}
<input type="hidden" name="location_{{ item_data.item.id }}" id="location_{{ item_data.item.id }}" value="{{ first_stock.location.id }}">
<input type="hidden" name="quantity_{{ item_data.item.id }}" id="quantity_{{ item_data.item.id }}" value="{{ item_data.item.quantity }}">
<input type="hidden" name="quantity_{{ item_data.item.id }}" id="quantity_{{ item_data.item.id }}" value="{{ item_data.suggested_quantity }}">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="text-muted">
<span class="me-4">
@ -100,7 +155,11 @@
</span>
<span>
<i class="bi bi-123"></i>
{% translate "Quantité à sortir" %}: <strong>{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</strong>
{% if item_data.executed_qty > 0 %}
{% translate "Complément à sortir" %}: <strong>{{ item_data.suggested_quantity }} {{ item_data.item.product.unit }}</strong>
{% else %}
{% translate "Quantité à sortir" %}: <strong>{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</strong>
{% endif %}
</span>
</div>
<div class="d-flex gap-2 align-items-center">
@ -128,7 +187,7 @@
{% else %}
{% if item_data.fallback_locations %}
{# Aucun stock existant mais des emplacements sont disponibles #}
<input type="hidden" name="quantity_{{ item_data.item.id }}" id="quantity_{{ item_data.item.id }}" value="{{ item_data.item.quantity }}">
<input type="hidden" name="quantity_{{ item_data.item.id }}" id="quantity_{{ item_data.item.id }}" value="{{ item_data.suggested_quantity }}">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="text-muted">
<span class="me-4">
@ -141,7 +200,11 @@
</span>
<span>
<i class="bi bi-123"></i>
{% translate "Quantité à sortir" %}: <strong>{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</strong>
{% if item_data.executed_qty > 0 %}
{% translate "Complément à sortir" %}: <strong>{{ item_data.suggested_quantity }} {{ item_data.item.product.unit }}</strong>
{% else %}
{% translate "Quantité à sortir" %}: <strong>{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</strong>
{% endif %}
</span>
</div>
<div class="d-flex gap-2 align-items-center">

View file

@ -1521,6 +1521,147 @@ class MaterialLoanTests(TestCase):
overdue_batches = list(response.context["overdue_batches"])
self.assertIn(overdue_batch, overdue_batches)
def test_reopen_processed_preparation_order(self):
from .models import PreparationOrder, PreparationOrderItem, Stock, StockMovementBatch
# 1. Créer un bon de préparation avec 10 unités et le traiter
self.stock.quantity = 20
self.stock.save()
po = PreparationOrder.objects.create(
order_number="BP-REOPEN-01",
status="to_process",
)
item = PreparationOrderItem.objects.create(
preparation_order=po,
product=self.product,
quantity=10,
)
# Traiter le bon
response = self.client.post(
reverse("stock:preparation_order_process", args=[po.pk]),
{
"mark_as_processed": "true",
f"quantity_{item.id}": "10",
f"location_{item.id}": str(self.location.id),
}
)
self.assertEqual(response.status_code, 302)
po.refresh_from_db()
self.assertEqual(po.status, "processed")
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 10) # 20 - 10 = 10
# 2. Réouvrir le bon
response = self.client.post(reverse("stock:preparation_order_reopen", args=[po.pk]))
self.assertEqual(response.status_code, 302)
po.refresh_from_db()
self.assertEqual(po.status, "to_process")
# 3. Réduire la quantité de 10 à 4 (Delta = -6 -> restitution de 6 en stock)
item.quantity = 4
item.save()
# 4. Traiter à nouveau
response = self.client.post(
reverse("stock:preparation_order_process", args=[po.pk]),
{
"mark_as_processed": "true",
}
)
self.assertEqual(response.status_code, 302)
po.refresh_from_db()
self.assertEqual(po.status, "processed")
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 16) # 10 + 6 = 16
def test_delete_item_on_reopened_order_restores_stock(self):
from .models import PreparationOrder, PreparationOrderItem, Stock, StockMovementBatch
self.stock.quantity = 30
self.stock.save()
po = PreparationOrder.objects.create(
order_number="BP-REOPEN-DEL-01",
status="to_process",
)
item = PreparationOrderItem.objects.create(
preparation_order=po,
product=self.product,
quantity=8,
)
# Traiter le bon
self.client.post(
reverse("stock:preparation_order_process", args=[po.pk]),
{
"mark_as_processed": "true",
f"quantity_{item.id}": "8",
f"location_{item.id}": str(self.location.id),
}
)
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 22) # 30 - 8 = 22
# Réouvrir le bon
self.client.post(reverse("stock:preparation_order_reopen", args=[po.pk]))
po.refresh_from_db()
self.assertEqual(po.status, "to_process")
# Supprimer l'article du bon
item.delete()
# Magasinier retraite le bon
response = self.client.post(
reverse("stock:preparation_order_process", args=[po.pk]),
{
"mark_as_processed": "true",
}
)
self.assertEqual(response.status_code, 302)
po.refresh_from_db()
self.assertEqual(po.status, "processed")
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 30) # Restitution intégrale des 8 unités
def test_cancel_processed_order_restores_stock(self):
from .models import PreparationOrder, PreparationOrderItem
self.stock.quantity = 50
self.stock.save()
po = PreparationOrder.objects.create(
order_number="BP-CANCEL-PROCESSED-01",
status="to_process",
)
item = PreparationOrderItem.objects.create(
preparation_order=po,
product=self.product,
quantity=15,
)
# Traiter le bon
self.client.post(
reverse("stock:preparation_order_process", args=[po.pk]),
{
"mark_as_processed": "true",
f"quantity_{item.id}": "15",
f"location_{item.id}": str(self.location.id),
}
)
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 35) # 50 - 15 = 35
# Annuler le bon traité
response = self.client.post(reverse("stock:preparation_order_cancel", args=[po.pk]))
self.assertEqual(response.status_code, 302)
po.refresh_from_db()
self.assertEqual(po.status, "cancelled")
self.stock.refresh_from_db()
self.assertEqual(self.stock.quantity, 50) # Restitution complète des 15 unités

View file

@ -71,6 +71,7 @@ urlpatterns = [
path("preparation-orders/<int:pk>/", views.preparation_order_detail, name="preparation_order_detail"),
path("preparation-orders/<int:pk>/items/", views.preparation_order_items_api, name="preparation_order_items_api"),
path("preparation-orders/<int:pk>/cancel/", views.preparation_order_cancel, name="preparation_order_cancel"),
path("preparation-orders/<int:pk>/reopen/", views.preparation_order_reopen, name="preparation_order_reopen"),
path("preparation-orders/<int:pk>/edit/", views.preparation_order_edit, name="preparation_order_edit"),
path("preparation-orders/<int:pk>/process/", views.preparation_order_process, name="preparation_order_process"),
path("preparation-orders/<int:pk>/toggle-loan-return/", views.preparation_order_toggle_loan_return, name="preparation_order_toggle_loan_return"),

View file

@ -8,6 +8,7 @@ from django.db import models, transaction
from django.forms import inlineformset_factory
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
@ -3785,16 +3786,30 @@ def preparation_order_detail(request, pk):
return redirect('stock:preparation_order_detail', pk=pk)
# Calculer les informations sur les produits
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()}
has_executed_outputs = bool(executed_totals)
is_reopened = preparation_order.status == 'to_process' and has_executed_outputs
# S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon
for p_id, loc_dict in executed_by_loc.items():
executed_qty = sum(loc_dict.values())
if executed_qty > 0:
PreparationOrderItem.objects.get_or_create(
preparation_order=preparation_order,
product_id=p_id,
defaults={'quantity': 0}
)
items_with_stock = []
for item in preparation_order.preparationorderitem_set.all():
for item in preparation_order.preparationorderitem_set.filter(quantity__gt=0):
product = item.product
raw_available = product.total_stock
# Quantité réservée totale (bon courant inclus)
reserved_stock = product.reserved_quantity
# Stock disponible (stock total physique)
available_stock = raw_available
# Bon de confection lié à cet article
prod_order = item.production_orders.first()
executed_qty = executed_totals.get(product.id, 0)
delta = item.quantity - executed_qty
items_with_stock.append({
'item': item,
@ -3804,9 +3819,30 @@ def preparation_order_detail(request, pk):
'is_sufficient': available_stock >= item.quantity,
'is_below_threshold': available_stock < product.threshold,
'production_order': prod_order,
'executed_qty': executed_qty,
'delta': delta,
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse')
})
# Produits supprimés du bon qui avaient été sortis précédemment (quantity == 0)
deleted_items_to_return = []
for item in preparation_order.preparationorderitem_set.filter(quantity=0):
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > 0:
product = item.product
loc_dict = executed_by_loc.get(product.id, {})
loc_names = []
for l_id, q in loc_dict.items():
loc_obj = WarehouseLocation.objects.filter(pk=l_id).select_related('warehouse').first()
if loc_obj:
loc_names.append(f"{loc_obj.name} ({loc_obj.warehouse.name}): {q}")
deleted_items_to_return.append({
'item': item,
'product': product,
'executed_qty': executed_qty,
'location_info': ", ".join(loc_names),
})
# Vérifier les permissions
from common.models import UserConfig
try:
@ -3842,6 +3878,9 @@ def preparation_order_detail(request, pk):
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
'exit_batches': exit_batches,
'return_batches': return_batches,
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
}
return render(request, 'stock/preparation_order_detail.html', context)
@ -3850,7 +3889,8 @@ def preparation_order_detail(request, pk):
@require_POST
def preparation_order_cancel(request, pk):
"""
Annule un bon de préparation et libère les quantités réservées
Annule un bon de préparation et libère les quantités réservées.
Si le bon avait déjà fait l'objet de sorties de stock, réintègre l'ensemble des produits sortis.
"""
# Vérifier les permissions
from common.models import UserConfig
@ -3866,15 +3906,77 @@ def preparation_order_cancel(request, pk):
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if preparation_order.status in ['to_process', 'pending']:
preparation_order.cancel()
messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number))
if preparation_order.status in ['to_process', 'pending', 'processed']:
with transaction.atomic():
# Restituer le stock si des sorties avaient été exécutées
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
if executed_by_loc:
batch = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_IN,
movement_subtype='return',
reason=_("Restitution suite à l'annulation du bon de préparation #{}").format(preparation_order.order_number),
preparation_order=preparation_order,
created_by=request.user,
)
for prod_id, loc_dict in executed_by_loc.items():
product = Product.objects.get(pk=prod_id)
for loc_id, qty in loc_dict.items():
if qty > 0:
loc = WarehouseLocation.objects.get(pk=loc_id)
stock, _created = Stock.objects.select_for_update().get_or_create(
product=product,
location=loc,
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=product,
source_location=None,
destination_location=loc,
quantity=qty,
reason=_("Restitution suite à l'annulation du bon #{}").format(preparation_order.order_number),
batch=batch,
)
stock.quantity += qty
stock.save()
preparation_order.cancel()
messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number))
else:
messages.error(request, _("Ce bon ne peut pas être annulé."))
return redirect('stock:preparation_order_detail', pk=pk)
@require_POST
def preparation_order_reopen(request, pk):
"""
Réouvre un bon de préparation traité pour permettre sa modification (statut : to_process).
"""
from common.models import UserConfig
try:
user_config = request.user.config
can_manage = user_config.can_manage_preparation_orders_any()
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage:
messages.error(request, _("Vous n'avez pas la permission de réouvrir des bons de préparation."))
return redirect('stock:preparation_order_detail', pk=pk)
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if preparation_order.status == 'processed':
preparation_order.reopen()
messages.success(request, _("Le bon de préparation #{} a été réouvert (statut : À traiter).").format(preparation_order.order_number))
return redirect(f"{reverse('stock:preparation_order_detail', args=[pk])}?open_edit=1")
else:
messages.error(request, _("Ce bon ne peut être réouvert que s'il est au statut Traité."))
return redirect('stock:preparation_order_detail', pk=pk)
@login_required
def preparation_order_edit(request, pk):
"""
@ -3998,7 +4100,17 @@ def preparation_order_edit(request, pk):
# Annuler le bon de confection associé si encore à traiter
if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
prod_order.cancel()
item.delete()
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_qty = sum(executed_by_loc.get(item.product_id, {}).values())
if executed_qty > 0:
item.quantity = 0
item.is_prepared = False
item.save()
else:
item.delete()
return JsonResponse({'deleted': item_id})
elif action == 'add_other_item':
@ -4226,35 +4338,123 @@ def preparation_order_process(request, pk):
# Vérifier si on marque comme traité
if 'mark_as_processed' in request.POST:
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()}
# Si products_to_move est vide (ex: confirmation directe depuis la page de détail),
# déterminer automatiquement les quantités et emplacements pour chaque produit demandé
if not products_to_move:
for item in preparation_order.preparationorderitem_set.filter(quantity__gt=0):
executed_qty = executed_totals.get(item.product_id, 0)
needed_out = item.quantity - executed_qty
if needed_out > 0:
stock_loc = Stock.objects.filter(
product=item.product,
location__in=allowed_locations,
quantity__gt=0
).select_related('location').order_by('-quantity').first()
if not stock_loc:
stock_loc = Stock.objects.filter(
product=item.product,
location__in=allowed_locations
).select_related('location').first()
location = stock_loc.location if stock_loc else allowed_locations.first()
if location:
products_to_move.append({
'item': item,
'quantity': needed_out,
'location': location,
})
# Créer le lot de mouvements lié au bon de préparation
with transaction.atomic():
batch = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_OUT,
movement_subtype='preparation_order',
reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number),
preparation_order=preparation_order,
created_by=request.user,
is_loan=preparation_order.is_loan,
estimated_return_date=preparation_order.estimated_return_date,
loan_returned=preparation_order.loan_returned,
)
for move in products_to_move:
stock, _created = Stock.objects.select_for_update().get_or_create(
product=move['item'].product,
location=move['location'],
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=move['item'].product,
source_location=move['location'],
destination_location=None,
quantity=move['quantity'],
# 1. Sorties complémentaires (si products_to_move contient des mouvements)
if products_to_move:
batch_out = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_OUT,
movement_subtype='preparation_order',
reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number),
batch=batch,
preparation_order=preparation_order,
created_by=request.user,
is_loan=preparation_order.is_loan,
estimated_return_date=preparation_order.estimated_return_date,
loan_returned=preparation_order.loan_returned,
)
stock.quantity -= move['quantity']
stock.save()
for move in products_to_move:
stock, _created = Stock.objects.select_for_update().get_or_create(
product=move['item'].product,
location=move['location'],
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=move['item'].product,
source_location=move['location'],
destination_location=None,
quantity=move['quantity'],
reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number),
batch=batch_out,
)
stock.quantity -= move['quantity']
stock.save()
# 2. Restitutions automatiques (baisse de quantité ou suppression d'article)
returns_to_execute = []
current_items = preparation_order.preparationorderitem_set.all()
current_product_ids = set(current_items.values_list('product_id', flat=True))
for item in current_items:
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > item.quantity:
needed_return = executed_qty - item.quantity
loc_dict = executed_by_loc.get(item.product_id, {})
for loc_id, past_qty in loc_dict.items():
if needed_return <= 0:
break
to_return = min(needed_return, past_qty)
returns_to_execute.append({
'product_id': item.product_id,
'location_id': loc_id,
'quantity': to_return,
})
needed_return -= to_return
for p_id, loc_dict in executed_by_loc.items():
if p_id not in current_product_ids:
for loc_id, past_qty in loc_dict.items():
if past_qty > 0:
returns_to_execute.append({
'product_id': p_id,
'location_id': loc_id,
'quantity': past_qty,
})
if returns_to_execute:
batch_in = StockMovementBatch.objects.create(
movement_type=StockMovementBatch.TYPE_IN,
movement_subtype='return',
reason=_("Restitution suite à modification du bon de préparation #{}").format(preparation_order.order_number),
preparation_order=preparation_order,
created_by=request.user,
)
for ret in returns_to_execute:
product = Product.objects.get(pk=ret['product_id'])
location = WarehouseLocation.objects.get(pk=ret['location_id'])
stock, _created = Stock.objects.select_for_update().get_or_create(
product=product,
location=location,
defaults={'quantity': 0},
)
StockMovement.objects.create(
product=product,
source_location=None,
destination_location=location,
quantity=ret['quantity'],
reason=_("Restitution suite à modification du bon #{}").format(preparation_order.order_number),
batch=batch_in,
)
stock.quantity += ret['quantity']
stock.save()
# Marquer comme traité
preparation_order.mark_as_processed()
@ -4264,8 +4464,23 @@ def preparation_order_process(request, pk):
return redirect('stock:preparation_order_detail', pk=pk)
# Préparer les données pour le formulaire
executed_by_loc = preparation_order.get_executed_outputs_by_product_and_location()
executed_totals = {p_id: sum(locs.values()) for p_id, locs in executed_by_loc.items()}
# S'assurer qu'une ligne PreparationOrderItem avec quantity=0 existe pour tout produit sorti mais supprimé du bon
for p_id, loc_dict in executed_by_loc.items():
executed_qty = sum(loc_dict.values())
if executed_qty > 0:
PreparationOrderItem.objects.get_or_create(
preparation_order=preparation_order,
product_id=p_id,
defaults={'quantity': 0}
)
items_with_locations = []
for item in preparation_order.preparationorderitem_set.all():
current_items = preparation_order.preparationorderitem_set.filter(quantity__gt=0)
for item in current_items:
# Récupérer les emplacements avec stock pour ce produit (inclure tous, même qty=0)
stocks = Stock.objects.filter(
product=item.product,
@ -4290,6 +4505,9 @@ def preparation_order_process(request, pk):
# Bon de confection lié à cet article
prod_order = item.production_orders.first()
executed_qty = executed_totals.get(item.product_id, 0)
delta = item.quantity - executed_qty
items_with_locations.append({
'item': item,
'stocks': stocks,
@ -4301,7 +4519,29 @@ def preparation_order_process(request, pk):
'has_single_location': stocks.count() == 1,
'single_location_id': stocks.first().location.id if stocks.count() == 1 else None,
'production_order': prod_order,
'executed_qty': executed_qty,
'delta': delta,
'suggested_quantity': max(0, delta),
})
# Produits supprimés du bon qui avaient été sortis précédemment (quantity == 0)
deleted_items_to_return = []
for item in preparation_order.preparationorderitem_set.filter(quantity=0):
executed_qty = executed_totals.get(item.product_id, 0)
if executed_qty > 0:
product = item.product
loc_dict = executed_by_loc.get(product.id, {})
loc_names = []
for l_id, q in loc_dict.items():
loc_obj = WarehouseLocation.objects.filter(pk=l_id).select_related('warehouse').first()
if loc_obj:
loc_names.append(f"{loc_obj.name} ({loc_obj.warehouse.name}): {q}")
deleted_items_to_return.append({
'item': item,
'product': product,
'executed_qty': executed_qty,
'location_info': ", ".join(loc_names),
})
# Enrichir les autres produits avec leur bon de confection éventuel
other_items_with_info = []
@ -4324,11 +4564,13 @@ def preparation_order_process(request, pk):
'other_items': other_items_with_info,
'allowed_locations': allowed_locations,
'has_modified_items': has_modified_items,
'deleted_items_to_return': deleted_items_to_return,
}
return render(request, 'stock/preparation_order_process.html', context)
@require_POST
def quick_stock_adjustment(request):
"""