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']
|
||||
).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 :
|
||||
|
|
|
|||
|
|
@ -1,77 +1,229 @@
|
|||
{% 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">
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
{% 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>
|
||||
<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"></i> {{ order.get_status_display }}
|
||||
<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"></i> {{ order.get_status_display }}
|
||||
<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"></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>
|
||||
{% 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>
|
||||
<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 %}
|
||||
<span class="text-muted"><em>{% translate "Aucun article" %}</em></span>
|
||||
<i class="bi bi-dash-circle me-1"></i>{% translate "Non utilisé" %}
|
||||
{% 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>
|
||||
<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>
|
||||
<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">
|
||||
× {{ 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>
|
||||
|
|
@ -81,6 +233,3 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Follow-up reports -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,13 +1112,29 @@
|
|||
{% 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="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 }}
|
||||
|
|
@ -1135,44 +1151,132 @@
|
|||
</span>
|
||||
{% endif %}
|
||||
</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" }}
|
||||
</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 %}
|
||||
<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 }} × {{ 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 }} × {{ 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>
|
||||
<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 %}
|
||||
</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 %}"
|
||||
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>
|
||||
<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 %}
|
||||
</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 %}
|
||||
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-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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue