feat: add stock management module, common models, and mobile views

This commit is contained in:
Karl Determe 2026-09-29 13:30:42 +02:00
parent ca4d1b3c1b
commit f429838a39
9 changed files with 654 additions and 98 deletions

View file

@ -411,7 +411,7 @@ class UserConfig(models.Model):
def can_manage_preparation_orders_any(self): def can_manage_preparation_orders_any(self):
"""Vérifie si l'utilisateur peut gérer des bons de préparation sur au moins un entrepôt.""" """Vérifie si l'utilisateur peut gérer des bons de préparation sur au moins un entrepôt."""
if self.user.is_superuser or self.has_role('admin'): if self.user.is_superuser or self.has_role('admin') or self.has_role('stock_manager'):
return True return True
if not self.userwarehouses.exists(): if not self.userwarehouses.exists():
if self.userlocations.exists(): if self.userlocations.exists():
@ -421,7 +421,7 @@ class UserConfig(models.Model):
def can_manage_preparation_orders_for(self, warehouse): def can_manage_preparation_orders_for(self, warehouse):
"""Vérifie si l'utilisateur peut gérer des bons de préparation pour un entrepôt spécifique.""" """Vérifie si l'utilisateur peut gérer des bons de préparation pour un entrepôt spécifique."""
if self.user.is_superuser or self.has_role('admin'): if self.user.is_superuser or self.has_role('admin') or self.has_role('stock_manager'):
return True return True
if not self.userwarehouses.exists(): if not self.userwarehouses.exists():
if self.userlocations.exists(): if self.userlocations.exists():

View file

@ -988,11 +988,17 @@ def stock_preparation_order_detail_mobile(request, pk):
if request.method == 'POST' and request.FILES.getlist('documents'): if request.method == 'POST' and request.FILES.getlist('documents'):
from stock.models import PreparationOrderDocument from stock.models import PreparationOrderDocument
for f in request.FILES.getlist('documents'): for f in request.FILES.getlist('documents'):
PreparationOrderDocument.objects.create( doc = PreparationOrderDocument.objects.create(
preparation_order=preparation_order, preparation_order=preparation_order,
file=f, file=f,
name=f.name, name=f.name,
) )
preparation_order.log_history(
action="document_added",
description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()),
user=request.user,
details={'document_id': doc.pk, 'filename': doc.filename()},
)
messages.success(request, _('Document(s) ajouté(s) avec succès.')) messages.success(request, _('Document(s) ajouté(s) avec succès.'))
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk) return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
@ -1056,10 +1062,12 @@ def stock_preparation_order_detail_mobile(request, pk):
'location_info': ", ".join(loc_names), 'location_info': ", ".join(loc_names),
}) })
from stock.permissions import user_can_manage_preparation_orders, user_can_edit_preparation_order
try: try:
user_config = request.user.config user_config = request.user.config
can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() can_manage = user_can_manage_preparation_orders(request.user)
can_cancel = user_config.can_manage_preparation_orders_any() can_edit = user_can_edit_preparation_order(request.user, preparation_order)
can_cancel = can_manage and preparation_order.status != 'cancelled'
can_manage_purchase = user_config.can_manage_purchase_orders_any() can_manage_purchase = user_config.can_manage_purchase_orders_any()
can_manage_stock = ( can_manage_stock = (
request.user.is_superuser request.user.is_superuser
@ -1069,10 +1077,14 @@ def stock_preparation_order_detail_mobile(request, pk):
) )
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
can_manage = False can_manage = False
can_edit = False
can_cancel = False can_cancel = False
can_manage_purchase = False can_manage_purchase = False
can_manage_stock = False can_manage_stock = False
history_entries = preparation_order.history_entries.select_related('user').order_by('-created_at')
has_pending_stock_adjustments = any(it['delta'] != 0 for it in items_with_stock) or bool(deleted_items_to_return)
other_items_with_info = [] other_items_with_info = []
for oi in preparation_order.other_items.all(): for oi in preparation_order.other_items.all():
other_items_with_info.append({ other_items_with_info.append({
@ -1109,6 +1121,7 @@ def stock_preparation_order_detail_mobile(request, pk):
'items_with_stock': items_with_stock, 'items_with_stock': items_with_stock,
'other_items': other_items_with_info, 'other_items': other_items_with_info,
'can_manage': can_manage, 'can_manage': can_manage,
'can_edit': can_edit,
'can_cancel': can_cancel, 'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase, 'can_manage_purchase': can_manage_purchase,
'can_manage_stock': can_manage_stock, 'can_manage_stock': can_manage_stock,
@ -1117,6 +1130,8 @@ def stock_preparation_order_detail_mobile(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, 'deleted_items_to_return': deleted_items_to_return,
'history_entries': history_entries,
'has_pending_stock_adjustments': has_pending_stock_adjustments,
'discussion': _discussion, 'discussion': _discussion,
'discussion_messages': discussion_messages_mobile, 'discussion_messages': discussion_messages_mobile,
'can_post_discussion_message': can_post_discussion_message_mobile, 'can_post_discussion_message': can_post_discussion_message_mobile,

View file

@ -4,7 +4,7 @@ from django.utils.html import format_html
from .models import ( from .models import (
Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement, Product, ProductCategory, Warehouse, WarehouseLocation, Stock, StockMovement,
PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem, PurchaseOrder, PurchaseOrderItem, PreparationOrder, PreparationOrderItem,
PreparationOrderOtherItem, PreparationOrderOtherItem, PreparationOrderHistory,
ProductionOrder, ProductionOrder,
SerializedItem, SerializedItemEvent, SerializedItem, SerializedItemEvent,
) )
@ -91,12 +91,23 @@ class PreparationOrderOtherItemInline(admin.TabularInline):
extra = 1 extra = 1
class PreparationOrderHistoryInline(admin.TabularInline):
model = PreparationOrderHistory
extra = 0
readonly_fields = ('created_at', 'author_short_name', 'action', 'description', 'details')
can_delete = False
def author_short_name(self, obj):
return obj.author_short_name
author_short_name.short_description = 'Auteur'
@admin.register(PreparationOrder) @admin.register(PreparationOrder)
class PreparationOrderAdmin(admin.ModelAdmin): class PreparationOrderAdmin(admin.ModelAdmin):
list_display = ('order_number', 'date', 'status', 'priority') list_display = ('order_number', 'date', 'status', 'priority')
search_fields = ('order_number',) search_fields = ('order_number',)
list_filter = ('status', 'date', 'priority') list_filter = ('status', 'date', 'priority')
inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline] inlines = [PreparationOrderItemInline, PreparationOrderOtherItemInline, PreparationOrderHistoryInline]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object if obj: # editing an existing object
@ -104,6 +115,14 @@ class PreparationOrderAdmin(admin.ModelAdmin):
return self.readonly_fields return self.readonly_fields
@admin.register(PreparationOrderHistory)
class PreparationOrderHistoryAdmin(admin.ModelAdmin):
list_display = ('preparation_order', 'created_at', 'author_short_name', 'action', 'description')
list_filter = ('action', 'created_at')
search_fields = ('preparation_order__order_number', 'user_short_name', 'description')
readonly_fields = ('preparation_order', 'user', 'user_short_name', 'created_at', 'action', 'description', 'details')
@admin.register(PreparationOrderItem) @admin.register(PreparationOrderItem)
class PreparationOrderItemAdmin(admin.ModelAdmin): class PreparationOrderItemAdmin(admin.ModelAdmin):
list_display = ('preparation_order', 'product', 'quantity', 'is_prepared', 'is_used') list_display = ('preparation_order', 'product', 'quantity', 'is_prepared', 'is_used')

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-09-29 11:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0002_preparationorderitem_is_used_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PreparationOrderHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_short_name', models.CharField(blank=True, max_length=150, verbose_name='Nom court')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date et heure')),
('action', models.CharField(choices=[('created', 'Création'), ('item_added', 'Ajout de produit'), ('quantity_changed', 'Modification de quantité'), ('item_removed', 'Suppression de produit'), ('other_item_added', "Ajout d'article personnalisé"), ('other_item_updated', "Modification d'article personnalisé"), ('other_item_removed', "Suppression d'article personnalisé"), ('status_changed', 'Changement de statut'), ('reopened', 'Réouverture'), ('processed', 'Traitement'), ('cancelled', 'Annulation'), ('loan_updated', 'Modification du prêt'), ('document_added', 'Ajout de document'), ('document_removed', 'Suppression de document'), ('other', 'Autre modification')], default='other', max_length=50, verbose_name='Action')),
('description', models.TextField(verbose_name='Description')),
('details', models.JSONField(blank=True, default=dict, verbose_name='Détails')),
('preparation_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history_entries', to='stock.preparationorder', verbose_name='Bon de préparation')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preparation_order_history_entries', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')),
],
options={
'verbose_name': 'Historique de bon de préparation',
'verbose_name_plural': 'Historiques de bons de préparation',
'ordering': ['-created_at'],
},
),
]

View file

@ -1,4 +1,5 @@
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Sum, F from django.db.models import Sum, F
from django.utils import timezone from django.utils import timezone
@ -1673,6 +1674,21 @@ class PreparationOrder(models.Model):
def total_items_count(self): def total_items_count(self):
return self.preparationorderitem_set.count() + self.other_items.count() return self.preparationorderitem_set.count() + self.other_items.count()
def log_history(self, action, description, user=None, details=None):
"""Enregistre un événement dans l'historique du bon de préparation."""
from common.utils import get_short_name
user_short = ""
if user and getattr(user, "is_authenticated", False):
user_short = get_short_name(user)
return self.history_entries.create(
user=user if (user and getattr(user, "is_authenticated", False)) else None,
user_short_name=user_short,
action=action,
description=description,
details=details or {},
)
class PreparationOrderItem(models.Model): class PreparationOrderItem(models.Model):
@ -1866,6 +1882,134 @@ class PreparationOrderOtherItem(models.Model):
) )
class PreparationOrderHistory(models.Model):
"""
Historique des modifications apportées à un bon de préparation.
Trace les ajouts, modifications de quantité, suppressions et changements de statut avec utilisateur (shortname) et date.
"""
ACTION_CHOICES = [
("created", _("Création")),
("item_added", _("Ajout de produit")),
("quantity_changed", _("Modification de quantité")),
("item_removed", _("Suppression de produit")),
("other_item_added", _("Ajout d'article personnalisé")),
("other_item_updated", _("Modification d'article personnalisé")),
("other_item_removed", _("Suppression d'article personnalisé")),
("status_changed", _("Changement de statut")),
("reopened", _("Réouverture")),
("processed", _("Traitement")),
("cancelled", _("Annulation")),
("loan_updated", _("Modification du prêt")),
("document_added", _("Ajout de document")),
("document_removed", _("Suppression de document")),
("other", _("Autre modification")),
]
preparation_order = models.ForeignKey(
PreparationOrder,
on_delete=models.CASCADE,
related_name="history_entries",
verbose_name=_("Bon de préparation"),
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="preparation_order_history_entries",
verbose_name=_("Utilisateur"),
)
user_short_name = models.CharField(
max_length=150,
blank=True,
verbose_name=_("Nom court"),
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Date et heure"),
)
action = models.CharField(
max_length=50,
choices=ACTION_CHOICES,
default="other",
verbose_name=_("Action"),
)
description = models.TextField(
verbose_name=_("Description"),
)
details = models.JSONField(
default=dict,
blank=True,
verbose_name=_("Détails"),
)
class Meta:
verbose_name = _("Historique de bon de préparation")
verbose_name_plural = _("Historiques de bons de préparation")
ordering = ["-created_at"]
def __str__(self):
return f"[{self.created_at.strftime('%d/%m/%Y %H:%M')}] {self.author_short_name}: {self.description}"
def save(self, *args, **kwargs):
if not self.user_short_name and self.user:
from common.utils import get_short_name
self.user_short_name = get_short_name(self.user)
super().save(*args, **kwargs)
@property
def author_short_name(self):
if self.user:
from common.utils import get_short_name
name = get_short_name(self.user)
if name:
return name
return self.user_short_name or _("Système")
@property
def badge_class(self):
badges = {
"created": "bg-primary",
"item_added": "bg-success",
"quantity_changed": "bg-warning text-dark",
"item_removed": "bg-danger",
"other_item_added": "bg-info text-dark",
"other_item_updated": "bg-warning text-dark",
"other_item_removed": "bg-danger",
"status_changed": "bg-info text-dark",
"reopened": "bg-warning text-dark",
"processed": "bg-success",
"cancelled": "bg-secondary",
"loan_updated": "bg-primary",
"document_added": "bg-secondary",
"document_removed": "bg-secondary",
}
return badges.get(self.action, "bg-secondary")
@property
def icon_class(self):
icons = {
"created": "bi-plus-circle-fill",
"item_added": "bi-box-seam-fill",
"quantity_changed": "bi-pencil-fill",
"item_removed": "bi-trash-fill",
"other_item_added": "bi-tag-fill",
"other_item_updated": "bi-pencil-square",
"other_item_removed": "bi-trash-fill",
"status_changed": "bi-arrow-repeat",
"reopened": "bi-arrow-counterclockwise",
"processed": "bi-check2-circle",
"cancelled": "bi-x-circle-fill",
"loan_updated": "bi-calendar-event-fill",
"document_added": "bi-paperclip",
"document_removed": "bi-trash",
}
return icons.get(self.action, "bi-clock-history")
# class ProductDesign(models.Model): # class ProductDesign(models.Model):
# """ # """
# Conception de produit : une fiche/recette décrivant comment assembler # Conception de produit : une fiche/recette décrivant comment assembler

View file

@ -303,3 +303,36 @@ def user_can_manage_products(user):
config.has_role('admin') config.has_role('admin')
or config.can_administer_any_warehouse() or config.can_administer_any_warehouse()
) )
def user_can_manage_preparation_orders(user, warehouse=None):
"""
Vérifie si l'utilisateur peut gérer des bons de préparation (création, traitement, annulation).
Autorisé pour les superutilisateurs, le rôle admin, le rôle stock_manager (magasinier),
ou les utilisateurs ayant la permission can_manage_preparation_orders.
"""
if not user.is_authenticated:
return False
if user.is_superuser:
return True
config = get_user_config(user)
if not config:
return False
if config.has_role('admin') or config.has_role('stock_manager'):
return True
if warehouse:
return config.can_manage_preparation_orders_for(warehouse)
return config.can_manage_preparation_orders_any()
def user_can_edit_preparation_order(user, preparation_order=None):
"""
Vérifie si l'utilisateur peut modifier un bon de préparation.
La modification est possible pour les gestionnaires et magasiniers,
même lorsque le bon est au statut 'traité' (processed).
Seul le statut 'annulé' (cancelled) est non modifiable.
"""
if preparation_order and preparation_order.status == 'cancelled':
return False
return user_can_manage_preparation_orders(user)

View file

@ -116,6 +116,21 @@
</div> </div>
{% endif %} {% endif %}
{% if preparation_order.status == 'processed' and has_pending_stock_adjustments %}
<div class="alert alert-warning border-warning shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-exclamation-triangle-fill text-warning me-2 fs-4"></i>
<h6 class="alert-heading mb-0 fw-bold">{% translate "Ajustements de stock en attente !" %}</h6>
</div>
<p class="mb-2 small" style="font-size:0.82rem;">{% translate "Ce bon a été modifié après son traitement. Une régularisation du stock est nécessaire." %}</p>
{% if can_manage %}
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-warning btn-sm w-100">
<i class="bi bi-check-circle me-1"></i> {% translate "Régulariser les stocks maintenant" %}
</a>
{% endif %}
</div>
{% endif %}
<!-- Intervention liée --> <!-- Intervention liée -->
{% if preparation_order.intervention %} {% if preparation_order.intervention %}
<div class="info-card"> <div class="info-card">
@ -465,6 +480,11 @@
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#confirmProcessModalMobile"> <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#confirmProcessModalMobile">
<i class="bi bi-check2-circle me-1"></i>{% translate "Signaler comme traité" %} <i class="bi bi-check2-circle me-1"></i>{% translate "Signaler comme traité" %}
</button> </button>
{% if can_edit %}
<a href="{% url 'stock:preparation_order_detail' preparation_order.pk %}?open_edit=1" class="btn btn-outline-warning">
<i class="bi bi-pencil-square me-1"></i>{% translate "Modifier les articles" %}
</a>
{% endif %}
{% if can_manage_purchase %} {% if can_manage_purchase %}
<a href="{% url 'stock:preparation_order_create_purchase' preparation_order.pk %}" class="btn btn-primary"> <a href="{% url 'stock:preparation_order_create_purchase' preparation_order.pk %}" class="btn btn-primary">
<i class="bi bi-cart-plus me-1"></i>{% translate "Créer un bon de commande" %} <i class="bi bi-cart-plus me-1"></i>{% translate "Créer un bon de commande" %}
@ -480,6 +500,16 @@
</button> </button>
{% endif %} {% endif %}
{% elif preparation_order.status == 'processed' %} {% elif preparation_order.status == 'processed' %}
{% if can_edit %}
<a href="{% url 'stock:preparation_order_detail' preparation_order.pk %}?open_edit=1" class="btn btn-outline-primary">
<i class="bi bi-pencil-square me-1"></i>{% translate "Modifier le bon" %}
</a>
{% endif %}
{% if has_pending_stock_adjustments %}
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>{% translate "Régulariser les stocks" %}
</a>
{% endif %}
{% if can_manage_stock %} {% if can_manage_stock %}
<a href="{% url 'mobile:new_stock_entry_mobile' %}?preparation_order={{ preparation_order.pk }}" class="btn btn-info text-white"> <a href="{% url 'mobile:new_stock_entry_mobile' %}?preparation_order={{ preparation_order.pk }}" class="btn btn-info text-white">
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Créer un retour de stock" %} <i class="bi bi-arrow-return-left me-1"></i>{% translate "Créer un retour de stock" %}
@ -564,6 +594,38 @@
</form> </form>
</div> </div>
<!-- Historique des modifications -->
<div class="section-title mt-3">
<i class="bi bi-clock-history me-1"></i>{% translate "Historique des modifications" %}
<span class="badge bg-secondary ms-1">{{ history_entries|length }}</span>
</div>
<div class="info-card">
{% if history_entries %}
{% for entry in history_entries %}
<div class="py-2 border-bottom {% if forloop.last %}border-bottom-0{% endif %}">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge {{ entry.badge_class }}" style="font-size:0.75rem;">
<i class="bi {{ entry.icon_class }} me-1"></i>{{ entry.get_action_display }}
</span>
<small class="text-muted" style="font-size:0.75rem;">
{{ entry.created_at|date:"d/m/Y H:i" }}
</small>
</div>
<div class="d-flex justify-content-between align-items-baseline">
<span class="small fw-semibold text-secondary">
<i class="bi bi-person me-1"></i>{{ entry.author_short_name }}
</span>
</div>
<div class="small text-dark mt-1" style="font-size:0.83rem;">
{{ entry.description }}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic mb-0 small text-center py-2">{% translate "Aucun historique disponible." %}</p>
{% endif %}
</div>
<script> <script>
(function() { (function() {
const dropZone = document.getElementById('mob-drop-zone'); const dropZone = document.getElementById('mob-drop-zone');

View file

@ -98,6 +98,23 @@
</div> </div>
{% endif %} {% endif %}
{% if preparation_order.status == 'processed' and has_pending_stock_adjustments %}
<div class="alert alert-warning border-warning shadow-sm mb-3" role="alert">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill text-warning me-3 fs-1"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">{% translate "Ajustements de stock en attente !" %}</h5>
<p class="mb-2">{% translate "Ce bon de préparation a été modifié après son traitement initial. Les quantités demandées ont changé et nécessitent une régularisation du stock (sortie de stock additionnelle ou réintégration)." %}</p>
{% if can_manage %}
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-warning btn-sm">
<i class="bi bi-check-circle me-1"></i> {% translate "Régulariser les stocks maintenant" %}
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Intervention liée --> <!-- Intervention liée -->
{% if preparation_order.intervention %} {% if preparation_order.intervention %}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
@ -267,9 +284,16 @@
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-box-seam"></i> {% translate "Produits demandés" %} <i class="bi bi-box-seam"></i> {% translate "Produits demandés" %}
</h5> </h5>
<span class="badge bg-secondary"> <div class="d-flex align-items-center gap-2">
{{ items_with_stock|length }} {% translate "article(s)" %} <span class="badge bg-secondary">
</span> {{ items_with_stock|length }} {% translate "article(s)" %}
</span>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editPreparationOrderModal">
<i class="bi bi-pencil me-1"></i> {% translate "Modifier les articles" %}
</button>
{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
@ -323,7 +347,17 @@
{% if has_executed_outputs %} {% if has_executed_outputs %}
<td class="text-center"> <td class="text-center">
{% if preparation_order.status == 'processed' %} {% if preparation_order.status == 'processed' %}
{% if item_data.gross_in > 0 and item_data.executed_qty == 0 %} {% if item_data.delta > 0 %}
<span class="badge bg-warning text-dark fs-6" title="{% translate 'Complément à prélever' %}">
<i class="bi bi-plus-circle me-1"></i>+{{ item_data.delta }} {% translate "à prélever" %}
</span>
<br><small class="text-muted">{% translate "Sorti net" %}: {{ item_data.executed_qty }}</small>
{% elif item_data.delta < 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Surplus à restituer en stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{{ item_data.delta }} {% translate "à restituer" %}
</span>
<br><small class="text-muted">{% translate "Sorti net" %}: {{ item_data.executed_qty }}</small>
{% elif item_data.gross_in > 0 and item_data.executed_qty == 0 %}
<span class="badge bg-info text-dark fs-6" title="{% translate 'Tous les articles ont été restitués au stock' %}"> <span class="badge bg-info text-dark fs-6" title="{% translate 'Tous les articles ont été restitués au stock' %}">
<i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué au stock" %} ({{ item_data.gross_in }}) <i class="bi bi-arrow-return-left me-1"></i>{% translate "Restitué au stock" %} ({{ item_data.gross_in }})
</span> </span>
@ -597,6 +631,60 @@
</div> </div>
{% endcomment %} {% endcomment %}
<!-- Historique des modifications -->
<div class="card mb-3 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-clock-history me-1"></i> {% translate "Historique des modifications" %}
</h5>
<span class="badge bg-secondary">
{{ history_entries|length }} {% translate "événement(s)" %}
</span>
</div>
<div class="card-body p-0">
{% if history_entries %}
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 170px;">{% translate "Date / Heure" %}</th>
<th style="width: 160px;">{% translate "Utilisateur" %}</th>
<th style="width: 160px;">{% translate "Action" %}</th>
<th>{% translate "Détails" %}</th>
</tr>
</thead>
<tbody>
{% for entry in history_entries %}
<tr>
<td class="text-nowrap small text-muted">
<i class="bi bi-calendar3 me-1"></i>{{ entry.created_at|date:"d/m/Y H:i:s" }}
</td>
<td>
<span class="fw-semibold">
<i class="bi bi-person me-1 text-secondary"></i>{{ entry.author_short_name }}
</span>
</td>
<td>
<span class="badge {{ entry.badge_class }}">
<i class="bi {{ entry.icon_class }} me-1"></i>{{ entry.get_action_display }}
</span>
</td>
<td>
{{ entry.description }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted text-center">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun historique disponible pour ce bon." %}
</div>
{% endif %}
</div>
</div>
<!-- Actions --> <!-- Actions -->
{% if can_manage %} {% if can_manage %}
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -640,7 +728,7 @@
</div> </div>
</div> </div>
{% if can_cancel %} {% if can_edit %}
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<div class="card h-100 border-warning"> <div class="card h-100 border-warning">
<div class="card-body text-center"> <div class="card-body text-center">
@ -653,7 +741,9 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% if can_cancel %}
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<div class="card h-100 border-danger"> <div class="card h-100 border-danger">
<div class="card-body text-center"> <div class="card-body text-center">
@ -672,6 +762,36 @@
</div> </div>
{% endif %} {% endif %}
{% elif preparation_order.status == 'processed' %} {% elif preparation_order.status == 'processed' %}
{% if can_edit %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-primary">
<div class="card-body text-center">
<i class="bi bi-pencil-square text-primary" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Modifier le bon de préparation" %}</h6>
<p class="text-muted small">{% translate "Modifier les quantités ou les articles du bon sans changer son statut" %}</p>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editPreparationOrderModal">
<i class="bi bi-pencil me-1"></i> {% translate "Modifier le bon" %}
</button>
</div>
</div>
</div>
{% endif %}
{% if has_pending_stock_adjustments and can_manage %}
<div class="col-md-4 mb-3">
<div class="card h-100 border-warning bg-light">
<div class="card-body text-center">
<i class="bi bi-arrow-repeat text-warning" style="font-size: 2rem;"></i>
<h6 class="mt-2">{% translate "Régulariser les stocks" %}</h6>
<p class="text-muted small">{% translate "Des ajustements de stock sont nécessaires suite aux modifications apportées" %}</p>
<a href="{% url 'stock:preparation_order_process' preparation_order.pk %}" class="btn btn-warning">
<i class="bi bi-check-circle me-1"></i> {% translate "Régulariser maintenant" %}
</a>
</div>
</div>
</div>
{% endif %}
{% if can_manage_stock %} {% if can_manage_stock %}
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="card h-100 border-info"> <div class="card h-100 border-info">
@ -738,7 +858,7 @@
</div> </div>
</div> </div>
{% if can_cancel %} {% if can_edit %}
<!-- Modale de modification du bon de préparation --> <!-- Modale de modification du bon de préparation -->
<div class="modal fade" id="editPreparationOrderModal" tabindex="-1" aria-labelledby="editPreparationOrderModalLabel" aria-hidden="true"> <div class="modal fade" id="editPreparationOrderModal" tabindex="-1" aria-labelledby="editPreparationOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" style="height:90vh;max-height:90vh;"> <div class="modal-dialog modal-xl" style="height:90vh;max-height:90vh;">

View file

@ -3519,6 +3519,13 @@ def new_preparation_order(request):
name=f.name, name=f.name,
) )
preparation_order.log_history(
action="created",
description=_("Création du bon de préparation"),
user=request.user,
details={'order_number': preparation_order.order_number},
)
messages.success(request, _("Commande de préparation enregistrée.")) messages.success(request, _("Commande de préparation enregistrée."))
return redirect("stock:stock_movements") return redirect("stock:stock_movements")
else: else:
@ -3662,6 +3669,15 @@ def new_preparation_order_from_intervention(request, intervention_id):
name=f.name, name=f.name,
) )
preparation_order.log_history(
action="created",
description=_("Création du bon de préparation depuis l'intervention #{intervention_id}").format(
intervention_id=intervention.id
),
user=request.user,
details={'order_number': preparation_order.order_number, 'intervention_id': intervention.id},
)
messages.success(request, _("Bon de préparation créé avec succès.")) messages.success(request, _("Bon de préparation créé avec succès."))
return redirect(redirect_target, intervention_id=intervention_id) return redirect(redirect_target, intervention_id=intervention_id)
else: else:
@ -3869,11 +3885,17 @@ def preparation_order_detail(request, pk):
# Ajout de documents via POST # Ajout de documents via POST
if request.method == 'POST' and request.FILES.getlist('documents'): if request.method == 'POST' and request.FILES.getlist('documents'):
for f in request.FILES.getlist('documents'): for f in request.FILES.getlist('documents'):
PreparationOrderDocument.objects.create( doc = PreparationOrderDocument.objects.create(
preparation_order=preparation_order, preparation_order=preparation_order,
file=f, file=f,
name=f.name, name=f.name,
) )
preparation_order.log_history(
action="document_added",
description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()),
user=request.user,
details={'document_id': doc.pk, 'filename': doc.filename()},
)
messages.success(request, _("Document(s) ajouté(s) avec succès.")) messages.success(request, _("Document(s) ajouté(s) avec succès."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -3942,11 +3964,13 @@ def preparation_order_detail(request, pk):
}) })
# Vérifier les permissions # Vérifier les permissions
from stock.permissions import user_can_manage_preparation_orders, user_can_edit_preparation_order
from common.models import UserConfig from common.models import UserConfig
try: try:
user_config = request.user.config user_config = request.user.config
can_manage = request.user.is_superuser or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any() can_manage = user_can_manage_preparation_orders(request.user)
can_cancel = user_config.can_manage_preparation_orders_any() can_edit = user_can_edit_preparation_order(request.user, preparation_order)
can_cancel = can_manage and preparation_order.status != 'cancelled'
can_manage_purchase = user_config.can_manage_purchase_orders_any() can_manage_purchase = user_config.can_manage_purchase_orders_any()
can_manage_stock = ( can_manage_stock = (
request.user.is_superuser request.user.is_superuser
@ -3956,10 +3980,14 @@ def preparation_order_detail(request, pk):
) )
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
can_manage = False can_manage = False
can_edit = False
can_cancel = False can_cancel = False
can_manage_purchase = False can_manage_purchase = False
can_manage_stock = False can_manage_stock = False
history_entries = preparation_order.history_entries.select_related('user').order_by('-created_at')
has_pending_stock_adjustments = any(it['delta'] != 0 for it in items_with_stock) or bool(deleted_items_to_return)
# Enrichir les autres produits avec leur bon de confection éventuel # Enrichir les autres produits avec leur bon de confection éventuel
other_items_with_info = [] other_items_with_info = []
for oi in preparation_order.other_items.all(): for oi in preparation_order.other_items.all():
@ -4002,6 +4030,7 @@ def preparation_order_detail(request, pk):
'items_with_stock': items_with_stock, 'items_with_stock': items_with_stock,
'other_items': other_items_with_info, 'other_items': other_items_with_info,
'can_manage': can_manage, 'can_manage': can_manage,
'can_edit': can_edit,
'can_cancel': can_cancel, 'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase, 'can_manage_purchase': can_manage_purchase,
'can_manage_stock': can_manage_stock, 'can_manage_stock': can_manage_stock,
@ -4011,6 +4040,8 @@ def preparation_order_detail(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, 'deleted_items_to_return': deleted_items_to_return,
'history_entries': history_entries,
'has_pending_stock_adjustments': has_pending_stock_adjustments,
'discussion': discussion, 'discussion': discussion,
'discussion_messages': discussion_messages, 'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message, 'can_post_discussion_message': can_post_discussion_message,
@ -4060,15 +4091,8 @@ def preparation_order_cancel(request, pk):
Annule un bon de préparation et libère les quantités réservées. Annule un bon de préparation et libère les quantités réservées.
Si le bon avait déjà fait l'objet de sorties de stock, réintègre l'ensemble des produits sortis. Si le bon avait déjà fait l'objet de sorties de stock, réintègre l'ensemble des produits sortis.
""" """
# Vérifier les permissions from stock.permissions import user_can_manage_preparation_orders
from common.models import UserConfig if not user_can_manage_preparation_orders(request.user):
try:
user_config = request.user.config
can_manage = user_config.can_manage_preparation_orders_any()
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage:
messages.error(request, _("Vous n'avez pas la permission d'annuler des bons de préparation.")) messages.error(request, _("Vous n'avez pas la permission d'annuler des bons de préparation."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -4108,6 +4132,12 @@ def preparation_order_cancel(request, pk):
stock.save() stock.save()
preparation_order.cancel() preparation_order.cancel()
preparation_order.log_history(
action="cancelled",
description=_("Annulation du bon de préparation (quantités réservées libérées, stock restitué le cas échéant)"),
user=request.user,
details={'status': 'cancelled'},
)
messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number)) messages.success(request, _("Le bon de préparation #{} a été annulé.").format(preparation_order.order_number))
else: else:
messages.error(request, _("Ce bon ne peut pas être annulé.")) messages.error(request, _("Ce bon ne peut pas être annulé."))
@ -4120,14 +4150,8 @@ def preparation_order_reopen(request, pk):
""" """
Réouvre un bon de préparation traité pour permettre sa modification (statut : to_process). Réouvre un bon de préparation traité pour permettre sa modification (statut : to_process).
""" """
from common.models import UserConfig from stock.permissions import user_can_manage_preparation_orders
try: if not user_can_manage_preparation_orders(request.user):
user_config = request.user.config
can_manage = user_config.can_manage_preparation_orders_any()
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage:
messages.error(request, _("Vous n'avez pas la permission de réouvrir des bons de préparation.")) messages.error(request, _("Vous n'avez pas la permission de réouvrir des bons de préparation."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -4135,6 +4159,12 @@ def preparation_order_reopen(request, pk):
if preparation_order.status == 'processed': if preparation_order.status == 'processed':
preparation_order.reopen() preparation_order.reopen()
preparation_order.log_history(
action="reopened",
description=_("Réouverture du bon de préparation (statut : À traiter)"),
user=request.user,
details={'status': 'to_process'},
)
messages.success(request, _("Le bon de préparation #{} a été réouvert (statut : À traiter).").format(preparation_order.order_number)) messages.success(request, _("Le bon de préparation #{} a été réouvert (statut : À traiter).").format(preparation_order.order_number))
return redirect(f"{reverse('stock:preparation_order_detail', args=[pk])}?open_edit=1") return redirect(f"{reverse('stock:preparation_order_detail', args=[pk])}?open_edit=1")
else: else:
@ -4149,21 +4179,18 @@ def preparation_order_reopen(request, pk):
def preparation_order_edit(request, pk): def preparation_order_edit(request, pk):
""" """
API JSON pour modifier les articles d'un bon de préparation (ajout, suppression, modification de quantité). API JSON pour modifier les articles d'un bon de préparation (ajout, suppression, modification de quantité).
Réservé aux utilisateurs avec le droit de gérer les bons de préparation. Réservé aux utilisateurs avec le droit de gérer les bons de préparation (y compris magasiniers).
Possible aux statuts 'to_process', 'pending' ou 'processed'.
""" """
from common.models import UserConfig from stock.permissions import user_can_edit_preparation_order
from django.http import JsonResponse from django.http import JsonResponse
try:
user_config = request.user.config
if not user_config.can_manage_preparation_orders_any():
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
except UserConfig.DoesNotExist:
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
preparation_order = get_object_or_404(PreparationOrder, pk=pk) preparation_order = get_object_or_404(PreparationOrder, pk=pk)
if preparation_order.status not in ['to_process', 'pending']: if not user_can_edit_preparation_order(request.user, preparation_order):
return JsonResponse({'error': _("Droits insuffisants ou bon non modifiable.")}, status=403)
if preparation_order.status not in ['to_process', 'pending', 'processed']:
return JsonResponse({'error': _("Ce bon ne peut plus être modifié.")}, status=400) return JsonResponse({'error': _("Ce bon ne peut plus être modifié.")}, status=400)
if request.method == 'GET': if request.method == 'GET':
@ -4223,6 +4250,23 @@ def preparation_order_edit(request, pk):
if not created: if not created:
item.quantity += quantity item.quantity += quantity
item.save() item.save()
preparation_order.log_history(
action="item_added",
description=_("Ajout de {qty} × {product} (nouvelle quantité : {total})").format(
qty=quantity, product=product.name, total=item.quantity
),
user=request.user,
details={'product_id': product.pk, 'product_name': product.name, 'quantity_added': quantity, 'new_quantity': item.quantity},
)
else:
preparation_order.log_history(
action="item_added",
description=_("Ajout du produit {product} (quantité : {qty})").format(
product=product.name, qty=quantity
),
user=request.user,
details={'product_id': product.pk, 'product_name': product.name, 'quantity': quantity},
)
return JsonResponse({ return JsonResponse({
'id': item.pk, 'id': item.pk,
'product_id': product.pk, 'product_id': product.pk,
@ -4253,6 +4297,22 @@ def preparation_order_edit(request, pk):
if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
prod_order.quantity = quantity prod_order.quantity = quantity
prod_order.save(update_fields=['quantity', 'updated_at']) prod_order.save(update_fields=['quantity', 'updated_at'])
if quantity != old_quantity:
preparation_order.log_history(
action="quantity_changed",
description=_("Quantité de {product} modifiée de {old_qty} à {new_qty}").format(
product=item.product.name, old_qty=old_quantity, new_qty=quantity
),
user=request.user,
details={
'item_id': item.pk,
'product_id': item.product.pk,
'product_name': item.product.name,
'old_quantity': old_quantity,
'new_quantity': quantity,
},
)
return JsonResponse({'id': item.pk, 'quantity': item.quantity}) return JsonResponse({'id': item.pk, 'quantity': item.quantity})
elif action == 'remove_item': elif action == 'remove_item':
@ -4260,6 +4320,10 @@ def preparation_order_edit(request, pk):
if not item_id: if not item_id:
return JsonResponse({'error': _("Données invalides.")}, status=400) return JsonResponse({'error': _("Données invalides.")}, status=400)
item = get_object_or_404(PreparationOrderItem, pk=item_id, preparation_order=preparation_order) item = get_object_or_404(PreparationOrderItem, pk=item_id, preparation_order=preparation_order)
prod_name = item.product.name
prod_id = item.product.pk
removed_qty = item.quantity
# Vérifier si un bon de confection est lié # Vérifier si un bon de confection est lié
prod_order = item.production_orders.first() prod_order = item.production_orders.first()
if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS:
@ -4279,6 +4343,15 @@ def preparation_order_edit(request, pk):
else: else:
item.delete() item.delete()
preparation_order.log_history(
action="item_removed",
description=_("Suppression du produit {product} (quantité initiale : {qty})").format(
product=prod_name, qty=removed_qty
),
user=request.user,
details={'product_id': prod_id, 'product_name': prod_name, 'quantity': removed_qty},
)
return JsonResponse({'deleted': item_id}) return JsonResponse({'deleted': item_id})
elif action == 'add_other_item': elif action == 'add_other_item':
@ -4311,6 +4384,14 @@ def preparation_order_edit(request, pk):
category=category, category=category,
sign_panel_type=sign_panel_type, sign_panel_type=sign_panel_type,
) )
preparation_order.log_history(
action="other_item_added",
description=_("Ajout d'article personnalisé : {desc} (quantité : {qty})").format(
desc=description, qty=quantity
),
user=request.user,
details={'description': description, 'quantity': quantity},
)
return JsonResponse({ return JsonResponse({
'id': oi.pk, 'id': oi.pk,
'description': oi.description, 'description': oi.description,
@ -4331,6 +4412,8 @@ def preparation_order_edit(request, pk):
if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS:
return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Modification impossible.").format( return JsonResponse({'error': _("Ce produit a un bon de confection en cours ou terminé ({code}, statut : {status}). Modification impossible.").format(
code=prod_order.code, status=prod_order.get_status_display())}, status=400) code=prod_order.code, status=prod_order.get_status_display())}, status=400)
old_description = oi.description
old_qty = oi.quantity
description = (data.get('description') or '').strip() description = (data.get('description') or '').strip()
quantity = data.get('quantity') quantity = data.get('quantity')
if description: if description:
@ -4363,6 +4446,23 @@ def preparation_order_edit(request, pk):
if quantity is not None and prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: if quantity is not None and prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
prod_order.quantity = oi.quantity prod_order.quantity = oi.quantity
prod_order.save(update_fields=['quantity', 'updated_at']) prod_order.save(update_fields=['quantity', 'updated_at'])
if old_description != oi.description or old_qty != oi.quantity:
preparation_order.log_history(
action="other_item_updated",
description=_("Modification d'article personnalisé : {desc} (quantité : {qty})").format(
desc=oi.description, qty=oi.quantity
),
user=request.user,
details={
'item_id': oi.pk,
'old_description': old_description,
'new_description': oi.description,
'old_quantity': old_qty,
'new_quantity': oi.quantity,
},
)
return JsonResponse({ return JsonResponse({
'id': oi.pk, 'id': oi.pk,
'description': oi.description, 'description': oi.description,
@ -4378,6 +4478,8 @@ def preparation_order_edit(request, pk):
if not oi_id: if not oi_id:
return JsonResponse({'error': _("Données invalides.")}, status=400) return JsonResponse({'error': _("Données invalides.")}, status=400)
oi = get_object_or_404(PreparationOrderOtherItem, pk=oi_id, preparation_order=preparation_order) oi = get_object_or_404(PreparationOrderOtherItem, pk=oi_id, preparation_order=preparation_order)
oi_desc = oi.description
oi_qty = oi.quantity
# Vérifier si un bon de confection est lié # Vérifier si un bon de confection est lié
prod_order = oi.production_orders.first() prod_order = oi.production_orders.first()
if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS: if prod_order and prod_order.status != ProductionOrder.STATUS_TO_PROCESS:
@ -4387,6 +4489,14 @@ def preparation_order_edit(request, pk):
if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS: if prod_order and prod_order.status == ProductionOrder.STATUS_TO_PROCESS:
prod_order.cancel() prod_order.cancel()
oi.delete() oi.delete()
preparation_order.log_history(
action="other_item_removed",
description=_("Suppression de l'article personnalisé : {desc} (quantité : {qty})").format(
desc=oi_desc, qty=oi_qty
),
user=request.user,
details={'description': oi_desc, 'quantity': oi_qty},
)
return JsonResponse({'deleted': oi_id}) return JsonResponse({'deleted': oi_id})
return JsonResponse({'error': _("Action inconnue.")}, status=400) return JsonResponse({'error': _("Action inconnue.")}, status=400)
@ -4396,38 +4506,37 @@ def preparation_order_edit(request, pk):
def preparation_order_process(request, pk): def preparation_order_process(request, pk):
""" """
Traite un bon de préparation - permet de sortir les produits du stock Traite un bon de préparation - permet de sortir les produits du stock.
Accessible également au statut 'processed' pour régulariser les stocks après modification.
""" """
# Vérifier les permissions # Vérifier les permissions
try: from stock.permissions import user_can_manage_preparation_orders
user_config = request.user.config if not user_can_manage_preparation_orders(request.user):
can_manage = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.can_manage_preparation_orders_any()
)
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage:
messages.error(request, _("Vous n'avez pas la permission de traiter des bons de préparation.")) messages.error(request, _("Vous n'avez pas la permission de traiter des bons de préparation."))
return redirect('stock:stock_movements') return redirect('stock:stock_movements')
user_config = getattr(request.user, 'config', None)
# Récupérer les entrepôts autorisés pour l'utilisateur # Récupérer les entrepôts autorisés pour l'utilisateur
if request.user.is_superuser or user_config.has_role('admin'): if request.user.is_superuser or (user_config and user_config.has_role('admin')):
allowed_locations = WarehouseLocation.objects.select_related('warehouse').all() allowed_locations = WarehouseLocation.objects.select_related('warehouse').all()
else: elif user_config:
user_warehouses = user_config.userwarehouses.filter(can_manage_preparation_orders=True).values_list('warehouse_id', flat=True) user_warehouses = user_config.userwarehouses.filter(can_manage_preparation_orders=True).values_list('warehouse_id', flat=True)
allowed_locations = WarehouseLocation.objects.filter( if user_config.has_role('stock_manager') and not user_warehouses.exists():
warehouse_id__in=user_warehouses allowed_locations = WarehouseLocation.objects.select_related('warehouse').all()
).select_related('warehouse') else:
allowed_locations = WarehouseLocation.objects.filter(
warehouse_id__in=user_warehouses
).select_related('warehouse')
else:
allowed_locations = WarehouseLocation.objects.none()
preparation_order = get_object_or_404( preparation_order = get_object_or_404(
PreparationOrder.objects.prefetch_related('preparationorderitem_set__product'), PreparationOrder.objects.prefetch_related('preparationorderitem_set__product'),
pk=pk pk=pk
) )
if preparation_order.status not in ['to_process', 'pending']: if preparation_order.status not in ['to_process', 'pending', 'processed']:
messages.error(request, _("Ce bon ne peut plus être traité.")) messages.error(request, _("Ce bon ne peut plus être traité."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -4626,6 +4735,12 @@ def preparation_order_process(request, pk):
# Marquer comme traité # Marquer comme traité
preparation_order.mark_as_processed() preparation_order.mark_as_processed()
preparation_order.log_history(
action="processed",
description=_("Traitement du bon de préparation (mouvements de stock appliqués)"),
user=request.user,
details={'status': 'processed'},
)
messages.success(request, _("Le bon de préparation #{} a été traité avec succès.").format( messages.success(request, _("Le bon de préparation #{} a été traité avec succès.").format(
preparation_order.order_number preparation_order.order_number
)) ))
@ -4881,18 +4996,15 @@ def preparation_order_document_upload(request, pk):
""" """
Upload AJAX d'un ou plusieurs documents sur un bon de préparation. Upload AJAX d'un ou plusieurs documents sur un bon de préparation.
""" """
from common.models import UserConfig from stock.permissions import user_can_edit_preparation_order
from common.private_files.helpers import build_private_url from common.private_files.helpers import build_private_url
from django.urls import reverse from django.urls import reverse
try: preparation_order = get_object_or_404(PreparationOrder, pk=pk)
user_config = request.user.config
if not user_config.can_manage_preparation_orders_any(): if not user_can_edit_preparation_order(request.user, preparation_order):
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
except UserConfig.DoesNotExist:
return JsonResponse({'error': _("Droits insuffisants.")}, status=403) return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
files = request.FILES.getlist('documents') files = request.FILES.getlist('documents')
if not files: if not files:
return JsonResponse({'error': _("Aucun fichier fourni.")}, status=400) return JsonResponse({'error': _("Aucun fichier fourni.")}, status=400)
@ -4904,6 +5016,12 @@ def preparation_order_document_upload(request, pk):
file=f, file=f,
name=f.name, name=f.name,
) )
preparation_order.log_history(
action="document_added",
description=_("Ajout du document joint : {name}").format(name=doc.name or doc.filename()),
user=request.user,
details={'document_id': doc.pk, 'filename': doc.filename()},
)
created.append({ created.append({
'id': doc.pk, 'id': doc.pk,
'filename': doc.filename(), 'filename': doc.filename(),
@ -4921,17 +5039,22 @@ def preparation_order_document_delete(request, doc_id):
""" """
Suppression AJAX d'un document de bon de préparation. Suppression AJAX d'un document de bon de préparation.
""" """
from common.models import UserConfig from stock.permissions import user_can_edit_preparation_order
try:
user_config = request.user.config
if not user_config.can_manage_preparation_orders_any():
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
except UserConfig.DoesNotExist:
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
doc = get_object_or_404(PreparationOrderDocument, pk=doc_id) doc = get_object_or_404(PreparationOrderDocument, pk=doc_id)
po = doc.preparation_order
if not user_can_edit_preparation_order(request.user, po):
return JsonResponse({'error': _("Droits insuffisants.")}, status=403)
doc_name = doc.name or doc.filename()
doc.delete() doc.delete()
po.log_history(
action="document_removed",
description=_("Suppression du document joint : {name}").format(name=doc_name),
user=request.user,
details={'document_id': doc_id, 'filename': doc_name},
)
return JsonResponse({'deleted': doc_id}) return JsonResponse({'deleted': doc_id})
@ -6143,18 +6266,9 @@ def serialized_item_delete(request, pk):
@require_POST @require_POST
def preparation_order_toggle_loan_return(request, pk): def preparation_order_toggle_loan_return(request, pk):
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from common.models import UserConfig from stock.permissions import user_can_manage_preparation_orders
try:
user_config = request.user.config
can_manage = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.can_manage_preparation_orders_any()
)
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage: if not user_can_manage_preparation_orders(request.user):
messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -6166,6 +6280,14 @@ def preparation_order_toggle_loan_return(request, pk):
# Synchroniser les lots sortants liés # Synchroniser les lots sortants liés
preparation_order.stock_batches.filter(movement_type=StockMovementBatch.TYPE_OUT).update(loan_returned=returned) preparation_order.stock_batches.filter(movement_type=StockMovementBatch.TYPE_OUT).update(loan_returned=returned)
status_label = _("Retourné") if returned else _("Non retourné")
preparation_order.log_history(
action="loan_updated",
description=_("Statut de retour du prêt modifié : {status}").format(status=status_label),
user=request.user,
details={'loan_returned': returned},
)
messages.success(request, _("Statut de retour mis à jour.")) messages.success(request, _("Statut de retour mis à jour."))
referer = request.META.get('HTTP_REFERER') referer = request.META.get('HTTP_REFERER')
if referer: if referer:
@ -6203,6 +6325,14 @@ def stock_movement_batch_toggle_loan_return(request, pk):
if batch.preparation_order: if batch.preparation_order:
batch.preparation_order.loan_returned = returned batch.preparation_order.loan_returned = returned
batch.preparation_order.save(update_fields=['loan_returned']) batch.preparation_order.save(update_fields=['loan_returned'])
batch.preparation_order.log_history(
action="loan_updated",
description=_("Statut de retour du prêt synchronisé : {status}").format(
status=_("Retourné") if returned else _("Non retourné")
),
user=request.user,
details={'loan_returned': returned, 'batch_id': batch.pk},
)
messages.success(request, _("Statut de retour mis à jour.")) messages.success(request, _("Statut de retour mis à jour."))
referer = request.META.get('HTTP_REFERER') referer = request.META.get('HTTP_REFERER')
@ -6215,18 +6345,9 @@ def stock_movement_batch_toggle_loan_return(request, pk):
@require_POST @require_POST
def preparation_order_update_loan_info(request, pk): def preparation_order_update_loan_info(request, pk):
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from common.models import UserConfig from stock.permissions import user_can_manage_preparation_orders
try:
user_config = request.user.config
can_manage = (
request.user.is_superuser
or user_config.has_role('admin')
or user_config.can_manage_preparation_orders_any()
)
except UserConfig.DoesNotExist:
can_manage = False
if not can_manage: if not user_can_manage_preparation_orders(request.user):
messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt.")) messages.error(request, _("Vous n'avez pas la permission de modifier ce prêt."))
return redirect('stock:preparation_order_detail', pk=pk) return redirect('stock:preparation_order_detail', pk=pk)
@ -6251,6 +6372,14 @@ def preparation_order_update_loan_info(request, pk):
estimated_return_date=estimated_return_date estimated_return_date=estimated_return_date
) )
loan_desc = _("Prêt activé (retour prévu : {date})").format(date=estimated_return_date_str or _("non défini")) if is_loan else _("Prêt désactivé")
preparation_order.log_history(
action="loan_updated",
description=_("Informations de prêt modifiées : {desc}").format(desc=loan_desc),
user=request.user,
details={'is_loan': is_loan, 'estimated_return_date': estimated_return_date_str},
)
messages.success(request, _("Informations de prêt enregistrées.")) messages.success(request, _("Informations de prêt enregistrées."))
referer = request.META.get('HTTP_REFERER') referer = request.META.get('HTTP_REFERER')
if referer: if referer: