fix(stock): permettre la modification et l'affichage des numéros de série d'un article

- Ajout de la vue et de la route `serialized_item_edit` avec régénération QR code et journalisation d'audit
- Affichage de la liste des exemplaires tracés avec modale de modification directe dans le formulaire produit (`product_form.html`)
- Ajout de boutons de modification sur les fiches détail bureau, mobile et dans les tableaux d'articles tracés
- Validation empêchant de décocher la traçabilité si des exemplaires existent
- Mise à jour des traductions (FR, EN, NL) et ajout des tests unitaires
This commit is contained in:
kdeterme 2026-10-12 13:18:07 +02:00
parent 5a48dc4d2f
commit 20669d545b
17 changed files with 714 additions and 13 deletions

View file

@ -485,6 +485,17 @@ class ProductForm(forms.ModelForm):
self.fields["authorized_locations"].required = False self.fields["authorized_locations"].required = False
self.fields["authorized_locations"].help_text = _("Si vide, le produit est autorisé dans tous les emplacements.") self.fields["authorized_locations"].help_text = _("Si vide, le produit est autorisé dans tous les emplacements.")
def clean_is_serialized(self):
from django.utils.translation import gettext_lazy as _
is_serialized = self.cleaned_data.get("is_serialized", False)
if self.instance and self.instance.pk:
if self.instance.is_serialized and not is_serialized:
if self.instance.serialized_items.filter(is_active=True).exists():
raise forms.ValidationError(
_("Impossible de désactiver le suivi par numéro de série : ce produit possède des articles tracés actifs.")
)
return is_serialized
class ProductAtLocationForm(ProductForm): class ProductAtLocationForm(ProductForm):
quantity = forms.IntegerField( quantity = forms.IntegerField(

View file

@ -6572,3 +6572,47 @@ msgstr "Please check at least one item line to include in the order."
#~ msgid "Rafraîchir la page" #~ msgid "Rafraîchir la page"
#~ msgstr "Refresh page" #~ msgstr "Refresh page"
msgid "Vous n'avez pas la permission de modifier cet article."
msgstr "You do not have permission to edit this item."
#, python-format
msgid "Numéro de série modifié : %(old)s → %(new)s"
msgstr "Serial number changed: %(old)s → %(new)s"
#, python-format
msgid "Article tracé %(sn)s mis à jour avec succès."
msgstr "Serialized item %(sn)s successfully updated."
msgid "Impossible de désactiver le suivi par numéro de série : ce produit possède des articles tracés actifs."
msgstr "Cannot disable serial tracking: this product has active serialized items."
msgid "Une fois ce produit enregistré, vous pourrez lui ajouter ses numéros de série et les gérer individuellement."
msgstr "Once this product is saved, you will be able to add its serial numbers and manage them individually."
msgid "En cochant cette option et en enregistrant, vous pourrez ajouter des numéros de série à ce produit."
msgstr "By checking this option and saving, you will be able to add serial numbers to this product."
msgid "Numéros de série associés"
msgstr "Associated serial numbers"
msgid "Ajouter un numéro de série"
msgstr "Add a serial number"
msgid "Chaque unité physique de ce produit est identifiée par un numéro de série unique. Vous pouvez modifier directement un numéro de série ci-dessous."
msgstr "Each physical unit of this product is identified by a unique serial number. You can edit a serial number directly below."
msgid "Modifier le numéro de série"
msgstr "Edit serial number"
msgid "Consulter la fiche"
msgstr "View details"
msgid "Aucun numéro de série n'a encore été enregistré pour ce produit."
msgstr "No serial number has been registered for this product yet."
msgid "Enregistrer un numéro de série"
msgstr "Register a serial number"
msgid "Modifier l'article tracé"
msgstr "Edit serialized item"

View file

@ -6626,3 +6626,47 @@ msgstr ""
#~ msgid "Rafraîchir la page" #~ msgid "Rafraîchir la page"
#~ msgstr "Rafraîchir la page" #~ msgstr "Rafraîchir la page"
msgid "Vous n'avez pas la permission de modifier cet article."
msgstr "Vous n'avez pas la permission de modifier cet article."
#, python-format
msgid "Numéro de série modifié : %(old)s → %(new)s"
msgstr "Numéro de série modifié : %(old)s → %(new)s"
#, python-format
msgid "Article tracé %(sn)s mis à jour avec succès."
msgstr "Article tracé %(sn)s mis à jour avec succès."
msgid "Impossible de désactiver le suivi par numéro de série : ce produit possède des articles tracés actifs."
msgstr "Impossible de désactiver le suivi par numéro de série : ce produit possède des articles tracés actifs."
msgid "Une fois ce produit enregistré, vous pourrez lui ajouter ses numéros de série et les gérer individuellement."
msgstr "Une fois ce produit enregistré, vous pourrez lui ajouter ses numéros de série et les gérer individuellement."
msgid "En cochant cette option et en enregistrant, vous pourrez ajouter des numéros de série à ce produit."
msgstr "En cochant cette option et en enregistrant, vous pourrez ajouter des numéros de série à ce produit."
msgid "Numéros de série associés"
msgstr "Numéros de série associés"
msgid "Ajouter un numéro de série"
msgstr "Ajouter un numéro de série"
msgid "Chaque unité physique de ce produit est identifiée par un numéro de série unique. Vous pouvez modifier directement un numéro de série ci-dessous."
msgstr "Chaque unité physique de ce produit est identifiée par un numéro de série unique. Vous pouvez modifier directement un numéro de série ci-dessous."
msgid "Modifier le numéro de série"
msgstr "Modifier le numéro de série"
msgid "Consulter la fiche"
msgstr "Consulter la fiche"
msgid "Aucun numéro de série n'a encore été enregistré pour ce produit."
msgstr "Aucun numéro de série n'a encore été enregistré pour ce produit."
msgid "Enregistrer un numéro de série"
msgstr "Enregistrer un numéro de série"
msgid "Modifier l'article tracé"
msgstr "Modifier l'article tracé"

View file

@ -6614,3 +6614,47 @@ msgstr "Vink minimaal één artikelregel aan om op te nemen in de bestelling."
#~ msgid "Rafraîchir la page" #~ msgid "Rafraîchir la page"
#~ msgstr "Pagina vernieuwen" #~ msgstr "Pagina vernieuwen"
msgid "Vous n'avez pas la permission de modifier cet article."
msgstr "U hebt geen toestemming om dit artikel te bewerken."
#, python-format
msgid "Numéro de série modifié : %(old)s → %(new)s"
msgstr "Serienummer gewijzigd: %(old)s → %(new)s"
#, python-format
msgid "Article tracé %(sn)s mis à jour avec succès."
msgstr "Getraceerd artikel %(sn)s succesvol bijgewerkt."
msgid "Impossible de désactiver le suivi par numéro de série : ce produit possède des articles tracés actifs."
msgstr "Kan serienummertracking niet uitschakelen: dit product heeft actieve getraceerde artikelen."
msgid "Une fois ce produit enregistré, vous pourrez lui ajouter ses numéros de série et les gérer individuellement."
msgstr "Zodra dit product is opgeslagen, kunt u serienummers toevoegen en deze afzonderlijk beheren."
msgid "En cochant cette option et en enregistrant, vous pourrez ajouter des numéros de série à ce produit."
msgstr "Door deze optie aan te vinken en op te slaan, kunt u serienummers aan dit product toevoegen."
msgid "Numéros de série associés"
msgstr "Gekoppelde serienummers"
msgid "Ajouter un numéro de série"
msgstr "Een serienummer toevoegen"
msgid "Chaque unité physique de ce produit est identifiée par un numéro de série unique. Vous pouvez modifier directement un numéro de série ci-dessous."
msgstr "Elke fysieke eenheid van dit product wordt geïdentificeerd door een uniek serienummer. U kunt hieronder rechtstreeks een serienummer bewerken."
msgid "Modifier le numéro de série"
msgstr "Serienummer bewerken"
msgid "Consulter la fiche"
msgstr "Fiche bekijken"
msgid "Aucun numéro de série n'a encore été enregistré pour ce produit."
msgstr "Er is nog geen serienummer geregistreerd voor dit product."
msgid "Enregistrer un numéro de série"
msgstr "Een serienummer registreren"
msgid "Modifier l'article tracé"
msgstr "Getraceerd artikel bewerken"

View file

@ -78,6 +78,15 @@
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="info-label">{% translate "Numéro de série" %}</div> <div class="info-label">{% translate "Numéro de série" %}</div>
<div class="info-value" style="font-family:monospace;font-size:1.1rem;word-break:break-all;">{{ item.serial_number }}</div> <div class="info-value" style="font-family:monospace;font-size:1.1rem;word-break:break-all;">{{ item.serial_number }}</div>
{% if can_manage and item.is_active %}
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#editItemMobileModal"
style="font-size:0.8rem;">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier le numéro de série" %}
</button>
</div>
{% endif %}
<div class="mt-1"> <div class="mt-1">
<a href="{% url 'mobile:stock_product_detail_mobile' item.product.pk %}" style="font-size:0.9rem;color:#2f3e8b;text-decoration:none;"> <a href="{% url 'mobile:stock_product_detail_mobile' item.product.pk %}" style="font-size:0.9rem;color:#2f3e8b;text-decoration:none;">
<i class="bi bi-box-seam me-1"></i>{{ item.product.name }} <i class="bi bi-box-seam me-1"></i>{{ item.product.name }}
@ -288,6 +297,52 @@
{% if item.created_by %}{% translate "par" %} {{ item.created_by.get_full_name|default:item.created_by.username }}{% endif %} {% if item.created_by %}{% translate "par" %} {{ item.created_by.get_full_name|default:item.created_by.username }}{% endif %}
</div> </div>
{% if can_manage and item.is_active %}
<!-- Modal modification mobile -->
<div class="modal fade" id="editItemMobileModal" tabindex="-1" aria-labelledby="editItemMobileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:serialized_item_edit' item.pk %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<div class="modal-header">
<h5 class="modal-title" id="editItemMobileModalLabel">
<i class="bi bi-pencil-square me-1"></i>
{% translate "Modifier le numéro de série" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Produit" %}</label>
<input type="text" class="form-control" value="{{ item.product.name }}" readonly disabled>
</div>
<div class="mb-3">
<label for="mob_edit_sn" class="form-label fw-semibold">
{% translate "Numéro de série" %} <span class="text-danger">*</span>
</label>
<input type="text" name="serial_number" id="mob_edit_sn"
class="form-control font-monospace" value="{{ item.serial_number }}" required>
</div>
<div class="mb-3">
<label for="mob_edit_notes" class="form-label fw-semibold">{% translate "Notes" %}</label>
<textarea name="notes" id="mob_edit_notes" class="form-control" rows="3">{{ item.notes }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-1"></i> {% translate "Enregistrer" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content %} {% endblock content %}
{% block extra_scripts %} {% block extra_scripts %}

View file

@ -631,8 +631,17 @@
<td> <td>
{% if si.current_location %}{{ si.current_location.warehouse.name }} — {{ si.current_location.name }}{% else %}—{% endif %} {% if si.current_location %}{{ si.current_location.warehouse.name }} — {{ si.current_location.name }}{% else %}—{% endif %}
</td> </td>
<td class="text-end"> <td class="text-end text-nowrap">
<a href="{% url 'stock:serialized_item_detail' si.pk %}" class="btn btn-sm btn-outline-primary"> {% if is_stock_manager and si.is_active %}
<a href="{% url 'stock:serialized_item_edit' si.pk %}?next={{ request.path }}"
class="btn btn-sm btn-outline-secondary"
title="{% translate 'Modifier le numéro de série' %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{% url 'stock:serialized_item_detail' si.pk %}"
class="btn btn-sm btn-outline-primary"
title="{% translate 'Consulter la fiche' %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
</td> </td>

View file

@ -134,6 +134,112 @@
{% if form.is_serialized.errors %} {% if form.is_serialized.errors %}
<div class="text-danger small mt-1">{{ form.is_serialized.errors }}</div> <div class="text-danger small mt-1">{{ form.is_serialized.errors }}</div>
{% endif %} {% endif %}
{% if action == 'create' %}
<div class="form-text text-muted mt-2" id="serialized-help-create" style="{% if not form.is_serialized.value %}display:none;{% endif %}">
<i class="bi bi-info-circle me-1"></i>
{% translate "Une fois ce produit enregistré, vous pourrez lui ajouter ses numéros de série et les gérer individuellement." %}
</div>
{% elif not product.is_serialized %}
<div class="form-text text-muted mt-2" id="serialized-help-edit" style="{% if not form.is_serialized.value %}display:none;{% endif %}">
<i class="bi bi-info-circle me-1"></i>
{% translate "En cochant cette option et en enregistrant, vous pourrez ajouter des numéros de série à ce produit." %}
</div>
{% endif %}
{% if action == 'edit' and product.is_serialized %}
<div class="mt-3 p-3 bg-light rounded border">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div>
<span class="fw-semibold">
<i class="bi bi-upc-scan me-1 text-primary"></i>
{% translate "Numéros de série associés" %}
</span>
<span class="badge bg-secondary ms-1">{{ serialized_items|length }}</span>
</div>
<a href="{% url 'stock:serialized_item_create_for_product' product.pk %}?next={{ request.path }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>{% translate "Ajouter un numéro de série" %}
</a>
</div>
<p class="form-text text-muted mb-3">
{% translate "Chaque unité physique de ce produit est identifiée par un numéro de série unique. Vous pouvez modifier directement un numéro de série ci-dessous." %}
</p>
{% if serialized_items %}
<div class="table-responsive bg-white rounded border">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Numéro de série" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Détenteur" %}</th>
<th>{% translate "Emplacement" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for si in serialized_items %}
<tr>
<td>
<a href="{% url 'stock:serialized_item_detail' si.pk %}" class="font-monospace fw-semibold">
{{ si.serial_number }}
</a>
</td>
<td>
{% if si.status == 'available' %}
<span class="badge bg-success">{{ si.get_status_display }}</span>
{% elif si.status == 'assigned' %}
<span class="badge bg-primary">{{ si.get_status_display }}</span>
{% elif si.status == 'lost' %}
<span class="badge bg-danger">{{ si.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ si.get_status_display }}</span>
{% endif %}
{% if not si.is_active %}
<span class="badge bg-danger">{% translate "Inactif" %}</span>
{% endif %}
</td>
<td>{{ si.current_holder.name|default:"—" }}</td>
<td>
{% if si.current_location %}
{{ si.current_location.warehouse.name }} — {{ si.current_location.name }}
{% else %}—{% endif %}
</td>
<td class="text-end text-nowrap">
<button type="button"
class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editSerialModal{{ si.pk }}"
title="{% translate 'Modifier le numéro de série' %}">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</button>
<a href="{% url 'stock:serialized_item_detail' si.pk %}"
class="btn btn-sm btn-outline-secondary"
title="{% translate 'Consulter la fiche' %}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info py-2 px-3 mb-0 small d-flex align-items-center justify-content-between flex-wrap gap-2">
<span>
<i class="bi bi-info-circle me-1"></i>
{% translate "Aucun numéro de série n'a encore été enregistré pour ce produit." %}
</span>
<a href="{% url 'stock:serialized_item_create_for_product' product.pk %}?next={{ request.path }}"
class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% translate "Enregistrer un numéro de série" %}
</a>
</div>
{% endif %}
</div>
{% endif %}
</div> </div>
<!-- Unité, prix & seuil --> <!-- Unité, prix & seuil -->
@ -276,6 +382,52 @@
</div> </div>
</div> </div>
</div> </div>
{% if product.is_serialized %}
{% for si in serialized_items %}
<!-- Modal modification numéro de série pour {{ si.serial_number }} -->
<div class="modal fade" id="editSerialModal{{ si.pk }}" tabindex="-1" aria-labelledby="editSerialModalLabel{{ si.pk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:serialized_item_edit' si.pk %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<div class="modal-header">
<h5 class="modal-title" id="editSerialModalLabel{{ si.pk }}">
<i class="bi bi-pencil-square me-1"></i>
{% translate "Modifier le numéro de série" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Produit" %}</label>
<input type="text" class="form-control" value="{{ product.name }} ({{ product.code }})" readonly disabled>
</div>
<div class="mb-3">
<label for="modal_serial_{{ si.pk }}" class="form-label fw-semibold">
{% translate "Numéro de série" %} <span class="text-danger">*</span>
</label>
<input type="text" name="serial_number" id="modal_serial_{{ si.pk }}"
class="form-control font-monospace" value="{{ si.serial_number }}" required>
</div>
<div class="mb-3">
<label for="modal_notes_{{ si.pk }}" class="form-label fw-semibold">{% translate "Notes" %}</label>
<textarea name="notes" id="modal_notes_{{ si.pk }}" class="form-control" rows="3">{{ si.notes }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-1"></i>{% translate "Enregistrer" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %} {% endif %}
</div> </div>
@ -289,6 +441,16 @@ document.addEventListener('DOMContentLoaded', function () {
tooltipTriggerList.forEach(function (el) { tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el); new bootstrap.Tooltip(el);
}); });
const isSerializedCheckbox = document.getElementById("{{ form.is_serialized.id_for_label }}");
if (isSerializedCheckbox) {
const helpCreate = document.getElementById("serialized-help-create");
const helpEdit = document.getElementById("serialized-help-edit");
isSerializedCheckbox.addEventListener("change", function() {
if (helpCreate) helpCreate.style.display = this.checked ? "block" : "none";
if (helpEdit) helpEdit.style.display = this.checked ? "block" : "none";
});
}
}); });
{% if action == 'create' %} {% if action == 'create' %}

View file

@ -15,6 +15,10 @@
</h3> </h3>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if can_manage and item.is_active %} {% if can_manage and item.is_active %}
<button type="button" class="btn btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#editItemModal">
<i class="bi bi-pencil"></i> {% translate "Modifier" %}
</button>
<button type="button" class="btn btn-outline-danger" <button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteItemModal"> data-bs-toggle="modal" data-bs-target="#deleteItemModal">
<i class="bi bi-trash"></i> {% translate "Supprimer" %} <i class="bi bi-trash"></i> {% translate "Supprimer" %}
@ -26,6 +30,51 @@
</div> </div>
</div> </div>
{% if can_manage and item.is_active %}
<!-- Modal modification numéro de série -->
<div class="modal fade" id="editItemModal" tabindex="-1" aria-labelledby="editItemModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{% url 'stock:serialized_item_edit' item.pk %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="editItemModalLabel">
<i class="bi bi-pencil-square me-1"></i>
{% translate "Modifier l'article tracé" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Produit" %}</label>
<input type="text" class="form-control" value="{{ item.product.name }} ({{ item.product.code }})" readonly disabled>
</div>
<div class="mb-3">
<label for="detail_edit_serial_number" class="form-label fw-semibold">
{% translate "Numéro de série" %} <span class="text-danger">*</span>
</label>
<input type="text" name="serial_number" id="detail_edit_serial_number"
class="form-control font-monospace" value="{{ item.serial_number }}" required>
</div>
<div class="mb-3">
<label for="detail_edit_notes" class="form-label fw-semibold">{% translate "Notes" %}</label>
<textarea name="notes" id="detail_edit_notes" class="form-control" rows="3">{{ item.notes }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-1"></i> {% translate "Enregistrer" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% if can_manage and item.is_active %} {% if can_manage and item.is_active %}
<!-- Modal suppression --> <!-- Modal suppression -->
<div class="modal fade" id="deleteItemModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteItemModal" tabindex="-1" aria-hidden="true">

View file

@ -6,9 +6,13 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h3"> <h3 class="h3">
<i class="bi bi-upc-scan"></i> <i class="bi bi-upc-scan"></i>
{% if action == 'edit' %}
{% translate "Modifier l'article tracé" %} — <span class="font-monospace">{{ item.serial_number }}</span>
{% else %}
{% translate "Enregistrer un article tracé" %} {% translate "Enregistrer un article tracé" %}
{% endif %}
</h3> </h3>
<a href="{% url 'stock:serialized_items_list' %}" class="btn btn-light"> <a href="{% if next %}{{ next }}{% elif action == 'edit' %}{% url 'stock:serialized_item_detail' item.pk %}{% else %}{% url 'stock:serialized_items_list' %}{% endif %}" class="btn btn-light">
<i class="bi bi-arrow-left"></i> {% translate "Retour" %} <i class="bi bi-arrow-left"></i> {% translate "Retour" %}
</a> </a>
</div> </div>
@ -17,6 +21,16 @@
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
{% if action == 'edit' %}
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Produit" %}</label>
<input type="text" class="form-control" value="{{ item.product.name }} ({{ item.product.code }})" readonly disabled>
</div>
{% else %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Produit" %} *</label> <label class="form-label fw-semibold">{% translate "Produit" %} *</label>
<select name="product" class="form-select" required> <select name="product" class="form-select" required>
@ -36,14 +50,16 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Numéro de série" %} *</label> <label class="form-label fw-semibold">{% translate "Numéro de série" %} *</label>
<input type="text" name="serial_number" class="form-control font-monospace" <input type="text" name="serial_number" class="form-control font-monospace"
value="{{ post.serial_number|default:'' }}" value="{% if action == 'edit' %}{{ post.serial_number|default:item.serial_number }}{% else %}{{ post.serial_number|default:'' }}{% endif %}"
placeholder="{% translate 'Ex: SN-2024-00123' %}" required> placeholder="{% translate 'Ex: SN-2024-00123' %}" required>
</div> </div>
{% if action != 'edit' %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Emplacement initial" %}</label> <label class="form-label fw-semibold">{% translate "Emplacement initial" %}</label>
<select name="current_location" class="form-select"> <select name="current_location" class="form-select">
@ -67,18 +83,24 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Notes" %}</label> <label class="form-label fw-semibold">{% translate "Notes" %}</label>
<textarea name="notes" class="form-control" rows="2" <textarea name="notes" class="form-control" rows="3"
placeholder="{% translate 'Notes optionnelles…' %}">{{ post.notes|default:'' }}</textarea> placeholder="{% translate 'Notes optionnelles…' %}">{% if action == 'edit' %}{{ post.notes|default:item.notes }}{% else %}{{ post.notes|default:'' }}{% endif %}</textarea>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check2"></i> {% translate "Enregistrer" %} <i class="bi bi-check2"></i>
{% if action == 'edit' %}
{% translate "Enregistrer les modifications" %}
{% else %}
{% translate "Enregistrer" %}
{% endif %}
</button> </button>
<a href="{% url 'stock:serialized_items_list' %}" class="btn btn-outline-secondary"> <a href="{% if next %}{{ next }}{% elif action == 'edit' %}{% url 'stock:serialized_item_detail' item.pk %}{% else %}{% url 'stock:serialized_items_list' %}{% endif %}" class="btn btn-outline-secondary">
{% translate "Annuler" %} {% translate "Annuler" %}
</a> </a>
</div> </div>
@ -89,10 +111,12 @@
{% endblock content %} {% endblock content %}
{% block script %} {% block script %}
{% if action != 'edit' %}
<script> <script>
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
const productSelect = document.querySelector('select[name="product"]'); const productSelect = document.querySelector('select[name="product"]');
const locationSelect = document.querySelector('select[name="current_location"]'); const locationSelect = document.querySelector('select[name="current_location"]');
if (!productSelect || !locationSelect) return;
const originalOptions = Array.from(locationSelect.options); const originalOptions = Array.from(locationSelect.options);
const apiBaseUrl = "{% url 'stock:product_locations_api' 0 %}".replace('/0/', '/{id}/'); const apiBaseUrl = "{% url 'stock:product_locations_api' 0 %}".replace('/0/', '/{id}/');
@ -143,4 +167,5 @@
} }
}); });
</script> </script>
{% endif %}
{% endblock script %} {% endblock script %}

View file

@ -362,8 +362,17 @@
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td>{{ item.created_at|date:"d/m/Y" }}</td> <td>{{ item.created_at|date:"d/m/Y" }}</td>
<td class="text-end"> <td class="text-end text-nowrap">
<a href="{% url 'stock:serialized_item_detail' item.pk %}" class="btn btn-sm btn-outline-primary"> {% if is_stock_manager and item.is_active %}
<a href="{% url 'stock:serialized_item_edit' item.pk %}?next={{ request.get_full_path }}"
class="btn btn-sm btn-outline-secondary"
title="{% translate 'Modifier le numéro de série' %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{% url 'stock:serialized_item_detail' item.pk %}"
class="btn btn-sm btn-outline-primary"
title="{% translate 'Consulter la fiche' %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
</td> </td>

View file

@ -14,6 +14,7 @@ from .models import (
Warehouse, Warehouse,
WarehouseLocation, WarehouseLocation,
SerializedItem, SerializedItem,
SerializedItemEvent,
PreparationOrder, PreparationOrder,
) )
@ -1005,6 +1006,162 @@ class ProductLocationsTests(TestCase):
if last_event: if last_event:
self.assertNotEqual(last_event.notes, "Transfer to fail") self.assertNotEqual(last_event.notes, "Transfer to fail")
def test_serialized_item_edit_view_get(self):
self.product.is_serialized = True
self.product.save()
item = SerializedItem.objects.create(
product=self.product,
serial_number="SN-EDIT-INIT",
current_location=self.location_ok,
status=SerializedItem.STATUS_AVAILABLE,
)
response = self.client.get(reverse("stock:serialized_item_edit", args=[item.pk]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "SN-EDIT-INIT")
def test_serialized_item_edit_view_post_success(self):
self.product.is_serialized = True
self.product.save()
item = SerializedItem.objects.create(
product=self.product,
serial_number="SN-EDIT-OLD",
current_location=self.location_ok,
status=SerializedItem.STATUS_AVAILABLE,
notes="Notes initiales",
)
next_url = reverse("stock:product_edit", args=[self.product.pk])
response = self.client.post(
reverse("stock:serialized_item_edit", args=[item.pk]),
data={
"serial_number": "SN-EDIT-NEW",
"notes": "Notes modifiées",
"next": next_url,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, next_url)
item.refresh_from_db()
self.assertEqual(item.serial_number, "SN-EDIT-NEW")
self.assertEqual(item.notes, "Notes modifiées")
# Vérifier l'événement de traçabilité dans l'historique
last_event = item.events.order_by('-created_at').first()
self.assertIsNotNone(last_event)
self.assertEqual(last_event.event_type, SerializedItemEvent.TYPE_NOTE)
self.assertIn("SN-EDIT-OLD", last_event.notes)
self.assertIn("SN-EDIT-NEW", last_event.notes)
def test_serialized_item_edit_view_post_duplicate_fails(self):
self.product.is_serialized = True
self.product.save()
SerializedItem.objects.create(
product=self.product,
serial_number="SN-EXISTING",
current_location=self.location_ok,
)
item2 = SerializedItem.objects.create(
product=self.product,
serial_number="SN-TO-CHANGE",
current_location=self.location_ok,
)
response = self.client.post(
reverse("stock:serialized_item_edit", args=[item2.pk]),
data={
"serial_number": "SN-EXISTING",
"notes": "",
},
)
self.assertEqual(response.status_code, 200)
item2.refresh_from_db()
self.assertEqual(item2.serial_number, "SN-TO-CHANGE")
def test_serialized_item_edit_view_post_empty_fails(self):
self.product.is_serialized = True
self.product.save()
item = SerializedItem.objects.create(
product=self.product,
serial_number="SN-NON-EMPTY",
current_location=self.location_ok,
)
response = self.client.post(
reverse("stock:serialized_item_edit", args=[item.pk]),
data={
"serial_number": " ",
"notes": "",
},
)
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
self.assertEqual(item.serial_number, "SN-NON-EMPTY")
def test_product_edit_context_includes_serialized_items(self):
self.product.is_serialized = True
self.product.save()
item = SerializedItem.objects.create(
product=self.product,
serial_number="SN-PROD-VIEW",
current_location=self.location_ok,
)
response = self.client.get(reverse("stock:product_edit", args=[self.product.pk]))
self.assertEqual(response.status_code, 200)
self.assertIn("serialized_items", response.context)
self.assertIn(item, response.context["serialized_items"])
self.assertContains(response, "SN-PROD-VIEW")
self.assertContains(response, "editSerialModal" + str(item.pk))
def test_product_form_clean_is_serialized_validation(self):
from .forms import ProductForm
self.product.is_serialized = True
self.product.save()
SerializedItem.objects.create(
product=self.product,
serial_number="SN-ACTIVE-PREVENT",
current_location=self.location_ok,
is_active=True,
)
# Trying to uncheck is_serialized while an active serialized item exists should fail
form = ProductForm(
data={
"code": self.product.code,
"name": self.product.name,
"sku": self.product.sku,
"unit": "pc",
"is_serialized": False,
},
instance=self.product,
)
self.assertFalse(form.is_valid())
self.assertIn("is_serialized", form.errors)
def test_mobile_serialized_item_detail_renders_edit_modal(self):
self.product.is_serialized = True
self.product.save()
item = SerializedItem.objects.create(
product=self.product,
serial_number="SN-MOB-TEST",
current_location=self.location_ok,
status=SerializedItem.STATUS_AVAILABLE,
)
response = self.client.get(reverse("mobile:stock_serialized_item_detail_mobile", args=[item.pk]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "SN-MOB-TEST")
self.assertContains(response, "editItemMobileModal")
class AssociateStockLocationsCommandTests(TestCase): class AssociateStockLocationsCommandTests(TestCase):
def setUp(self): def setUp(self):

View file

@ -442,7 +442,11 @@ class WarehouseViewsPermissionsTestCase(TestCase):
# Let's call the view # Let's call the view
response = new_stock_entry(request) response = new_stock_entry(request)
# It should show error message and stay on the form page (not redirect to success) # It should show error message and stay on the form page (not redirect to success)
self.assertTrue(any("le sous-type 'correction' est réservé aux administrateurs" in str(m).lower() for m in messages)) self.assertTrue(any(
"le sous-type 'correction' est réservé aux administrateurs" in str(m).lower()
or "subtype 'correction' is reserved for administrators" in str(m).lower()
for m in messages
))
# Scenario 2: User has can_administer=True # Scenario 2: User has can_administer=True
access.can_administer = True access.can_administer = True

View file

@ -117,6 +117,7 @@ urlpatterns = [
path("serialized/new/", views.serialized_item_create, name="serialized_item_create"), path("serialized/new/", views.serialized_item_create, name="serialized_item_create"),
path("serialized/new/<int:product_id>/", views.serialized_item_create, name="serialized_item_create_for_product"), path("serialized/new/<int:product_id>/", views.serialized_item_create, name="serialized_item_create_for_product"),
path("serialized/<int:pk>/", views.serialized_item_detail, name="serialized_item_detail"), path("serialized/<int:pk>/", views.serialized_item_detail, name="serialized_item_detail"),
path("serialized/<int:pk>/edit/", views.serialized_item_edit, name="serialized_item_edit"),
path("serialized/<int:pk>/event/", views.serialized_item_event_create, name="serialized_item_event_create"), path("serialized/<int:pk>/event/", views.serialized_item_event_create, name="serialized_item_event_create"),
path("serialized/<int:pk>/delete/", views.serialized_item_delete, name="serialized_item_delete"), path("serialized/<int:pk>/delete/", views.serialized_item_delete, name="serialized_item_delete"),
] ]

View file

@ -6078,7 +6078,18 @@ def product_edit(request, product_id):
else: else:
form = ProductForm(instance=product) form = ProductForm(instance=product)
return render(request, 'stock/product_form.html', {'form': form, 'action': 'edit', 'product': product}) serialized_items = None
if product.is_serialized:
serialized_items = product.serialized_items.select_related(
"current_location__warehouse", "current_holder"
).order_by("serial_number")
return render(request, 'stock/product_form.html', {
'form': form,
'action': 'edit',
'product': product,
'serialized_items': serialized_items,
})
@login_required @login_required
@ -6697,12 +6708,14 @@ def serialized_item_create(request, product_id=None):
if errors: if errors:
for e in errors: for e in errors:
messages.error(request, e) messages.error(request, e)
next_url = request.GET.get("next") or request.POST.get("next")
return render(request, "stock/serialized_item_form.html", { return render(request, "stock/serialized_item_form.html", {
"serialized_products": serialized_products, "serialized_products": serialized_products,
"locations": locations, "locations": locations,
"members": members, "members": members,
"initial_product": initial_product, "initial_product": initial_product,
"post": request.POST, "post": request.POST,
"next": next_url,
}) })
item = SerializedItem.objects.create( item = SerializedItem.objects.create(
@ -6744,13 +6757,87 @@ def serialized_item_create(request, product_id=None):
# (l'entrée couvre déjà la mise en stock) # (l'entrée couvre déjà la mise en stock)
messages.success(request, _("Article %(sn)s enregistré avec succès.") % {"sn": serial_number}) messages.success(request, _("Article %(sn)s enregistré avec succès.") % {"sn": serial_number})
next_url = request.GET.get("next") or request.POST.get("next")
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect("stock:serialized_item_detail", pk=item.pk) return redirect("stock:serialized_item_detail", pk=item.pk)
next_url = request.GET.get("next") or request.POST.get("next")
return render(request, "stock/serialized_item_form.html", { return render(request, "stock/serialized_item_form.html", {
"serialized_products": serialized_products, "serialized_products": serialized_products,
"locations": locations, "locations": locations,
"members": members, "members": members,
"initial_product": initial_product, "initial_product": initial_product,
"next": next_url,
})
@login_required
def serialized_item_edit(request, pk):
"""
Modifier un article tracé existant (numéro de série, notes).
Réservé aux utilisateurs pouvant gérer les produits.
"""
if not user_can_manage_products(request.user):
messages.error(request, _("Vous n'avez pas la permission de modifier cet article."))
return redirect("stock:serialized_items_list")
item = get_object_or_404(
SerializedItem.objects.select_related(
"product", "current_location__warehouse", "current_holder"
),
pk=pk,
)
next_url = request.GET.get("next") or request.POST.get("next")
if request.method == "POST":
new_serial = request.POST.get("serial_number", "").strip()
new_notes = request.POST.get("notes", "").strip()
errors = []
if not new_serial:
errors.append(_("Le numéro de série est obligatoire."))
elif SerializedItem.objects.filter(serial_number=new_serial).exclude(pk=item.pk).exists():
errors.append(_("Ce numéro de série existe déjà."))
if errors:
for err in errors:
messages.error(request, err)
if next_url and next_url.startswith("/"):
return redirect(next_url)
return render(request, "stock/serialized_item_form.html", {
"action": "edit",
"item": item,
"post": request.POST,
"next": next_url,
})
old_serial = item.serial_number
serial_changed = (old_serial != new_serial)
item.serial_number = new_serial
item.notes = new_notes
item.save()
if serial_changed:
SerializedItemEvent.objects.create(
item=item,
event_type=SerializedItemEvent.TYPE_NOTE,
notes=str(_("Numéro de série modifié : %(old)s → %(new)s")) % {"old": old_serial, "new": new_serial},
created_by=request.user,
)
messages.success(request, _("Article tracé %(sn)s mis à jour avec succès.") % {"sn": new_serial})
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect("stock:serialized_item_detail", pk=item.pk)
return render(request, "stock/serialized_item_form.html", {
"action": "edit",
"item": item,
"next": next_url,
}) })