feat: enable preparation order reopening and track returned quantities for deleted items
This commit is contained in:
parent
d5a404037c
commit
ea2bb4817c
3 changed files with 244 additions and 8 deletions
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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> · <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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue