feat: add usage tracking for material items in preparation orders and include status in reporting

This commit is contained in:
kdeterme 2026-09-11 10:48:36 +02:00
parent 4424c2d9c4
commit 26c07a769f
13 changed files with 1217 additions and 136 deletions

View file

@ -810,6 +810,40 @@ class Intervention(models.Model):
status__in=['to_process', 'pending']
).exists()
@property
def has_material(self):
"""Indique si l'intervention dispose de matériel mentionné dans ses bons de préparation."""
if not hasattr(self, 'preparation_orders'):
return False
return self.preparation_orders.filter(
Q(preparationorderitem__isnull=False) | Q(other_items__isnull=False)
).distinct().exists()
def get_material_stats(self):
"""
Retourne le statut d'utilisation du matériel pour cette intervention :
- total : nombre total d'articles (catalogue + autres)
- used : nombre d'articles marqués comme utilisés
- all_used : True si tous les articles sont utilisés (et total > 0)
- has_material : True si au moins un article existe
"""
if not hasattr(self, 'preparation_orders'):
return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False}
from stock.models import PreparationOrderItem, PreparationOrderOtherItem
order_ids = list(self.preparation_orders.values_list('id', flat=True))
if not order_ids:
return {'total': 0, 'used': 0, 'all_used': False, 'has_material': False}
std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids)
other_items = PreparationOrderOtherItem.objects.filter(preparation_order_id__in=order_ids)
total = std_items.count() + other_items.count()
used = std_items.filter(is_used=True).count() + other_items.filter(is_used=True).count()
return {
'total': total,
'used': used,
'all_used': (total > 0 and used == total),
'has_material': total > 0,
}
def get_associated_observations(self):
"""
Retourne le QuerySet de toutes les observations associées à cette intervention :

View file

@ -1,86 +1,235 @@
{% load i18n %}
{% if can_create_preparation_order or preparation_orders %}
<div id="section-stock" class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h4><i class="bi bi-box-seam"></i> {% translate "Stock" %}</h4>
<div id="section-stock" class="card mb-4 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
<h4 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-box-seam text-primary"></i>
<span>{% translate "Matériel / Stock" %}</span>
</h4>
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if material_stats.has_material %}
<span class="badge bg-light text-dark border py-2 px-2" id="desktop-material-stats-badge" title="{% translate 'Matériel utilisé sur cette intervention' %}">
<i class="bi bi-tools text-primary me-1"></i>
<span id="desktop-material-used-count">{{ material_stats.used }}</span> / <span id="desktop-material-total-count">{{ material_stats.total }}</span> {% translate "utilisé(s)" %}
</span>
<button type="button"
class="btn btn-sm btn-outline-success d-flex align-items-center gap-1"
id="btn-desktop-toggle-all-material"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', null)"
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% else %}title="{% translate 'Tout cocher ou tout décocher pour cette intervention' %}"{% endif %}>
<i class="bi bi-check-all fs-6"></i>
<span id="btn-desktop-toggle-all-text">{% if material_stats.all_used %}{% translate "Tout décocher" %}{% else %}{% translate "Tout cocher" %}{% endif %}</span>
</button>
{% endif %}
{% if can_create_preparation_order %}
<a href="{% url 'stock:new_preparation_order_from_intervention' intervention.id %}"
class="btn btn-light border-0"
class="btn btn-sm btn-primary d-flex align-items-center gap-1"
title="{% translate 'Créer un bon de préparation' %}">
<i class="bi bi-plus-circle text-primary"></i>
<i class="bi bi-plus-circle"></i> {% translate "Nouveau bon" %}
</a>
{% endif %}
</div>
<div class="card-body">
{% if preparation_orders %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Numéro" %}</th>
<th>{% translate "Date" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Articles" %}</th>
<th>{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for order in preparation_orders %}
<tr>
<td><a href="{% url 'stock:preparation_order_detail' order.pk %}"><strong>{{ order.order_number }}</strong></a></td>
<td>{{ order.date|date:"d/m/Y H:i" }}</td>
<td>
{% if order.status == 'to_process' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split"></i> {{ order.get_status_display }}
</span>
{% elif order.status == 'pending' %}
<span class="badge bg-info">
<i class="bi bi-clock"></i> {{ order.get_status_display }}
</span>
{% elif order.status == 'processed' %}
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> {{ order.get_status_display }}
</span>
{% endif %}
</td>
<td>
{% with items=order.preparationorderitem_set.all %}
{% if items %}
<small>
{% for item in items|slice:":3" %}
{{ item.product.name }} ({{ item.quantity }}){% if not forloop.last %}, {% endif %}
{% endfor %}
{% if items.count > 3 %}
<span class="text-muted">... +{{ items.count|add:"-3" }}</span>
{% endif %}
</small>
{% else %}
<span class="text-muted"><em>{% translate "Aucun article" %}</em></span>
{% endif %}
{% endwith %}
</td>
<td>
<a href="{% url 'stock:preparation_order_detail' order.pk %}"
class="btn btn-sm btn-outline-primary"
title="{% translate 'Voir les détails' %}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
{% translate "Aucun bon de préparation créé pour cette intervention." %}
</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Follow-up reports -->
<div class="card-body p-3">
{% if preparation_orders %}
{% for order in preparation_orders %}
<div class="card mb-3 border {% if forloop.last %}mb-0{% endif %}">
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'stock:preparation_order_detail' order.pk %}" class="fw-bold text-decoration-none fs-6">
<i class="bi bi-file-earmark-text text-primary me-1"></i>{{ order.order_number }}
</a>
<span class="text-muted small">
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
</span>
{% if order.status == 'to_process' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split me-1"></i>{{ order.get_status_display }}
</span>
{% elif order.status == 'pending' %}
<span class="badge bg-info">
<i class="bi bi-clock me-1"></i>{{ order.get_status_display }}
</span>
{% elif order.status == 'processed' %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>{{ order.get_status_display }}
</span>
{% elif order.status == 'cancelled' %}
<span class="badge bg-secondary">
<i class="bi bi-x-circle me-1"></i>{{ order.get_status_display }}
</span>
{% endif %}
{% if order.is_loan %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
<i class="bi bi-arrow-left-right me-1"></i>{% translate "Prêt" %}
</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<button type="button"
class="btn btn-sm btn-outline-secondary py-1 px-2"
style="font-size:0.75rem;"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ order.id }}')"
{% if not can_edit_intervention %}disabled{% endif %}
title="{% translate 'Cocher / décocher tout pour ce bon' %}">
<i class="bi bi-check2-all"></i> {% translate "Tout cocher (ce bon)" %}
</button>
{% endif %}
{% endwith %}
<a href="{% url 'stock:preparation_order_detail' order.pk %}"
class="btn btn-sm btn-outline-primary py-1 px-2"
title="{% translate 'Voir les détails du bon' %}">
<i class="bi bi-eye"></i>
</a>
</div>
</div>
<div class="card-body p-0">
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<div class="table-responsive mb-0">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 100px;" class="text-center">{% translate "Utilisé ?" %}</th>
<th>{% translate "Article" %}</th>
<th style="width: 150px;">{% translate "Référence" %}</th>
<th style="width: 120px;" class="text-center">{% translate "Quantité" %}</th>
<th style="width: 130px;" class="text-center">{% translate "Préparation" %}</th>
<th style="width: 130px;" class="text-center">{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input material-used-checkbox"
type="checkbox"
role="switch"
style="cursor: pointer; width: 2.2rem; height: 1.2rem;"
data-item-id="{{ item.id }}"
data-is-other="false"
data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %}
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}
onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)">
</div>
</td>
<td>
<span class="fw-semibold">{{ item.product.name }}</span>
{% if item.product.category %}
<small class="text-muted d-block">{{ item.product.category.name_fr }}</small>
{% endif %}
</td>
<td>
{% if item.product.code %}
<code class="text-muted">{{ item.product.code }}</code>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td class="text-center">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace fs-6">
&times; {{ item.quantity }}
</span>
</td>
<td class="text-center">
{% if item.is_prepared %}
<span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}">
<i class="bi bi-check2"></i> {% translate "Préparé" %}
</span>
{% else %}
<span class="badge bg-light text-muted border" title="{% translate 'En attente de préparation' %}">
<i class="bi bi-hourglass"></i> {% translate "En attente" %}
</span>
{% endif %}
</td>
<td class="text-center">
<span class="badge {% if item.is_used %}bg-success text-white{% else %}bg-secondary-subtle text-muted{% endif %} material-status-badge"
id="material-badge-item-{{ item.id }}">
{% if item.is_used %}
<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}
{% else %}
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
{% endif %}
</span>
</td>
</tr>
{% endfor %}
{% for item in other_items %}
<tr>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input material-used-checkbox"
type="checkbox"
role="switch"
style="cursor: pointer; width: 2.2rem; height: 1.2rem;"
data-item-id="{{ item.id }}"
data-is-other="true"
data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %}
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}
onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)">
</div>
</td>
<td>
<span class="fw-semibold">{{ item.description }}</span>
<span class="badge bg-light text-secondary border ms-1" style="font-size:0.7rem;">{% translate "Hors catalogue" %}</span>
{% if item.category %}
<small class="text-muted d-block">{{ item.category.name_fr }}</small>
{% endif %}
</td>
<td>
<span class="text-muted">—</span>
</td>
<td class="text-center">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace fs-6">
&times; {{ item.quantity }}
</span>
</td>
<td class="text-center">
{% if item.is_prepared %}
<span class="badge bg-success-subtle text-success border border-success-subtle" title="{% translate 'Préparé au magasin' %}">
<i class="bi bi-check2"></i> {% translate "Préparé" %}
</span>
{% else %}
<span class="badge bg-light text-muted border" title="{% translate 'En attente de préparation' %}">
<i class="bi bi-hourglass"></i> {% translate "En attente" %}
</span>
{% endif %}
</td>
<td class="text-center">
<span class="badge {% if item.is_used %}bg-success text-white{% else %}bg-secondary-subtle text-muted{% endif %} material-status-badge"
id="material-badge-other-{{ item.id }}">
{% if item.is_used %}
<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}
{% else %}
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small fst-italic">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun article sur ce bon de préparation." %}
</div>
{% endif %}
{% endwith %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
{% translate "Aucun bon de préparation créé pour cette intervention." %}
</p>
{% endif %}
</div>
</div>
{% endif %}

View file

@ -600,6 +600,132 @@ function toggleInterventionChecked(interventionId, checkboxEl) {
alert('{% translate "Erreur réseau." %}');
});
}
function updateMaterialUI(data) {
const usedCountEls = document.querySelectorAll('#desktop-material-used-count, #mobile-material-used-count');
const totalCountEls = document.querySelectorAll('#desktop-material-total-count, #mobile-material-total-count');
usedCountEls.forEach(el => el.textContent = data.used_count);
totalCountEls.forEach(el => el.textContent = data.total_count);
const toggleAllBtnTexts = document.querySelectorAll('#btn-desktop-toggle-all-text, #btn-mobile-toggle-all-text');
toggleAllBtnTexts.forEach(el => {
el.textContent = data.all_used ? "{% translate 'Tout décocher' %}" : "{% translate 'Tout cocher' %}";
});
if (data.item_id !== undefined && data.is_other !== undefined) {
const prefix = data.is_other ? 'other-' : 'item-';
const badges = document.querySelectorAll(`#material-badge-${prefix}${data.item_id}, #mobile-material-item-badge-${prefix}${data.item_id}`);
badges.forEach(b => {
if (data.is_used) {
b.className = 'badge bg-success text-white material-status-badge';
b.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}';
} else {
b.className = 'badge bg-secondary-subtle text-muted material-status-badge';
b.innerHTML = '<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}';
}
});
}
}
function toggleMaterialItemUsed(interventionId, itemId, isOther, checkboxEl) {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
checkboxEl.disabled = true;
const formData = new FormData();
formData.append('is_other', isOther ? 'true' : 'false');
formData.append('is_used', checkboxEl.checked ? 'true' : 'false');
fetch(`/interventions/${interventionId}/material/${itemId}/toggle-used/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
checkboxEl.disabled = false;
if (data.success) {
checkboxEl.checked = data.is_used;
const otherFlag = isOther ? 'true' : 'false';
document.querySelectorAll(`.material-used-checkbox[data-item-id="${itemId}"][data-is-other="${otherFlag}"]`).forEach(cb => {
cb.checked = data.is_used;
});
updateMaterialUI(data);
} else {
checkboxEl.checked = !checkboxEl.checked;
alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}");
}
})
.catch(() => {
checkboxEl.disabled = false;
checkboxEl.checked = !checkboxEl.checked;
alert("{% translate 'Erreur réseau.' %}");
});
}
function toggleAllMaterialUsed(interventionId, orderId) {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
const buttons = document.querySelectorAll('#btn-desktop-toggle-all-material, #btn-mobile-toggle-all-material');
buttons.forEach(b => b.disabled = true);
const formData = new FormData();
if (orderId) {
formData.append('order_id', orderId);
}
fetch(`/interventions/${interventionId}/material/toggle-all-used/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
buttons.forEach(b => b.disabled = false);
if (data.success) {
const isAllUsed = data.all_used;
const selector = orderId
? `.material-used-checkbox[data-order-id="${orderId}"]`
: '.material-used-checkbox';
document.querySelectorAll(selector).forEach(cb => {
cb.checked = isAllUsed;
const itemId = cb.dataset.itemId;
const isOther = cb.dataset.isOther === 'true';
const prefix = isOther ? 'other-' : 'item-';
const badges = document.querySelectorAll(`#material-badge-${prefix}${itemId}, #mobile-material-item-badge-${prefix}${itemId}`);
badges.forEach(b => {
if (isAllUsed) {
b.className = 'badge bg-success text-white material-status-badge';
b.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}';
} else {
b.className = 'badge bg-secondary-subtle text-muted material-status-badge';
b.innerHTML = '<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}';
}
});
});
updateMaterialUI({
used_count: data.used_count,
total_count: data.total_count,
all_used: data.intervention_all_used !== undefined ? data.intervention_all_used : data.all_used
});
} else {
alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}");
}
})
.catch(() => {
buttons.forEach(b => b.disabled = false);
alert("{% translate 'Erreur réseau.' %}");
});
}
</script>
<script>

View file

@ -1095,7 +1095,7 @@
<div class="card mt-4 mb-4 bg-white" id="section-stock">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold d-flex align-items-center gap-1">
<i class="bi bi-box-seam me-1 text-primary"></i>{% translate "Stock" %}
<i class="bi bi-box-seam me-1 text-primary"></i>{% translate "Matériel / Stock" %}
</span>
<div class="d-flex align-items-center gap-2">
{% if preparation_orders %}
@ -1112,67 +1112,171 @@
{% endif %}
</div>
</div>
<div class="card-body">
{% if material_stats.has_material %}
<div class="bg-light px-3 py-2 border-bottom d-flex justify-content-between align-items-center flex-wrap gap-2">
<span class="badge bg-white text-dark border py-1 px-2" id="mobile-material-stats-badge">
<i class="bi bi-tools text-primary me-1"></i>
<span id="mobile-material-used-count">{{ material_stats.used }}</span> / <span id="mobile-material-total-count">{{ material_stats.total }}</span> {% translate "utilisé(s)" %}
</span>
<button type="button"
class="btn btn-sm btn-outline-success d-flex align-items-center gap-1 py-1 px-2"
id="btn-mobile-toggle-all-material"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', null)"
{% if not can_edit_intervention %}disabled{% endif %}
style="font-size:0.75rem;">
<i class="bi bi-check-all fs-6"></i>
<span id="btn-mobile-toggle-all-text">{% if material_stats.all_used %}{% translate "Tout décocher" %}{% else %}{% translate "Tout cocher" %}{% endif %}</span>
</button>
</div>
{% endif %}
<div class="card-body p-2">
{% if preparation_orders %}
<div class="list-group list-group-flush">
{% for order in preparation_orders %}
<div class="list-group-item px-0 py-2 {% if not forloop.last %}border-bottom{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}" class="fw-semibold text-decoration-none">
#{{ order.order_number }}
</a>
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
{% for order in preparation_orders %}
<div class="card mb-3 border {% if forloop.last %}mb-0{% endif %}">
<div class="card-header bg-light py-2 px-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}" class="fw-semibold text-decoration-none">
#{{ order.order_number }}
</a>
<span class="badge {{ order.status_badge_class }}">
<i class="bi {{ order.status_icon }} me-1"></i>{{ order.get_status_display }}
</span>
<span class="badge {{ order.priority_badge_class }}">
{{ order.get_priority_display }}
</span>
{% if order.is_loan %}
<span class="badge bg-primary">
<i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %}
</span>
<span class="badge {{ order.priority_badge_class }}">
{{ order.get_priority_display }}
</span>
{% if order.is_loan %}
<span class="badge bg-primary">
<i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %}
</span>
{% endif %}
</div>
<div class="text-muted small mb-1">
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
</div>
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<div class="d-flex flex-wrap gap-1 mt-1">
{% for item in items|slice:":3" %}
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
{{ item.product.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% for item in other_items|slice:":2" %}
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
{{ item.name }} &times; {{ item.quantity }}
</span>
{% endfor %}
{% with total_count=items.count|add:other_items.count %}
{% if total_count > 3 %}
<span class="badge bg-secondary-subtle text-muted" style="font-size:0.75rem;">
+{{ total_count|add:"-3" }}
</span>
{% endif %}
{% endwith %}
</div>
{% else %}
<span class="text-muted small fst-italic">{% translate "Aucun article" %}</span>
{% endif %}
{% endwith %}
{% endif %}
</div>
<div class="text-muted small">
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
</div>
</div>
<div class="d-flex align-items-center gap-1">
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-2"
style="font-size:0.7rem;"
onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ order.id }}')"
{% if not can_edit_intervention %}disabled{% endif %}
title="{% translate 'Cocher / décocher tout pour ce bon' %}">
<i class="bi bi-check2-all"></i>
</button>
{% endif %}
{% endwith %}
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}"
class="btn btn-sm btn-outline-primary align-self-center flex-shrink-0"
class="btn btn-sm btn-outline-primary p-1"
title="{% translate 'Voir les détails' %}">
<i class="bi bi-chevron-right"></i>
</a>
</div>
</div>
{% endfor %}
<div class="card-body p-0">
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
{% if items or other_items %}
<div class="list-group list-group-flush">
{% for item in items %}
<div class="list-group-item px-3 py-2 d-flex align-items-center justify-content-between gap-2">
<div class="d-flex align-items-center gap-2 flex-grow-1" style="min-width: 0;">
<div class="form-check form-switch mb-0">
<input class="form-check-input material-used-checkbox"
type="checkbox"
role="switch"
style="cursor: pointer; width: 2.2rem; height: 1.2rem;"
data-item-id="{{ item.id }}"
data-is-other="false"
data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %}
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}
onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', false, this)">
</div>
<div class="text-truncate">
<div class="fw-semibold text-truncate small">{{ item.product.name }}</div>
<div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;">
{% if item.product.code %}<span><code>{{ item.product.code }}</code></span>{% endif %}
{% if item.is_prepared %}
<span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span>
{% else %}
<span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span>
{% endif %}
</div>
</div>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace" style="font-size: 0.75rem;">
&times; {{ item.quantity }}
</span>
<span class="badge {% if item.is_used %}bg-success text-white{% else %}bg-secondary-subtle text-muted{% endif %} material-status-badge"
id="mobile-material-item-badge-item-{{ item.id }}"
style="font-size: 0.7rem;">
{% if item.is_used %}
<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}
{% else %}
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
{% endif %}
</span>
</div>
</div>
{% endfor %}
{% for item in other_items %}
<div class="list-group-item px-3 py-2 d-flex align-items-center justify-content-between gap-2">
<div class="d-flex align-items-center gap-2 flex-grow-1" style="min-width: 0;">
<div class="form-check form-switch mb-0">
<input class="form-check-input material-used-checkbox"
type="checkbox"
role="switch"
style="cursor: pointer; width: 2.2rem; height: 1.2rem;"
data-item-id="{{ item.id }}"
data-is-other="true"
data-order-id="{{ order.id }}"
{% if item.is_used %}checked{% endif %}
{% if not can_edit_intervention %}disabled title="{% translate 'Permissions insuffisantes' %}"{% endif %}
onchange="toggleMaterialItemUsed('{{ intervention.id }}', '{{ item.id }}', true, this)">
</div>
<div class="text-truncate">
<div class="fw-semibold text-truncate small">
{{ item.description }}
<span class="badge bg-light text-secondary border ms-1" style="font-size:0.65rem;">{% translate "Hors cat." %}</span>
</div>
<div class="text-muted d-flex align-items-center gap-1" style="font-size: 0.7rem;">
{% if item.is_prepared %}
<span class="text-success"><i class="bi bi-check2"></i> {% translate "Préparé" %}</span>
{% else %}
<span class="text-muted"><i class="bi bi-hourglass"></i> {% translate "En attente" %}</span>
{% endif %}
</div>
</div>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace" style="font-size: 0.75rem;">
&times; {{ item.quantity }}
</span>
<span class="badge {% if item.is_used %}bg-success text-white{% else %}bg-secondary-subtle text-muted{% endif %} material-status-badge"
id="mobile-material-item-badge-other-{{ item.id }}"
style="font-size: 0.7rem;">
{% if item.is_used %}
<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}
{% else %}
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
{% endif %}
</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-3 text-muted small fst-italic">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun article sur ce bon de préparation." %}
</div>
{% endif %}
{% endwith %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0 small">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun bon de préparation créé pour cette intervention." %}
@ -6817,4 +6921,136 @@ function toggleSubscription(interventionId) {
}
</script>
<script>
function updateMaterialUI(data) {
const usedCountEls = document.querySelectorAll('#desktop-material-used-count, #mobile-material-used-count');
const totalCountEls = document.querySelectorAll('#desktop-material-total-count, #mobile-material-total-count');
usedCountEls.forEach(el => el.textContent = data.used_count);
totalCountEls.forEach(el => el.textContent = data.total_count);
const toggleAllBtnTexts = document.querySelectorAll('#btn-desktop-toggle-all-text, #btn-mobile-toggle-all-text');
toggleAllBtnTexts.forEach(el => {
el.textContent = data.all_used ? "{% translate 'Tout décocher' %}" : "{% translate 'Tout cocher' %}";
});
if (data.item_id !== undefined && data.is_other !== undefined) {
const prefix = data.is_other ? 'other-' : 'item-';
const badges = document.querySelectorAll(`#material-badge-${prefix}${data.item_id}, #mobile-material-item-badge-${prefix}${data.item_id}`);
badges.forEach(b => {
if (data.is_used) {
b.className = 'badge bg-success text-white material-status-badge';
b.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}';
} else {
b.className = 'badge bg-secondary-subtle text-muted material-status-badge';
b.innerHTML = '<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}';
}
});
}
}
function toggleMaterialItemUsed(interventionId, itemId, isOther, checkboxEl) {
const csrfToken = (typeof getCookie === 'function' ? getCookie('csrftoken') : '')
|| document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1]
|| '';
checkboxEl.disabled = true;
const formData = new FormData();
formData.append('is_other', isOther ? 'true' : 'false');
formData.append('is_used', checkboxEl.checked ? 'true' : 'false');
fetch(`/interventions/${interventionId}/material/${itemId}/toggle-used/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
checkboxEl.disabled = false;
if (data.success) {
checkboxEl.checked = data.is_used;
const otherFlag = isOther ? 'true' : 'false';
document.querySelectorAll(`.material-used-checkbox[data-item-id="${itemId}"][data-is-other="${otherFlag}"]`).forEach(cb => {
cb.checked = data.is_used;
});
updateMaterialUI(data);
} else {
checkboxEl.checked = !checkboxEl.checked;
alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}");
}
})
.catch(() => {
checkboxEl.disabled = false;
checkboxEl.checked = !checkboxEl.checked;
alert("{% translate 'Erreur réseau.' %}");
});
}
function toggleAllMaterialUsed(interventionId, orderId) {
const csrfToken = (typeof getCookie === 'function' ? getCookie('csrftoken') : '')
|| document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1]
|| '';
const buttons = document.querySelectorAll('#btn-desktop-toggle-all-material, #btn-mobile-toggle-all-material');
buttons.forEach(b => b.disabled = true);
const formData = new FormData();
if (orderId) {
formData.append('order_id', orderId);
}
fetch(`/interventions/${interventionId}/material/toggle-all-used/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(r => r.json())
.then(data => {
buttons.forEach(b => b.disabled = false);
if (data.success) {
const isAllUsed = data.all_used;
const selector = orderId
? `.material-used-checkbox[data-order-id="${orderId}"]`
: '.material-used-checkbox';
document.querySelectorAll(selector).forEach(cb => {
cb.checked = isAllUsed;
const itemId = cb.dataset.itemId;
const isOther = cb.dataset.isOther === 'true';
const prefix = isOther ? 'other-' : 'item-';
const badges = document.querySelectorAll(`#material-badge-${prefix}${itemId}, #mobile-material-item-badge-${prefix}${itemId}`);
badges.forEach(b => {
if (isAllUsed) {
b.className = 'badge bg-success text-white material-status-badge';
b.innerHTML = '<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}';
} else {
b.className = 'badge bg-secondary-subtle text-muted material-status-badge';
b.innerHTML = '<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}';
}
});
});
updateMaterialUI({
used_count: data.used_count,
total_count: data.total_count,
all_used: data.intervention_all_used !== undefined ? data.intervention_all_used : data.all_used,
});
} else {
alert(data.error || "{% translate 'Erreur lors de la mise à jour.' %}");
}
})
.catch(() => {
buttons.forEach(b => b.disabled = false);
alert("{% translate 'Erreur réseau.' %}");
});
}
</script>
{% endblock extra_scripts %}

View file

@ -0,0 +1,310 @@
import json
from datetime import timedelta
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from common.models import Role, UserConfig, Thematic, UserThematics, UserContractAccess, UserContractStatusPermission
from contracts.models import Company, CompanyTeam, CompanyMember, Contract
from interventions.models import Intervention
from stock.models import Product, PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem
class MaterialUsedTests(TestCase):
def setUp(self):
self.User = get_user_model()
self.role_tech, _ = Role.objects.get_or_create(name='technician')
self.company = Company.objects.create(name='Entreprise Stock')
self.team = CompanyTeam.objects.create(name='Équipe Stock', company=self.company)
self.thematic = Thematic.objects.create(code='roads', name_fr='Voirie', name_nl='Wegen')
# Utilisateur avec droits d'édition
self.user_editor = self.User.objects.create_user(username='editor_user', password='password123')
self.config_editor = UserConfig.objects.create(user=self.user_editor, is_intern=False)
self.config_editor.roles.add(self.role_tech)
self.member_editor = CompanyMember.objects.create(user=self.user_editor, name='Editeur')
self.member_editor.teams.add(self.team)
# Utilisateur sans droits
self.user_no_perm = self.User.objects.create_user(username='no_perm_user', password='password123')
self.config_no_perm = UserConfig.objects.create(user=self.user_no_perm, is_intern=False)
self.config_no_perm.roles.add(self.role_tech)
# Contrat
self.contract = Contract.objects.create(
company=self.company,
contract_number='C-STOCK-TEST',
start_date=timezone.now().date() - timedelta(days=5),
end_date=timezone.now().date() + timedelta(days=30),
is_active=True,
allow_team_members_edit=True,
)
self.contract.thematics.add(self.thematic)
# Permissions d'accès pour l'éditeur
UserThematics.objects.create(
user_config=self.config_editor,
thematic=self.thematic,
can_view_interventions=True,
can_edit_interventions=True,
)
uca = UserContractAccess.objects.create(
user_config=self.config_editor,
contract=self.contract,
can_view_interventions=True,
)
for status in ['assigned', 'in_progress', 'finished']:
UserContractStatusPermission.objects.create(
user_contract=uca,
status=status,
can_view=True,
can_edit=True,
can_change_status_to=True,
)
# Intervention
self.intervention = Intervention.objects.create(
title='Intervention avec Matériel',
thematic=self.thematic,
contract=self.contract,
assigned_provider=self.company,
assigned_member=self.member_editor,
status='assigned',
)
# Produits et Bon de préparation
self.product1 = Product.objects.create(code='P1', name='Panneau A1', sku='SKU-P1')
self.product2 = Product.objects.create(code='P2', name='Poteau acier', sku='SKU-P2')
self.order1 = PreparationOrder.objects.create(
order_number='BP-001',
intervention=self.intervention,
status='to_process',
)
self.item1 = PreparationOrderItem.objects.create(
preparation_order=self.order1,
product=self.product1,
quantity=2,
is_used=False,
)
self.item2 = PreparationOrderItem.objects.create(
preparation_order=self.order1,
product=self.product2,
quantity=1,
is_used=False,
)
self.other_item1 = PreparationOrderOtherItem.objects.create(
preparation_order=self.order1,
description='Vis et écrous 10mm',
quantity=10,
is_used=False,
)
self.client_editor = Client()
self.client_editor.login(username='editor_user', password='password123')
self.client_no_perm = Client()
self.client_no_perm.login(username='no_perm_user', password='password123')
def test_model_methods_and_material_stats(self):
"""Vérifie le calcul des statistiques de matériel sur Intervention et PreparationOrder."""
# Initialement aucun article n'est marqué comme utilisé
stats = self.intervention.get_material_stats()
self.assertTrue(stats['has_material'])
self.assertEqual(stats['total'], 3)
self.assertEqual(stats['used'], 0)
self.assertFalse(stats['all_used'])
self.assertFalse(self.order1.all_items_used)
self.assertEqual(self.order1.used_items_count, 0)
self.assertEqual(self.order1.total_items_count, 3)
# Marquer un article catalogue comme utilisé
self.item1.is_used = True
self.item1.save()
stats = self.intervention.get_material_stats()
self.assertEqual(stats['used'], 1)
self.assertFalse(stats['all_used'])
# Marquer tous les articles comme utilisés
self.item2.is_used = True
self.item2.save()
self.other_item1.is_used = True
self.other_item1.save()
stats = self.intervention.get_material_stats()
self.assertEqual(stats['used'], 3)
self.assertTrue(stats['all_used'])
self.assertTrue(self.order1.all_items_used)
def test_other_item_name_property(self):
"""Vérifie que la propriété name sur PreparationOrderOtherItem renvoie description."""
self.assertEqual(self.other_item1.name, 'Vis et écrous 10mm')
def test_toggle_material_used_endpoint_catalog_item(self):
"""Vérifie le toggle d'un article catalogue via POST JSON."""
url = reverse('interventions:toggle_material_used', kwargs={
'intervention_id': self.intervention.id,
'item_id': self.item1.id,
})
# Basculer vers is_used=True
response = self.client_editor.post(url, {'is_other': 'false', 'is_used': 'true'})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertTrue(data['is_used'])
self.assertEqual(data['used_count'], 1)
self.assertEqual(data['total_count'], 3)
self.assertFalse(data['all_used'])
self.item1.refresh_from_db()
self.assertTrue(self.item1.is_used)
# Basculer vers is_used=False
response = self.client_editor.post(url, {'is_other': 'false', 'is_used': 'false'})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertFalse(data['is_used'])
self.assertEqual(data['used_count'], 0)
self.item1.refresh_from_db()
self.assertFalse(self.item1.is_used)
def test_toggle_material_used_endpoint_other_item(self):
"""Vérifie le toggle d'un article hors catalogue (other_item)."""
url = reverse('interventions:toggle_material_used', kwargs={
'intervention_id': self.intervention.id,
'item_id': self.other_item1.id,
})
response = self.client_editor.post(url, {'is_other': 'true', 'is_used': 'true'})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertTrue(data['is_used'])
self.assertTrue(data['is_other'])
self.other_item1.refresh_from_db()
self.assertTrue(self.other_item1.is_used)
def test_toggle_material_used_permission_denied(self):
"""Un utilisateur sans permissions d'édition ne peut pas cocher un article."""
url = reverse('interventions:toggle_material_used', kwargs={
'intervention_id': self.intervention.id,
'item_id': self.item1.id,
})
response = self.client_no_perm.post(url, {'is_other': 'false', 'is_used': 'true'})
self.assertEqual(response.status_code, 403)
data = response.json()
self.assertFalse(data['success'])
self.item1.refresh_from_db()
self.assertFalse(self.item1.is_used)
def test_toggle_all_material_used_intervention(self):
"""Vérifie l'action 'Tout cocher' / 'Tout décocher' pour l'ensemble de l'intervention."""
url = reverse('interventions:toggle_all_material_used', kwargs={
'intervention_id': self.intervention.id,
})
# 1. Tout cocher
response = self.client_editor.post(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertTrue(data['all_used'])
self.assertEqual(data['used_count'], 3)
self.assertEqual(data['total_count'], 3)
self.item1.refresh_from_db()
self.item2.refresh_from_db()
self.other_item1.refresh_from_db()
self.assertTrue(self.item1.is_used)
self.assertTrue(self.item2.is_used)
self.assertTrue(self.other_item1.is_used)
# 2. Tout décocher (puisque tout était déjà coché)
response = self.client_editor.post(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertFalse(data['all_used'])
self.assertEqual(data['used_count'], 0)
self.item1.refresh_from_db()
self.item2.refresh_from_db()
self.other_item1.refresh_from_db()
self.assertFalse(self.item1.is_used)
self.assertFalse(self.item2.is_used)
self.assertFalse(self.other_item1.is_used)
def test_toggle_all_material_used_single_order(self):
"""Vérifie l'action 'Tout cocher' ciblée sur un bon de commande spécifique."""
# Créer un 2ème bon de commande avec un article
order2 = PreparationOrder.objects.create(
order_number='BP-002',
intervention=self.intervention,
status='to_process',
)
item_order2 = PreparationOrderItem.objects.create(
preparation_order=order2,
product=self.product1,
quantity=1,
is_used=False,
)
url = reverse('interventions:toggle_all_material_used', kwargs={
'intervention_id': self.intervention.id,
})
# Cocher uniquement order1
response = self.client_editor.post(url, {'order_id': self.order1.id})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertTrue(data['all_used']) # les articles du bon 1 sont tous cochés
self.assertFalse(data['intervention_all_used']) # l'intervention entière n'est pas encore tout cochée
self.assertEqual(data['used_count'], 3)
self.assertEqual(data['total_count'], 4)
self.item1.refresh_from_db()
self.item2.refresh_from_db()
self.other_item1.refresh_from_db()
item_order2.refresh_from_db()
self.assertTrue(self.item1.is_used)
self.assertTrue(self.item2.is_used)
self.assertTrue(self.other_item1.is_used)
self.assertFalse(item_order2.is_used) # non affecté !
def test_toggle_all_material_used_permission_denied(self):
"""Un utilisateur sans permission ne peut pas exécuter 'Tout cocher'."""
url = reverse('interventions:toggle_all_material_used', kwargs={
'intervention_id': self.intervention.id,
})
response = self.client_no_perm.post(url)
self.assertEqual(response.status_code, 403)
data = response.json()
self.assertFalse(data['success'])
def test_detail_views_contain_material_stats(self):
"""Vérifie que la page desktop et la page mobile contiennent material_stats dans le contexte."""
desktop_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': self.intervention.id})
response = self.client_editor.get(desktop_url)
self.assertEqual(response.status_code, 200)
self.assertIn('material_stats', response.context)
self.assertEqual(response.context['material_stats']['total'], 3)
self.assertContains(response, 'id="section-stock"')
self.assertContains(response, 'material-used-checkbox')
mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': self.intervention.id})
response_mobile = self.client_editor.get(mobile_url)
self.assertEqual(response_mobile.status_code, 200)
self.assertIn('material_stats', response_mobile.context)
self.assertContains(response_mobile, 'id="section-stock"')
self.assertContains(response_mobile, 'material-used-checkbox')

View file

@ -23,6 +23,8 @@ urlpatterns = [
path('<int:intervention_id>/toggle-subscription/', views.toggle_intervention_subscription, name='toggle_intervention_subscription'),
path('<int:intervention_id>/toggle-checked/', views.toggle_intervention_checked, name='toggle_intervention_checked'),
path('<int:intervention_id>/toggle-follow-up-performed/', views.toggle_follow_up_performed, name='toggle_follow_up_performed'),
path('<int:intervention_id>/material/<int:item_id>/toggle-used/', views.toggle_material_used, name='toggle_material_used'),
path('<int:intervention_id>/material/toggle-all-used/', views.toggle_all_material_used, name='toggle_all_material_used'),
path('<int:intervention_id>/update-correction-reason/', views.update_correction_reason, name='update_correction_reason'),
path('<int:intervention_id>/update-correction-details/', views.update_correction_details, name='update_correction_details'),
path('<int:intervention_id>/add-note/', views.intervention_add_note, name='intervention_add_note'),

View file

@ -512,7 +512,13 @@ def intervention_detail(request, intervention_id):
or (can_manage_links and user_config.can_manage_preparation_orders_any())
)
)
preparation_orders = intervention.preparation_orders.all().order_by('-date') if hasattr(intervention, 'preparation_orders') else []
preparation_orders = (
intervention.preparation_orders.all()
.prefetch_related('preparationorderitem_set__product', 'other_items')
.order_by('-date')
if hasattr(intervention, 'preparation_orders') else []
)
material_stats = intervention.get_material_stats()
# Config desktop pour le modal de liaison d'assets (même mécanique que mobile)
desktop_assets_config = None
@ -725,6 +731,7 @@ def intervention_detail(request, intervention_id):
# Bons de préparation
'can_create_preparation_order': can_create_preparation_order,
'preparation_orders': preparation_orders,
'material_stats': material_stats,
# Config modal liaison assets (desktop)
'desktop_assets_enabled': can_manage_links,
'desktop_assets_config': desktop_assets_config,
@ -3779,3 +3786,110 @@ def create_operations_from_selected_template(request, intervention_id):
'error': str(e)
}, status=500)
@login_required
@require_POST
def toggle_material_used(request, intervention_id, item_id):
"""
Bascule l'état is_used d'un article de matériel (catalogue ou autre item) lié à l'intervention.
"""
from interventions.permissions import can_edit_intervention
from stock.models import PreparationOrderItem, PreparationOrderOtherItem
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_edit_intervention(request.user, intervention):
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier cette intervention.")}, status=403)
is_other = request.POST.get('is_other') == 'true' or request.GET.get('is_other') == 'true'
set_value = request.POST.get('is_used')
order_ids = intervention.preparation_orders.values_list('id', flat=True)
if is_other:
try:
item = PreparationOrderOtherItem.objects.get(pk=item_id, preparation_order_id__in=order_ids)
except PreparationOrderOtherItem.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404)
else:
try:
item = PreparationOrderItem.objects.get(pk=item_id, preparation_order_id__in=order_ids)
except PreparationOrderItem.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Article introuvable pour cette intervention.")}, status=404)
if set_value is not None:
item.is_used = (set_value in ('true', '1', True))
else:
item.is_used = not item.is_used
item.save(update_fields=['is_used'])
stats = intervention.get_material_stats()
order = item.preparation_order
order_all_used = order.all_items_used
return JsonResponse({
'success': True,
'item_id': item.id,
'is_other': is_other,
'is_used': item.is_used,
'order_id': order.id,
'order_all_used': order_all_used,
'used_count': stats['used'],
'total_count': stats['total'],
'all_used': stats['all_used'],
})
@login_required
@require_POST
def toggle_all_material_used(request, intervention_id):
"""
Coche ou décoche l'ensemble du matériel de l'intervention (ou d'un bon de préparation spécifique).
Paramètres optionnels dans POST :
- order_id : si spécifié, applique uniquement au bon indiqué
- used : booléen optionnel ('true'/'false'). Si absent, bascule :
si tous sont utilisés -> passe à False, sinon -> passe à True.
"""
from interventions.permissions import can_edit_intervention
from stock.models import PreparationOrder, PreparationOrderItem, PreparationOrderOtherItem
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_edit_intervention(request.user, intervention):
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de modifier cette intervention.")}, status=403)
order_id = request.POST.get('order_id')
orders = intervention.preparation_orders.all()
if order_id:
orders = orders.filter(pk=order_id)
if not orders.exists():
return JsonResponse({'success': False, 'error': _("Bon de préparation introuvable.")}, status=404)
order_ids = list(orders.values_list('id', flat=True))
std_items = PreparationOrderItem.objects.filter(preparation_order_id__in=order_ids)
other_items = PreparationOrderOtherItem.objects.filter(preparation_order_id__in=order_ids)
total_target = std_items.count() + other_items.count()
if total_target == 0:
return JsonResponse({'success': True, 'all_used': False, 'used_count': 0, 'total_count': 0})
used_param = request.POST.get('used')
if used_param is not None:
target_state = (used_param in ('true', '1', True))
else:
target_used_count = std_items.filter(is_used=True).count() + other_items.filter(is_used=True).count()
target_state = not (target_used_count == total_target)
std_items.update(is_used=target_state)
other_items.update(is_used=target_state)
stats = intervention.get_material_stats()
return JsonResponse({
'success': True,
'all_used': target_state,
'intervention_all_used': stats['all_used'],
'used_count': stats['used'],
'total_count': stats['total'],
'order_id': int(order_id) if order_id else None,
})

View file

@ -1092,6 +1092,7 @@ def intervention_detail_mobile(request, intervention_id):
.order_by('-date')
if hasattr(intervention, 'preparation_orders') else []
)
material_stats = intervention.get_material_stats()
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
@ -1099,6 +1100,7 @@ def intervention_detail_mobile(request, intervention_id):
'intervention': intervention,
'can_create_preparation_order': can_create_preparation_order,
'preparation_orders': preparation_orders,
'material_stats': material_stats,
'sequenced_blocks': sequenced_data['blocks'],
'document_blocks': sequenced_data['blocks'],
'all_sequenced_documents': sequenced_data['all_documents'],

View file

@ -1811,12 +1811,12 @@ class InterventionReportPDF(FPDF):
self.ln(3)
# ========== STOCK ==========
self._add_section_title(_("Stock"))
self._add_section_title(_("Stock / Matériel"))
preparation_orders = (
intervention.preparation_orders
.all()
.order_by("-date")
.prefetch_related("preparationorderitem_set__product")
.prefetch_related("preparationorderitem_set__product", "other_items")
)
if preparation_orders.exists():
@ -1838,14 +1838,25 @@ class InterventionReportPDF(FPDF):
self.set_text_color(0, 0, 0)
self.set_font(family="arial", style="", size=9)
order_items = order.preparationorderitem_set.all()
if order_items.exists():
other_items = order.other_items.all()
if order_items.exists() or other_items.exists():
for item in order_items:
product_name = item.product.name if item.product else _("Produit inconnu")
quantity = item.quantity if item.quantity is not None else 0
unit = item.product.get_unit_display() if item.product else ""
used_str = _("Utilisé") if item.is_used else _("Non utilisé")
item_line = f" • {product_name}: {quantity}"
if unit:
item_line += f" {unit}"
item_line += f" [{used_str}]"
self.set_x(12)
self.multi_cell(0, 7, item_line)
self.ln(1)
for item in other_items:
desc = item.description or _("Article hors catalogue")
quantity = item.quantity if item.quantity is not None else 0
used_str = _("Utilisé") if item.is_used else _("Non utilisé")
item_line = f" • {desc}: {quantity} ({_('Hors catalogue')}) [{used_str}]"
self.set_x(12)
self.multi_cell(0, 7, item_line)
self.ln(1)

View file

@ -4,6 +4,7 @@ from django.utils.html import format_html
from .models import (
Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement,
PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem,
PreparationOrderOtherItem,
ProductionOrder,
SerializedItem, SerializedItemEvent,
)
@ -84,22 +85,37 @@ class PreparationOrderItemInline(admin.TabularInline):
model = PreparationOrderItem
extra = 1
class PreparationOrderOtherItemInline(admin.TabularInline):
model = PreparationOrderOtherItem
extra = 1
@admin.register(PreparationOrder)
class PreparationOrderAdmin(admin.ModelAdmin):
list_display = ('order_number', 'date', 'status', 'priority')
search_fields = ('order_number',)
list_filter = ('status', 'date', 'priority')
inlines = [PreparationOrderItemInline]
inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline]
def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object
return self.readonly_fields + ('priority',)
return self.readonly_fields
@admin.register(PreparationOrderItem)
class PreparationOrderItemAdmin(admin.ModelAdmin):
list_display = ('preparation_order', 'product', 'quantity')
list_display = ('preparation_order', 'product', 'quantity', 'is_prepared', 'is_used')
search_fields = ('preparation_order__order_number', 'product__name')
list_filter = ('is_prepared', 'is_used')
@admin.register(PreparationOrderOtherItem)
class PreparationOrderOtherItemAdmin(admin.ModelAdmin):
list_display = ('preparation_order', 'description', 'quantity', 'is_prepared', 'is_used')
search_fields = ('preparation_order__order_number', 'description')
list_filter = ('is_prepared', 'is_used')
# class ProductDesignComponentInline(admin.TabularInline):

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-09-11 08:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='preparationorderitem',
name='is_used',
field=models.BooleanField(default=False, help_text="Indique si le matériel a été utilisé lors de l'intervention.", verbose_name='Utilisé'),
),
migrations.AddField(
model_name='preparationorderotheritem',
name='is_used',
field=models.BooleanField(default=False, help_text="Indique si le matériel a été utilisé lors de l'intervention.", verbose_name='Utilisé'),
),
]

View file

@ -1481,6 +1481,49 @@ class PreparationOrder(models.Model):
return list(gross_locs)[0]
return None
def get_all_items(self):
"""Retourne la liste combinée des articles catalogue et autres articles avec leurs informations d'utilisation."""
items = []
for it in self.preparationorderitem_set.select_related('product').all():
items.append({
'id': it.id,
'is_other': False,
'name': it.product.name,
'code': it.product.code,
'quantity': it.quantity,
'unit': it.product.get_unit_display() if hasattr(it.product, 'get_unit_display') else '',
'is_prepared': it.is_prepared,
'is_used': it.is_used,
'item_obj': it,
})
for oi in self.other_items.all():
items.append({
'id': oi.id,
'is_other': True,
'name': oi.description,
'code': '',
'quantity': oi.quantity,
'unit': '',
'is_prepared': oi.is_prepared,
'is_used': oi.is_used,
'item_obj': oi,
})
return items
@property
def all_items_used(self):
items = self.get_all_items()
return bool(items) and all(it['is_used'] for it in items)
@property
def used_items_count(self):
items = self.get_all_items()
return sum(1 for it in items if it['is_used'])
@property
def total_items_count(self):
return self.preparationorderitem_set.count() + self.other_items.count()
class PreparationOrderItem(models.Model):
@ -1488,6 +1531,11 @@ class PreparationOrderItem(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.IntegerField() # Quantité demandée initialement (0 si ajouté pendant le traitement)
is_prepared = models.BooleanField(default=False)
is_used = models.BooleanField(
default=False,
verbose_name=_("Utilisé"),
help_text=_("Indique si le matériel a été utilisé lors de l'intervention."),
)
quantity_modified = models.BooleanField(
default=False,
verbose_name=_("Quantité modifiée"),
@ -1651,6 +1699,15 @@ class PreparationOrderOtherItem(models.Model):
verbose_name=_("Type de panneau"),
)
is_prepared = models.BooleanField(default=False, verbose_name=_("Préparé"))
is_used = models.BooleanField(
default=False,
verbose_name=_("Utilisé"),
help_text=_("Indique si le matériel a été utilisé lors de l'intervention."),
)
@property
def name(self):
return self.description
def __str__(self):
return _("{quantity} x {description} dans le bon {order}").format(

View file

@ -1716,9 +1716,10 @@ class PreparationOrderAutocompleteTests(TestCase):
b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po)
StockMovement.objects.create(batch=b_in, product=prod, quantity=2)
from django.utils.translation import gettext as _
response = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Restitué au stock")
self.assertContains(response, _("Restitué au stock"))
self.assertNotContains(response, "à prélever")
def test_create_return_button_and_prefilled_entry_form(self):