feat: add usage tracking for material items in preparation orders and include status in reporting
This commit is contained in:
parent
4424c2d9c4
commit
26c07a769f
13 changed files with 1217 additions and 136 deletions
|
|
@ -810,6 +810,40 @@ class Intervention(models.Model):
|
||||||
status__in=['to_process', 'pending']
|
status__in=['to_process', 'pending']
|
||||||
).exists()
|
).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):
|
def get_associated_observations(self):
|
||||||
"""
|
"""
|
||||||
Retourne le QuerySet de toutes les observations associées à cette intervention :
|
Retourne le QuerySet de toutes les observations associées à cette intervention :
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,229 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% if can_create_preparation_order or preparation_orders %}
|
{% if can_create_preparation_order or preparation_orders %}
|
||||||
<div id="section-stock" class="card mb-4">
|
<div id="section-stock" class="card mb-4 shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
|
||||||
<h4><i class="bi bi-box-seam"></i> {% translate "Stock" %}</h4>
|
<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 %}
|
{% if can_create_preparation_order %}
|
||||||
<a href="{% url 'stock:new_preparation_order_from_intervention' intervention.id %}"
|
<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' %}">
|
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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
{% if preparation_orders %}
|
{% 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 %}
|
{% for order in preparation_orders %}
|
||||||
<tr>
|
<div class="card mb-3 border {% if forloop.last %}mb-0{% endif %}">
|
||||||
<td><a href="{% url 'stock:preparation_order_detail' order.pk %}"><strong>{{ order.order_number }}</strong></a></td>
|
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
|
||||||
<td>{{ order.date|date:"d/m/Y H:i" }}</td>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<td>
|
<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' %}
|
{% if order.status == 'to_process' %}
|
||||||
<span class="badge bg-warning text-dark">
|
<span class="badge bg-warning text-dark">
|
||||||
<i class="bi bi-hourglass-split"></i> {{ order.get_status_display }}
|
<i class="bi bi-hourglass-split me-1"></i>{{ order.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% elif order.status == 'pending' %}
|
{% elif order.status == 'pending' %}
|
||||||
<span class="badge bg-info">
|
<span class="badge bg-info">
|
||||||
<i class="bi bi-clock"></i> {{ order.get_status_display }}
|
<i class="bi bi-clock me-1"></i>{{ order.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% elif order.status == 'processed' %}
|
{% elif order.status == 'processed' %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">
|
||||||
<i class="bi bi-check-circle"></i> {{ order.get_status_display }}
|
<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">
|
||||||
|
× {{ 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>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="text-center">
|
||||||
{% with items=order.preparationorderitem_set.all %}
|
<span class="badge {% if item.is_used %}bg-success text-white{% else %}bg-secondary-subtle text-muted{% endif %} material-status-badge"
|
||||||
{% if items %}
|
id="material-badge-item-{{ item.id }}">
|
||||||
<small>
|
{% if item.is_used %}
|
||||||
{% for item in items|slice:":3" %}
|
<i class="bi bi-check-circle me-1"></i>{% translate "Utilisé" %}
|
||||||
{{ 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 %}
|
{% else %}
|
||||||
<span class="text-muted"><em>{% translate "Aucun article" %}</em></span>
|
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
</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>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'stock:preparation_order_detail' order.pk %}"
|
<span class="fw-semibold">{{ item.description }}</span>
|
||||||
class="btn btn-sm btn-outline-primary"
|
<span class="badge bg-light text-secondary border ms-1" style="font-size:0.7rem;">{% translate "Hors catalogue" %}</span>
|
||||||
title="{% translate 'Voir les détails' %}">
|
{% if item.category %}
|
||||||
<i class="bi bi-eye"></i>
|
<small class="text-muted d-block">{{ item.category.name_fr }}</small>
|
||||||
</a>
|
{% 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">
|
||||||
|
× {{ 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
<i class="bi bi-info-circle"></i>
|
<i class="bi bi-info-circle"></i>
|
||||||
|
|
@ -81,6 +233,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- Follow-up reports -->
|
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,132 @@ function toggleInterventionChecked(interventionId, checkboxEl) {
|
||||||
alert('{% translate "Erreur réseau." %}');
|
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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -1095,7 +1095,7 @@
|
||||||
<div class="card mt-4 mb-4 bg-white" id="section-stock">
|
<div class="card mt-4 mb-4 bg-white" id="section-stock">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="fw-semibold d-flex align-items-center gap-1">
|
<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>
|
</span>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% if preparation_orders %}
|
{% if preparation_orders %}
|
||||||
|
|
@ -1112,13 +1112,29 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if preparation_orders %}
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{% for order in preparation_orders %}
|
{% for order in preparation_orders %}
|
||||||
<div class="list-group-item px-0 py-2 {% if not forloop.last %}border-bottom{% endif %}">
|
<div class="card mb-3 border {% if forloop.last %}mb-0{% endif %}">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
<div class="card-header bg-light py-2 px-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div class="flex-grow-1">
|
<div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-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">
|
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}" class="fw-semibold text-decoration-none">
|
||||||
#{{ order.order_number }}
|
#{{ order.order_number }}
|
||||||
|
|
@ -1135,44 +1151,132 @@
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small mb-1">
|
<div class="text-muted small">
|
||||||
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
|
<i class="bi bi-calendar3 me-1"></i>{{ order.date|date:"d/m/Y H:i" }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
|
{% with items=order.preparationorderitem_set.all other_items=order.other_items.all %}
|
||||||
{% if items or other_items %}
|
{% if items or other_items %}
|
||||||
<div class="d-flex flex-wrap gap-1 mt-1">
|
<button type="button"
|
||||||
{% for item in items|slice:":3" %}
|
class="btn btn-sm btn-outline-secondary py-0 px-2"
|
||||||
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
|
style="font-size:0.7rem;"
|
||||||
{{ item.product.name }} × {{ item.quantity }}
|
onclick="toggleAllMaterialUsed('{{ intervention.id }}', '{{ order.id }}')"
|
||||||
</span>
|
{% if not can_edit_intervention %}disabled{% endif %}
|
||||||
{% endfor %}
|
title="{% translate 'Cocher / décocher tout pour ce bon' %}">
|
||||||
{% for item in other_items|slice:":2" %}
|
<i class="bi bi-check2-all"></i>
|
||||||
<span class="badge bg-light text-dark border" style="font-size:0.75rem;">
|
</button>
|
||||||
{{ item.name }} × {{ 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 %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted small fst-italic">{% translate "Aucun article" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
<a href="{% url 'mobile:stock_preparation_order_detail_mobile' order.pk %}"
|
<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' %}">
|
title="{% translate 'Voir les détails' %}">
|
||||||
<i class="bi bi-chevron-right"></i>
|
<i class="bi bi-chevron-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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="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;">
|
||||||
|
× {{ 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;">
|
||||||
|
× {{ 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 %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<p class="text-muted mb-0 small">
|
<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." %}
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock extra_scripts %}
|
||||||
310
loko/interventions/tests/test_material_used.py
Normal file
310
loko/interventions/tests/test_material_used.py
Normal 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')
|
||||||
|
|
@ -23,6 +23,8 @@ urlpatterns = [
|
||||||
path('<int:intervention_id>/toggle-subscription/', views.toggle_intervention_subscription, name='toggle_intervention_subscription'),
|
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-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>/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-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>/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'),
|
path('<int:intervention_id>/add-note/', views.intervention_add_note, name='intervention_add_note'),
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,13 @@ def intervention_detail(request, intervention_id):
|
||||||
or (can_manage_links and user_config.can_manage_preparation_orders_any())
|
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)
|
# Config desktop pour le modal de liaison d'assets (même mécanique que mobile)
|
||||||
desktop_assets_config = None
|
desktop_assets_config = None
|
||||||
|
|
@ -725,6 +731,7 @@ def intervention_detail(request, intervention_id):
|
||||||
# Bons de préparation
|
# Bons de préparation
|
||||||
'can_create_preparation_order': can_create_preparation_order,
|
'can_create_preparation_order': can_create_preparation_order,
|
||||||
'preparation_orders': preparation_orders,
|
'preparation_orders': preparation_orders,
|
||||||
|
'material_stats': material_stats,
|
||||||
# Config modal liaison assets (desktop)
|
# Config modal liaison assets (desktop)
|
||||||
'desktop_assets_enabled': can_manage_links,
|
'desktop_assets_enabled': can_manage_links,
|
||||||
'desktop_assets_config': desktop_assets_config,
|
'desktop_assets_config': desktop_assets_config,
|
||||||
|
|
@ -3779,3 +3786,110 @@ def create_operations_from_selected_template(request, intervention_id):
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1092,6 +1092,7 @@ def intervention_detail_mobile(request, intervention_id):
|
||||||
.order_by('-date')
|
.order_by('-date')
|
||||||
if hasattr(intervention, 'preparation_orders') else []
|
if hasattr(intervention, 'preparation_orders') else []
|
||||||
)
|
)
|
||||||
|
material_stats = intervention.get_material_stats()
|
||||||
|
|
||||||
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
||||||
|
|
||||||
|
|
@ -1099,6 +1100,7 @@ def intervention_detail_mobile(request, intervention_id):
|
||||||
'intervention': intervention,
|
'intervention': intervention,
|
||||||
'can_create_preparation_order': can_create_preparation_order,
|
'can_create_preparation_order': can_create_preparation_order,
|
||||||
'preparation_orders': preparation_orders,
|
'preparation_orders': preparation_orders,
|
||||||
|
'material_stats': material_stats,
|
||||||
'sequenced_blocks': sequenced_data['blocks'],
|
'sequenced_blocks': sequenced_data['blocks'],
|
||||||
'document_blocks': sequenced_data['blocks'],
|
'document_blocks': sequenced_data['blocks'],
|
||||||
'all_sequenced_documents': sequenced_data['all_documents'],
|
'all_sequenced_documents': sequenced_data['all_documents'],
|
||||||
|
|
|
||||||
|
|
@ -1811,12 +1811,12 @@ class InterventionReportPDF(FPDF):
|
||||||
self.ln(3)
|
self.ln(3)
|
||||||
|
|
||||||
# ========== STOCK ==========
|
# ========== STOCK ==========
|
||||||
self._add_section_title(_("Stock"))
|
self._add_section_title(_("Stock / Matériel"))
|
||||||
preparation_orders = (
|
preparation_orders = (
|
||||||
intervention.preparation_orders
|
intervention.preparation_orders
|
||||||
.all()
|
.all()
|
||||||
.order_by("-date")
|
.order_by("-date")
|
||||||
.prefetch_related("preparationorderitem_set__product")
|
.prefetch_related("preparationorderitem_set__product", "other_items")
|
||||||
)
|
)
|
||||||
|
|
||||||
if preparation_orders.exists():
|
if preparation_orders.exists():
|
||||||
|
|
@ -1838,14 +1838,25 @@ class InterventionReportPDF(FPDF):
|
||||||
self.set_text_color(0, 0, 0)
|
self.set_text_color(0, 0, 0)
|
||||||
self.set_font(family="arial", style="", size=9)
|
self.set_font(family="arial", style="", size=9)
|
||||||
order_items = order.preparationorderitem_set.all()
|
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:
|
for item in order_items:
|
||||||
product_name = item.product.name if item.product else _("Produit inconnu")
|
product_name = item.product.name if item.product else _("Produit inconnu")
|
||||||
quantity = item.quantity if item.quantity is not None else 0
|
quantity = item.quantity if item.quantity is not None else 0
|
||||||
unit = item.product.get_unit_display() if item.product else ""
|
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}"
|
item_line = f" • {product_name}: {quantity}"
|
||||||
if unit:
|
if unit:
|
||||||
item_line += f" {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.set_x(12)
|
||||||
self.multi_cell(0, 7, item_line)
|
self.multi_cell(0, 7, item_line)
|
||||||
self.ln(1)
|
self.ln(1)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.utils.html import format_html
|
||||||
from .models import (
|
from .models import (
|
||||||
Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement,
|
Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement,
|
||||||
PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem,
|
PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem,
|
||||||
|
PreparationOrderOtherItem,
|
||||||
ProductionOrder,
|
ProductionOrder,
|
||||||
SerializedItem, SerializedItemEvent,
|
SerializedItem, SerializedItemEvent,
|
||||||
)
|
)
|
||||||
|
|
@ -84,22 +85,37 @@ class PreparationOrderItemInline(admin.TabularInline):
|
||||||
model = PreparationOrderItem
|
model = PreparationOrderItem
|
||||||
extra = 1
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
|
class PreparationOrderOtherItemInline(admin.TabularInline):
|
||||||
|
model = PreparationOrderOtherItem
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PreparationOrder)
|
@admin.register(PreparationOrder)
|
||||||
class PreparationOrderAdmin(admin.ModelAdmin):
|
class PreparationOrderAdmin(admin.ModelAdmin):
|
||||||
list_display = ('order_number', 'date', 'status', 'priority')
|
list_display = ('order_number', 'date', 'status', 'priority')
|
||||||
search_fields = ('order_number',)
|
search_fields = ('order_number',)
|
||||||
list_filter = ('status', 'date', 'priority')
|
list_filter = ('status', 'date', 'priority')
|
||||||
inlines = [PreparationOrderItemInline]
|
inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline]
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
if obj: # editing an existing object
|
if obj: # editing an existing object
|
||||||
return self.readonly_fields + ('priority',)
|
return self.readonly_fields + ('priority',)
|
||||||
return self.readonly_fields
|
return self.readonly_fields
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PreparationOrderItem)
|
@admin.register(PreparationOrderItem)
|
||||||
class PreparationOrderItemAdmin(admin.ModelAdmin):
|
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')
|
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):
|
# class ProductDesignComponentInline(admin.TabularInline):
|
||||||
|
|
|
||||||
|
|
@ -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é'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1481,6 +1481,49 @@ class PreparationOrder(models.Model):
|
||||||
return list(gross_locs)[0]
|
return list(gross_locs)[0]
|
||||||
return None
|
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):
|
class PreparationOrderItem(models.Model):
|
||||||
|
|
@ -1488,6 +1531,11 @@ class PreparationOrderItem(models.Model):
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
quantity = models.IntegerField() # Quantité demandée initialement (0 si ajouté pendant le traitement)
|
quantity = models.IntegerField() # Quantité demandée initialement (0 si ajouté pendant le traitement)
|
||||||
is_prepared = models.BooleanField(default=False)
|
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(
|
quantity_modified = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Quantité modifiée"),
|
verbose_name=_("Quantité modifiée"),
|
||||||
|
|
@ -1651,6 +1699,15 @@ class PreparationOrderOtherItem(models.Model):
|
||||||
verbose_name=_("Type de panneau"),
|
verbose_name=_("Type de panneau"),
|
||||||
)
|
)
|
||||||
is_prepared = models.BooleanField(default=False, verbose_name=_("Préparé"))
|
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):
|
def __str__(self):
|
||||||
return _("{quantity} x {description} dans le bon {order}").format(
|
return _("{quantity} x {description} dans le bon {order}").format(
|
||||||
|
|
|
||||||
|
|
@ -1716,9 +1716,10 @@ class PreparationOrderAutocompleteTests(TestCase):
|
||||||
b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po)
|
b_in = StockMovementBatch.objects.create(movement_type="in", preparation_order=po)
|
||||||
StockMovement.objects.create(batch=b_in, product=prod, quantity=2)
|
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]))
|
response = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Restitué au stock")
|
self.assertContains(response, _("Restitué au stock"))
|
||||||
self.assertNotContains(response, "à prélever")
|
self.assertNotContains(response, "à prélever")
|
||||||
|
|
||||||
def test_create_return_button_and_prefilled_entry_form(self):
|
def test_create_return_button_and_prefilled_entry_form(self):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue