feat: enable preparation order reopening and track returned quantities for deleted items

This commit is contained in:
kdeterme 2026-08-12 10:26:08 +02:00
parent d5a404037c
commit ea2bb4817c
3 changed files with 244 additions and 8 deletions

View file

@ -963,21 +963,60 @@ def stock_preparation_order_detail_mobile(request, pk):
messages.success(request, _('Document(s) ajouté(s) avec succès.'))
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
from stock.models import PreparationOrderItem, WarehouseLocation
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
available_stock = product.total_stock
reserved_stock = product.reserved_quantity
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,
'available_stock': available_stock,
'reserved_stock': reserved_stock,
'is_sufficient': available_stock >= item.quantity,
'production_order': prod_order,
'executed_qty': executed_qty,
'delta': delta,
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse'),
})
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),
})
try:
user_config = request.user.config
can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any()
@ -1007,6 +1046,9 @@ def stock_preparation_order_detail_mobile(request, pk):
'can_manage_purchase': can_manage_purchase,
'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,
})

View file

@ -69,15 +69,33 @@
<small class="text-muted"><i class="bi bi-calendar3 me-1"></i>{{ preparation_order.date|date:"d/m/Y H:i" }}</small>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<span class="badge {{ preparation_order.status_badge_class }} fs-6">
<i class="bi {{ preparation_order.status_icon }} me-1"></i>{{ preparation_order.get_status_display }}
</span>
{% if is_reopened %}
<span class="badge bg-warning text-dark fs-6">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réouvert" %}
</span>
{% else %}
<span class="badge {{ preparation_order.status_badge_class }} fs-6">
<i class="bi {{ preparation_order.status_icon }} me-1"></i>{{ preparation_order.get_status_display }}
</span>
{% endif %}
<span class="badge {{ preparation_order.priority_badge_class }}">
{{ preparation_order.get_priority_display }}
</span>
</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-2 fs-3"></i>
<div>
<h6 class="alert-heading mb-1 fw-bold"><i class="bi bi-exclamation-circle-fill me-1"></i>{% translate "Bon réouvert pour modification" %}</h6>
<p class="mb-0 small" style="font-size:0.8rem;">{% translate "Ce bon a fait l'objet d'un traitement initial. Les deltas et restitutions seront traités à la confirmation." %}</p>
</div>
</div>
</div>
{% endif %}
<!-- Intervention liée -->
{% if preparation_order.intervention %}
<div class="info-card">
@ -250,6 +268,42 @@
</div>
{% endif %}
{% endif %}
<!-- Delta / Restitution info -->
{% if has_executed_outputs %}
<div class="mt-2" style="font-size:0.82rem;">
{% if item_data.executed_qty > 0 %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark"><i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
<small class="text-muted ms-1">({% translate "Sorti" %}: {{ item_data.executed_qty }})</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark"><i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {{ item_data.item.product.unit }} {% translate "à restituer" %}</span>
<small class="text-muted ms-1">({% 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"><i class="bi bi-box-arrow-right me-1"></i>{{ item_data.item.quantity }} {{ item_data.item.product.unit }} {% translate "à prélever" %}</span>
{% endif %}
</div>
{% endif %}
<!-- Switch Préparé -->
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center">
<span class="fw-semibold text-secondary" style="font-size:0.82rem;">
<i class="bi bi-check-square me-1"></i>{% translate "Préparé" %}
</span>
<div class="form-check form-switch 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{% endif %}>
</div>
</div>
{% if item_data.production_order %}
<div class="mt-2">
<a href="{% url 'mobile:stock_production_order_detail_mobile' item_data.production_order.pk %}"
@ -307,11 +361,61 @@
</a>
</div>
{% endif %}
<!-- Switch Préparé pour autres produits -->
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center">
<span class="fw-semibold text-secondary" style="font-size:0.82rem;">
<i class="bi bi-check-square me-1"></i>{% translate "Préparé" %}
</span>
<div class="form-check form-switch 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{% endif %}>
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if not items_with_stock and not other_items %}
<!-- Articles supprimés du bon (restitution au stock) -->
{% if deleted_items_to_return %}
<div class="section-title mt-3 text-info">
<i class="bi bi-arrow-return-left"></i>{% translate "Articles supprimés (restitution au stock)" %}
<span class="badge bg-info text-dark ms-1" style="font-size:0.7rem;">{{ deleted_items_to_return|length }}</span>
</div>
{% for del in deleted_items_to_return %}
<div class="product-card border-info" style="background:#f4fafd;">
<div class="d-flex justify-content-between align-items-start">
<div class="fw-semibold text-dark" style="font-size:0.92rem; flex:1;">{{ del.product.name }}</div>
<span class="badge bg-info text-dark ms-2">+{{ del.executed_qty }} {{ del.product.unit }} {% translate "à restituer" %}</span>
</div>
<div class="product-meta mt-1">
<code>{{ del.product.code }}</code> &nbsp;·&nbsp; <small class="text-muted"><i class="bi bi-geo-alt me-1"></i>{{ del.location_info }}</small>
</div>
{% if del.item %}
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center">
<span class="fw-semibold text-info" style="font-size:0.82rem;">
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué / Préparé" %}
</span>
<div class="form-check form-switch 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{% endif %}>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% if not items_with_stock and not other_items and not deleted_items_to_return %}
<div class="text-center text-muted py-4">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
{% translate "Aucun article dans ce bon." %}
@ -322,9 +426,9 @@
{% if can_manage %}
<div class="d-grid gap-2 mt-3">
{% if preparation_order.status == 'to_process' or preparation_order.status == 'pending' %}
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i>{% translate "Traiter le bon" %}
</a>
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#confirmProcessModalMobile">
<i class="bi bi-check2-circle me-1"></i>{% translate "Signaler comme traité" %}
</button>
{% if can_manage_purchase %}
<a href="{% url 'stock:preparation_order_create_purchase' preparation_order.pk %}" class="btn btn-primary">
<i class="bi bi-cart-plus me-1"></i>{% translate "Créer un bon de commande" %}
@ -522,6 +626,93 @@ document.addEventListener('DOMContentLoaded', function() {
modalIsLoan.addEventListener('change', toggleModalReturnDate);
toggleModalReturnDate();
}
// Bascule Ajax état Préparé
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_token }}', '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;
});
});
});
</script>
<!-- Modale mobile de confirmation du traitement -->
<div class="modal fade" id="confirmProcessModalMobile" tabindex="-1" aria-labelledby="confirmProcessModalMobileLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="confirmProcessModalMobileLabel">
<i class="bi bi-check-circle me-2"></i>{% translate "Confirmation du traitement" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></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">
<input type="hidden" name="next" value="{% url 'mobile:stock_preparation_order_detail_mobile' preparation_order.pk %}">
<div class="modal-body">
<p class="fw-bold text-success mb-2" style="font-size:0.9rem;">
<i class="bi bi-info-circle me-1"></i>
{% translate "Signaler ce bon de préparation comme TRAITÉ ?" %}
</p>
<div class="card mb-2 border shadow-sm">
<div class="card-header bg-light py-1 px-2" style="font-size:0.8rem;">
<strong>{% translate "Mouvements de stock" %} :</strong>
</div>
<ul class="list-group list-group-flush small">
{% for item_data in items_with_stock %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<span class="text-truncate" style="max-width:180px;">{{ item_data.item.product.name }}</span>
{% if has_executed_outputs %}
{% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark">+{{ item_data.delta }} sorti</span>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark">{{ item_data.delta }} restitué</span>
{% else %}
<span class="badge bg-secondary">Inchangé</span>
{% endif %}
{% else %}
<span class="badge bg-success">-{{ item_data.item.quantity }} {{ item_data.item.product.unit }}</span>
{% endif %}
</li>
{% endfor %}
{% for del in deleted_items_to_return %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<span class="text-truncate" style="max-width:180px;">{{ del.product.name }} <span class="badge bg-danger">Supprimé</span></span>
<span class="badge bg-info text-dark">+{{ del.executed_qty }} restitué</span>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success btn-sm">
<i class="bi bi-check-lg me-1"></i> {% translate "Confirmer et marquer traité" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View file

@ -4461,6 +4461,9 @@ def preparation_order_process(request, pk):
messages.success(request, _("Le bon de préparation #{} a été traité avec succès.").format(
preparation_order.order_number
))
next_url = request.POST.get('next') or request.META.get('HTTP_REFERER', '')
if 'mobile' in str(next_url):
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
return redirect('stock:preparation_order_detail', pk=pk)
# Préparer les données pour le formulaire