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:
parent
5a48dc4d2f
commit
20669d545b
17 changed files with 714 additions and 13 deletions
|
|
@ -485,6 +485,17 @@ class ProductForm(forms.ModelForm):
|
|||
self.fields["authorized_locations"].required = False
|
||||
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):
|
||||
quantity = forms.IntegerField(
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6572,3 +6572,47 @@ msgstr "Please check at least one item line to include in the order."
|
|||
|
||||
#~ msgid "Rafraîchir la 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"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6626,3 +6626,47 @@ msgstr ""
|
|||
|
||||
#~ msgid "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é"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6614,3 +6614,47 @@ msgstr "Vink minimaal één artikelregel aan om op te nemen in de bestelling."
|
|||
|
||||
#~ msgid "Rafraîchir la page"
|
||||
#~ 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"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@
|
|||
<div style="flex:1;min-width:0;">
|
||||
<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>
|
||||
{% 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">
|
||||
<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 }}
|
||||
|
|
@ -288,6 +297,52 @@
|
|||
{% if item.created_by %}{% translate "par" %} {{ item.created_by.get_full_name|default:item.created_by.username }}{% endif %}
|
||||
</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 %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
|
|
|
|||
|
|
@ -631,8 +631,17 @@
|
|||
<td>
|
||||
{% if si.current_location %}{{ si.current_location.warehouse.name }} — {{ si.current_location.name }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'stock:serialized_item_detail' si.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<td class="text-end text-nowrap">
|
||||
{% 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>
|
||||
</a>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,112 @@
|
|||
{% if form.is_serialized.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.is_serialized.errors }}</div>
|
||||
{% 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>
|
||||
|
||||
<!-- Unité, prix & seuil -->
|
||||
|
|
@ -276,6 +382,52 @@
|
|||
</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 %}
|
||||
|
||||
</div>
|
||||
|
|
@ -289,6 +441,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
tooltipTriggerList.forEach(function (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' %}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
</h3>
|
||||
<div class="d-flex gap-2">
|
||||
{% 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"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteItemModal">
|
||||
<i class="bi bi-trash"></i> {% translate "Supprimer" %}
|
||||
|
|
@ -26,6 +30,51 @@
|
|||
</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 %}
|
||||
<!-- Modal suppression -->
|
||||
<div class="modal fade" id="deleteItemModal" tabindex="-1" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@
|
|||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="h3">
|
||||
<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é" %}
|
||||
{% endif %}
|
||||
</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" %}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -17,6 +21,16 @@
|
|||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% 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">
|
||||
<label class="form-label fw-semibold">{% translate "Produit" %} *</label>
|
||||
<select name="product" class="form-select" required>
|
||||
|
|
@ -36,14 +50,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% translate "Numéro de série" %} *</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{% if action != 'edit' %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% translate "Emplacement initial" %}</label>
|
||||
<select name="current_location" class="form-select">
|
||||
|
|
@ -67,18 +83,24 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% translate "Notes" %}</label>
|
||||
<textarea name="notes" class="form-control" rows="2"
|
||||
placeholder="{% translate 'Notes optionnelles…' %}">{{ post.notes|default:'' }}</textarea>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
placeholder="{% translate 'Notes optionnelles…' %}">{% if action == 'edit' %}{{ post.notes|default:item.notes }}{% else %}{{ post.notes|default:'' }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<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>
|
||||
<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" %}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -89,10 +111,12 @@
|
|||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
{% if action != 'edit' %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const productSelect = document.querySelector('select[name="product"]');
|
||||
const locationSelect = document.querySelector('select[name="current_location"]');
|
||||
if (!productSelect || !locationSelect) return;
|
||||
const originalOptions = Array.from(locationSelect.options);
|
||||
const apiBaseUrl = "{% url 'stock:product_locations_api' 0 %}".replace('/0/', '/{id}/');
|
||||
|
||||
|
|
@ -143,4 +167,5 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -362,8 +362,17 @@
|
|||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ item.created_at|date:"d/m/Y" }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'stock:serialized_item_detail' item.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<td class="text-end text-nowrap">
|
||||
{% 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>
|
||||
</a>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from .models import (
|
|||
Warehouse,
|
||||
WarehouseLocation,
|
||||
SerializedItem,
|
||||
SerializedItemEvent,
|
||||
PreparationOrder,
|
||||
)
|
||||
|
||||
|
|
@ -1005,6 +1006,162 @@ class ProductLocationsTests(TestCase):
|
|||
if last_event:
|
||||
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):
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -442,7 +442,11 @@ class WarehouseViewsPermissionsTestCase(TestCase):
|
|||
# Let's call the view
|
||||
response = new_stock_entry(request)
|
||||
# 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
|
||||
access.can_administer = True
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ urlpatterns = [
|
|||
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/<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>/delete/", views.serialized_item_delete, name="serialized_item_delete"),
|
||||
]
|
||||
|
|
@ -6078,7 +6078,18 @@ def product_edit(request, product_id):
|
|||
else:
|
||||
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
|
||||
|
|
@ -6697,12 +6708,14 @@ def serialized_item_create(request, product_id=None):
|
|||
if errors:
|
||||
for e in errors:
|
||||
messages.error(request, e)
|
||||
next_url = request.GET.get("next") or request.POST.get("next")
|
||||
return render(request, "stock/serialized_item_form.html", {
|
||||
"serialized_products": serialized_products,
|
||||
"locations": locations,
|
||||
"members": members,
|
||||
"initial_product": initial_product,
|
||||
"post": request.POST,
|
||||
"next": next_url,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
next_url = request.GET.get("next") or request.POST.get("next")
|
||||
return render(request, "stock/serialized_item_form.html", {
|
||||
"serialized_products": serialized_products,
|
||||
"locations": locations,
|
||||
"members": members,
|
||||
"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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue