feat: add reopen functionality to preparation orders and display delta quantities for adjustments
This commit is contained in:
parent
7767856cb5
commit
d5a404037c
6 changed files with 865 additions and 110 deletions
|
|
@ -1387,12 +1387,55 @@ class PreparationOrder(models.Model):
|
||||||
self.save()
|
self.save()
|
||||||
self.update_reserved_quantities()
|
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):
|
def cancel(self):
|
||||||
"""Annule le bon et libère les quantités réservées"""
|
"""Annule le bon et libère les quantités réservées"""
|
||||||
self.status = "cancelled"
|
self.status = "cancelled"
|
||||||
self.save()
|
self.save()
|
||||||
self.update_reserved_quantities()
|
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):
|
class PreparationOrderItem(models.Model):
|
||||||
preparation_order = models.ForeignKey(PreparationOrder, on_delete=models.CASCADE)
|
preparation_order = models.ForeignKey(PreparationOrder, on_delete=models.CASCADE)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<!-- Statut -->
|
<!-- 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">
|
<span class="badge bg-warning text-dark fs-5">
|
||||||
<i class="bi bi-hourglass-split"></i> {{ preparation_order.get_status_display }}
|
<i class="bi bi-hourglass-split"></i> {{ preparation_order.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -60,6 +64,18 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Intervention liée -->
|
||||||
{% if preparation_order.intervention %}
|
{% if preparation_order.intervention %}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
|
|
@ -232,19 +248,23 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% translate "Produit" %}</th>
|
<th>{% translate "Produit" %}</th>
|
||||||
<th>{% translate "Code" %}</th>
|
<th>{% translate "Code" %}</th>
|
||||||
<th>{% translate "Catégorie" %}</th>
|
<th>{% translate "Catégorie" %}</th>
|
||||||
<th class="text-center">{% translate "Quantité demandée" %}</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' %}
|
{% 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 "Quantité réservée" %}</th>
|
||||||
<th class="text-center">{% translate "Stock disponible" %}</th>
|
<th class="text-center">{% translate "Stock disponible" %}</th>
|
||||||
<th>{% translate "Emplacements" %}</th>
|
<th>{% translate "Emplacements" %}</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th>{% translate "Bon de confection" %}</th>
|
<th>{% translate "Bon de confection" %}</th>
|
||||||
|
<th class="text-center" style="width:70px;">{% translate "Préparé" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -268,13 +288,38 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if item_data.item.quantity > 0 %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="badge bg-secondary" title="{% translate 'Produit ajouté pendant le traitement' %}">
|
<span class="badge bg-secondary" title="{% translate 'Produit ajouté pendant le traitement' %}">
|
||||||
<i class="bi bi-plus-circle"></i> {% translate "Ajouté" %}
|
<i class="bi bi-plus-circle"></i> {% translate "Ajouté" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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' %}
|
{% if preparation_order.status == 'to_process' or preparation_order.status == 'pending' %}
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span class="badge bg-info">
|
<span class="badge bg-info">
|
||||||
|
|
@ -316,11 +361,67 @@
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,7 +436,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% translate "Description" %}</th>
|
<th>{% translate "Description" %}</th>
|
||||||
|
|
@ -343,6 +444,7 @@
|
||||||
<th>{% translate "Type (Signalisation)" %}</th>
|
<th>{% translate "Type (Signalisation)" %}</th>
|
||||||
<th class="text-center">{% translate "Quantité" %}</th>
|
<th class="text-center">{% translate "Quantité" %}</th>
|
||||||
{% if can_manage %}<th></th>{% endif %}
|
{% if can_manage %}<th></th>{% endif %}
|
||||||
|
<th class="text-center" style="width:70px;">{% translate "Préparé" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -384,6 +486,17 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -449,11 +562,11 @@
|
||||||
<div class="card h-100 border-success">
|
<div class="card h-100 border-success">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
|
<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>
|
<h6 class="mt-2">{% translate "Signaler comme traité" %}</h6>
|
||||||
<p class="text-muted small">{% translate "Sortir les produits du stock et marquer comme traité" %}</p>
|
<p class="text-muted small">{% translate "Valider la préparation et enregistrer les mouvements de stock" %}</p>
|
||||||
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-success">
|
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#confirmProcessModal">
|
||||||
<i class="bi bi-arrow-right-circle"></i> {% translate "Traiter" %}
|
<i class="bi bi-check2-circle me-1"></i> {% translate "Signaler comme traité" %}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -508,6 +621,42 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -674,9 +823,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer flex-shrink-0">
|
<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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -705,7 +851,6 @@
|
||||||
}
|
}
|
||||||
function markChanged() {
|
function markChanged() {
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
document.getElementById('reloadPageBtn').style.display = 'inline-block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRow(item) {
|
function buildRow(item) {
|
||||||
|
|
@ -719,12 +864,7 @@
|
||||||
<td>${item.product_name} ${lockBadge}</td>
|
<td>${item.product_name} ${lockBadge}</td>
|
||||||
<td><code>${item.product_code}</code></td>
|
<td><code>${item.product_code}</code></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="input-group input-group-sm justify-content-center">
|
<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' : ''}>
|
||||||
<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>
|
|
||||||
</td>
|
</td>
|
||||||
<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' : ''}>
|
<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>${catLabel}</td>
|
||||||
<td><small class="text-muted">${signLabel}</small></td>
|
<td><small class="text-muted">${signLabel}</small></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="input-group input-group-sm justify-content-center">
|
<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' : ''}>
|
||||||
<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>
|
|
||||||
</td>
|
</td>
|
||||||
<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' : ''}>
|
<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 () {
|
document.getElementById('editPreparationOrderModal')?.addEventListener('show.bs.modal', function () {
|
||||||
hideError();
|
hideError();
|
||||||
hasChanges = false;
|
hasChanges = false;
|
||||||
document.getElementById('reloadPageBtn').style.display = 'none';
|
|
||||||
loadItems();
|
loadItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('closeEditModal')?.addEventListener('click', function () {
|
function reloadIfNeeded() {
|
||||||
if (hasChanges) location.reload();
|
if (hasChanges) {
|
||||||
});
|
const url = new URL(window.location.href);
|
||||||
document.getElementById('editPreparationOrderModal')?.addEventListener('hidden.bs.modal', function () {
|
url.searchParams.delete('open_edit');
|
||||||
if (hasChanges) location.reload();
|
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) {
|
document.getElementById('editItemsBody')?.addEventListener('click', function (e) {
|
||||||
hideError();
|
hideError();
|
||||||
const saveBtn = e.target.closest('.btn-save-qty');
|
|
||||||
const removeBtn = e.target.closest('.btn-remove');
|
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) {
|
if (removeBtn) {
|
||||||
const itemId = removeBtn.dataset.itemId;
|
const itemId = removeBtn.dataset.itemId;
|
||||||
if (!confirm('{% translate "Supprimer cet article du bon ?" %}')) return;
|
if (!confirm('{% translate "Supprimer cet article du bon ?" %}')) return;
|
||||||
|
|
@ -836,23 +994,9 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Other items ----
|
|
||||||
document.getElementById('editOtherItemsBody')?.addEventListener('click', function (e) {
|
document.getElementById('editOtherItemsBody')?.addEventListener('click', function (e) {
|
||||||
hideError();
|
hideError();
|
||||||
const saveBtn = e.target.closest('.btn-save-other-qty');
|
|
||||||
const removeBtn = e.target.closest('.btn-remove-other');
|
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) {
|
if (removeBtn) {
|
||||||
const otherId = removeBtn.dataset.otherId;
|
const otherId = removeBtn.dataset.otherId;
|
||||||
if (!confirm('{% translate "Supprimer cette ligne ?" %}')) return;
|
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 ----
|
// ---- Catégorie signalisation toggle ----
|
||||||
document.getElementById('addOtherCategory')?.addEventListener('change', function () {
|
document.getElementById('addOtherCategory')?.addEventListener('change', function () {
|
||||||
const catId = parseInt(this.value) || 0;
|
const catId = parseInt(this.value) || 0;
|
||||||
|
|
@ -1283,6 +1469,85 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const modalIsLoan = document.getElementById('modal_is_loan');
|
const modalIsLoan = document.getElementById('modal_is_loan');
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,66 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Liste des produits à traiter -->
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card-header bg-primary text-white">
|
||||||
<h5 class="mb-0 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>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for item_data in items_with_locations %}
|
{% 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 }}"
|
id="card_{{ item_data.item.id }}"
|
||||||
data-prepared="{% if item_data.item.is_prepared %}true{% else %}false{% endif %}"
|
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.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="card-header {% if item_data.item.quantity_modified %}bg-warning text-dark{% else %}bg-light{% endif %}">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<div class="col-md-6">
|
<div class="col-md-5">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
{% if item_data.item.quantity_modified %}
|
{% if item_data.item.quantity_modified %}
|
||||||
<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>
|
<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>
|
||||||
|
|
@ -58,16 +101,29 @@
|
||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div class="col-md-7 text-end">
|
||||||
{% if item_data.item.quantity > 0 %}
|
{% 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">
|
<span class="badge bg-primary">
|
||||||
<i class="bi bi-box"></i> {% translate "Demandé" %}: {{ item_data.item.quantity }} {{ item_data.item.product.unit }}
|
<i class="bi bi-box"></i> {% translate "Demandé" %}: {{ item_data.item.quantity }} {{ item_data.item.product.unit }}
|
||||||
</span>
|
</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' %}">
|
<span class="badge bg-info" title="{% translate 'Quantité réservée' %}">
|
||||||
<i class="bi bi-bookmark"></i> {% translate "Réservé" %}: {{ item_data.reserved }}
|
<i class="bi bi-bookmark"></i> {% translate "Réservé" %}: {{ item_data.reserved }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -83,7 +139,6 @@
|
||||||
data-product-code="{{ item_data.item.product.code }}">
|
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>
|
<i class="bi bi-box-seam"></i> {% translate "Disponible" %}: <span id="stock_display_{{ item_data.item.id }}">{{ item_data.available }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,7 +146,7 @@
|
||||||
{% if item_data.stocks %}
|
{% if item_data.stocks %}
|
||||||
{% with first_stock=item_data.stocks.0 %}
|
{% 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="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="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<span class="me-4">
|
<span class="me-4">
|
||||||
|
|
@ -100,7 +155,11 @@
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-123"></i>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 align-items-center">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
|
@ -128,7 +187,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if item_data.fallback_locations %}
|
{% if item_data.fallback_locations %}
|
||||||
{# Aucun stock existant mais des emplacements sont disponibles #}
|
{# 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="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<span class="me-4">
|
<span class="me-4">
|
||||||
|
|
@ -141,7 +200,11 @@
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-123"></i>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 align-items-center">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
|
|
||||||
|
|
@ -1521,6 +1521,147 @@ class MaterialLoanTests(TestCase):
|
||||||
overdue_batches = list(response.context["overdue_batches"])
|
overdue_batches = list(response.context["overdue_batches"])
|
||||||
self.assertIn(overdue_batch, 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ urlpatterns = [
|
||||||
path("preparation-orders/<int:pk>/", views.preparation_order_detail, name="preparation_order_detail"),
|
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>/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>/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>/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>/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"),
|
path("preparation-orders/<int:pk>/toggle-loan-return/", views.preparation_order_toggle_loan_return, name="preparation_order_toggle_loan_return"),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from django.db import models, transaction
|
||||||
from django.forms import inlineformset_factory
|
from django.forms import inlineformset_factory
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
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.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_POST
|
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)
|
return redirect('stock:preparation_order_detail', pk=pk)
|
||||||
|
|
||||||
# Calculer les informations sur les produits
|
# 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 = []
|
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
|
product = item.product
|
||||||
raw_available = product.total_stock
|
raw_available = product.total_stock
|
||||||
# Quantité réservée totale (bon courant inclus)
|
|
||||||
reserved_stock = product.reserved_quantity
|
reserved_stock = product.reserved_quantity
|
||||||
# Stock disponible (stock total physique)
|
|
||||||
available_stock = raw_available
|
available_stock = raw_available
|
||||||
# Bon de confection lié à cet article
|
|
||||||
prod_order = item.production_orders.first()
|
prod_order = item.production_orders.first()
|
||||||
|
executed_qty = executed_totals.get(product.id, 0)
|
||||||
|
delta = item.quantity - executed_qty
|
||||||
|
|
||||||
items_with_stock.append({
|
items_with_stock.append({
|
||||||
'item': item,
|
'item': item,
|
||||||
|
|
@ -3804,9 +3819,30 @@ def preparation_order_detail(request, pk):
|
||||||
'is_sufficient': available_stock >= item.quantity,
|
'is_sufficient': available_stock >= item.quantity,
|
||||||
'is_below_threshold': available_stock < product.threshold,
|
'is_below_threshold': available_stock < product.threshold,
|
||||||
'production_order': prod_order,
|
'production_order': prod_order,
|
||||||
|
'executed_qty': executed_qty,
|
||||||
|
'delta': delta,
|
||||||
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse')
|
'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
|
# Vérifier les permissions
|
||||||
from common.models import UserConfig
|
from common.models import UserConfig
|
||||||
try:
|
try:
|
||||||
|
|
@ -3842,6 +3878,9 @@ def preparation_order_detail(request, pk):
|
||||||
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
|
'categories': list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')),
|
||||||
'exit_batches': exit_batches,
|
'exit_batches': exit_batches,
|
||||||
'return_batches': return_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)
|
return render(request, 'stock/preparation_order_detail.html', context)
|
||||||
|
|
@ -3850,7 +3889,8 @@ def preparation_order_detail(request, pk):
|
||||||
@require_POST
|
@require_POST
|
||||||
def preparation_order_cancel(request, pk):
|
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
|
# Vérifier les permissions
|
||||||
from common.models import UserConfig
|
from common.models import UserConfig
|
||||||
|
|
@ -3866,15 +3906,77 @@ def preparation_order_cancel(request, pk):
|
||||||
|
|
||||||
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
|
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
|
||||||
|
|
||||||
if preparation_order.status in ['to_process', 'pending']:
|
if preparation_order.status in ['to_process', 'pending', 'processed']:
|
||||||
preparation_order.cancel()
|
with transaction.atomic():
|
||||||
messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number))
|
# 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:
|
else:
|
||||||
messages.error(request, _("Ce bon ne peut pas être annulé."))
|
messages.error(request, _("Ce bon ne peut pas être annulé."))
|
||||||
|
|
||||||
return redirect('stock:preparation_order_detail', pk=pk)
|
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
|
@login_required
|
||||||
def preparation_order_edit(request, pk):
|
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
|
# Annuler le bon de confection associé si encore à traiter
|
||||||
if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
|
if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
|
||||||
prod_order.cancel()
|
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})
|
return JsonResponse({'deleted': item_id})
|
||||||
|
|
||||||
elif action == 'add_other_item':
|
elif action == 'add_other_item':
|
||||||
|
|
@ -4226,35 +4338,123 @@ def preparation_order_process(request, pk):
|
||||||
|
|
||||||
# Vérifier si on marque comme traité
|
# Vérifier si on marque comme traité
|
||||||
if 'mark_as_processed' in request.POST:
|
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():
|
with transaction.atomic():
|
||||||
batch = StockMovementBatch.objects.create(
|
# 1. Sorties complémentaires (si products_to_move contient des mouvements)
|
||||||
movement_type=StockMovementBatch.TYPE_OUT,
|
if products_to_move:
|
||||||
movement_subtype='preparation_order',
|
batch_out = StockMovementBatch.objects.create(
|
||||||
reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number),
|
movement_type=StockMovementBatch.TYPE_OUT,
|
||||||
preparation_order=preparation_order,
|
movement_subtype='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'],
|
|
||||||
reason=_("Sortie bon de préparation #{}").format(preparation_order.order_number),
|
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']
|
for move in products_to_move:
|
||||||
stock.save()
|
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é
|
# Marquer comme traité
|
||||||
preparation_order.mark_as_processed()
|
preparation_order.mark_as_processed()
|
||||||
|
|
@ -4264,8 +4464,23 @@ def preparation_order_process(request, pk):
|
||||||
return redirect('stock:preparation_order_detail', pk=pk)
|
return redirect('stock:preparation_order_detail', pk=pk)
|
||||||
|
|
||||||
# Préparer les données pour le formulaire
|
# 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 = []
|
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)
|
# Récupérer les emplacements avec stock pour ce produit (inclure tous, même qty=0)
|
||||||
stocks = Stock.objects.filter(
|
stocks = Stock.objects.filter(
|
||||||
product=item.product,
|
product=item.product,
|
||||||
|
|
@ -4290,6 +4505,9 @@ def preparation_order_process(request, pk):
|
||||||
# Bon de confection lié à cet article
|
# Bon de confection lié à cet article
|
||||||
prod_order = item.production_orders.first()
|
prod_order = item.production_orders.first()
|
||||||
|
|
||||||
|
executed_qty = executed_totals.get(item.product_id, 0)
|
||||||
|
delta = item.quantity - executed_qty
|
||||||
|
|
||||||
items_with_locations.append({
|
items_with_locations.append({
|
||||||
'item': item,
|
'item': item,
|
||||||
'stocks': stocks,
|
'stocks': stocks,
|
||||||
|
|
@ -4301,8 +4519,30 @@ def preparation_order_process(request, pk):
|
||||||
'has_single_location': stocks.count() == 1,
|
'has_single_location': stocks.count() == 1,
|
||||||
'single_location_id': stocks.first().location.id if stocks.count() == 1 else None,
|
'single_location_id': stocks.first().location.id if stocks.count() == 1 else None,
|
||||||
'production_order': prod_order,
|
'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
|
# Enrichir les autres produits avec leur bon de confection éventuel
|
||||||
other_items_with_info = []
|
other_items_with_info = []
|
||||||
for oi in preparation_order.other_items.all():
|
for oi in preparation_order.other_items.all():
|
||||||
|
|
@ -4324,11 +4564,13 @@ def preparation_order_process(request, pk):
|
||||||
'other_items': other_items_with_info,
|
'other_items': other_items_with_info,
|
||||||
'allowed_locations': allowed_locations,
|
'allowed_locations': allowed_locations,
|
||||||
'has_modified_items': has_modified_items,
|
'has_modified_items': has_modified_items,
|
||||||
|
'deleted_items_to_return': deleted_items_to_return,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'stock/preparation_order_process.html', context)
|
return render(request, 'stock/preparation_order_process.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def quick_stock_adjustment(request):
|
def quick_stock_adjustment(request):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue