diff --git a/loko/stock/forms.py b/loko/stock/forms.py index a09997f..b7af5bb 100644 --- a/loko/stock/forms.py +++ b/loko/stock/forms.py @@ -47,6 +47,13 @@ class PreparationOrderForm(forms.ModelForm): else: self.fields[field_name].widget.attrs['class'] = 'form-control' self.fields["requester"].required = False + self.fields["order_number"].required = False + + def clean_order_number(self): + order_number = self.cleaned_data.get('order_number') + if order_number: + return order_number.strip() + return order_number # class PreparationOrderDesignForm(forms.Form): diff --git a/loko/stock/management/commands/recodify_preparation_orders.py b/loko/stock/management/commands/recodify_preparation_orders.py new file mode 100644 index 0000000..fc64223 --- /dev/null +++ b/loko/stock/management/commands/recodify_preparation_orders.py @@ -0,0 +1,102 @@ +import re +from django.core.management.base import BaseCommand +from django.db import transaction +from stock.models import PreparationOrder +from interventions.models import Intervention + + +class Command(BaseCommand): + help = ( + "Met à jour la numérotation des bons de préparation liés à une intervention " + "au nouveau format standard : BP-- (ex: BP-I113392-01)." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help="Simule l'exécution sans modifier la base de données.", + ) + parser.add_argument( + '--intervention', + type=str, + help="ID ou Code d'une intervention spécifique à traiter (ex: 43047 ou I113392).", + ) + parser.add_argument( + '--force', + action='store_true', + help="Recodifie même si le bon correspond déjà au format standard.", + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + interv_filter = options['intervention'] + force = options['force'] + + if dry_run: + self.stdout.write(self.style.WARNING("--- MODE SIMULATION (dry-run) : aucune modification en base ---")) + + qs = PreparationOrder.objects.filter(intervention__isnull=False).select_related('intervention') + + if interv_filter: + if interv_filter.isdigit(): + qs = qs.filter(intervention_id=int(interv_filter)) + else: + qs = qs.filter(intervention__code=interv_filter) + + interventions = list(Intervention.objects.filter( + id__in=qs.values_list('intervention_id', flat=True).distinct() + )) + + self.stdout.write(f"Vérification des bons pour {len(interventions)} intervention(s)...") + + total_renamed = 0 + + # Pattern strict du format actuel : BP--<2 chiffres> + for interv in interventions: + interv_code = interv.code or f"I{interv.id}" + interv_orders = list(qs.filter(intervention=interv).order_by('date', 'id')) + + pattern = re.compile(rf"^BP-{re.escape(interv_code)}-\d{{2}}$") + + # Déterminer les bons à renommer + orders_to_process = [] + for order in interv_orders: + if force or not pattern.match(order.order_number): + orders_to_process.append(order) + + if not orders_to_process and not force: + continue + + self.stdout.write(f"\nIntervention {interv_code} (#{interv.id}) : {len(interv_orders)} bon(s) associé(s)") + + # Assigner séquentiellement les nouveaux numéros + for idx, order in enumerate(interv_orders, start=1): + new_number = f"BP-{interv_code}-{idx:02d}" + old_number = order.order_number + + if old_number == new_number: + self.stdout.write(f" - Bon #{order.id} : déjà conforme ({old_number})") + continue + + self.stdout.write(f" - Bon #{order.id} : {old_number} -> {self.style.SUCCESS(new_number)}") + + if not dry_run: + with transaction.atomic(): + order.order_number = new_number + order.save(update_fields=['order_number']) + order.generate_qr_code() + PreparationOrder.objects.filter(pk=order.pk).update(qr_code=order.qr_code.name) + order.log_history( + action="updated", + description=f"Recodification automatique du bon : {old_number} -> {new_number}", + user=None, + details={'old_order_number': old_number, 'new_order_number': new_number}, + ) + + total_renamed += 1 + + if dry_run: + self.stdout.write(self.style.WARNING(f"\n[Simulation terminée] {total_renamed} bon(s) seraient renommés.")) + else: + self.stdout.write(self.style.SUCCESS(f"\n✓ Terminé : {total_renamed} bon(s) renommé(s) avec succès.")) diff --git a/loko/stock/migrations/0005_alter_preparationorder_order_number.py b/loko/stock/migrations/0005_alter_preparationorder_order_number.py new file mode 100644 index 0000000..0b091e2 --- /dev/null +++ b/loko/stock/migrations/0005_alter_preparationorder_order_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-30 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0004_alter_preparationorder_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='preparationorder', + name='order_number', + field=models.CharField(blank=True, max_length=100, unique=True), + ), + ] diff --git a/loko/stock/models.py b/loko/stock/models.py index 71f70d3..72698df 100644 --- a/loko/stock/models.py +++ b/loko/stock/models.py @@ -1,7 +1,7 @@ from django.contrib.gis.db import models from django.conf import settings from django.db import transaction -from django.db.models import Sum, F +from django.db.models import Sum, F, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ import uuid @@ -1387,7 +1387,7 @@ def update_stock_alerts(product, warehouse): class PreparationOrder(models.Model): - order_number = models.CharField(max_length=100, unique=True) + order_number = models.CharField(max_length=100, unique=True, blank=True) date = models.DateTimeField(auto_now_add=True) status = models.CharField( max_length=50, @@ -1510,12 +1510,79 @@ class PreparationOrder(models.Model): return self.estimated_return_date < timezone.now().date() return False + @classmethod + def generate_order_number(cls, intervention=None): + """ + Génère un numéro de bon de préparation unique. + - Si rattaché à une intervention : BP-- + Ex: BP-I113392-01, BP-I113392-02, etc. + - Si autonome (sans intervention) : BP-YYYYMMDD- + Ex: BP-20260930-001, BP-20260930-002, etc. + """ + if intervention: + interv_code = (getattr(intervention, 'code', None) or '').strip() + if not interv_code: + interv_id = getattr(intervention, 'id', intervention) + interv_code = f"I{interv_id}" + + prefix = f"BP-{interv_code}-" + + interv_obj = intervention if hasattr(intervention, 'pk') else None + q_filter = Q(order_number__startswith=prefix) + if interv_obj: + q_filter |= Q(intervention=interv_obj) + + existing_orders = list(cls.objects.filter(q_filter).values_list('order_number', flat=True)) + + existing_indexes = [] + for num in existing_orders: + if num and num.startswith(prefix): + suffix = num[len(prefix):] + if suffix.isdigit(): + existing_indexes.append(int(suffix)) + + linked_count = 0 + if interv_obj: + linked_count = cls.objects.filter(intervention=interv_obj).count() + + if existing_indexes: + next_index = max(max(existing_indexes), linked_count) + 1 + elif linked_count > 0: + next_index = linked_count + 1 + else: + next_index = 1 + + candidate = f"{prefix}{next_index:02d}" + while cls.objects.filter(order_number=candidate).exists(): + next_index += 1 + candidate = f"{prefix}{next_index:02d}" + return candidate + else: + import datetime + today = datetime.date.today() + prefix = f"BP-{today.strftime('%Y%m%d')}-" + existing = cls.objects.filter(order_number__startswith=prefix).values_list('order_number', flat=True) + existing_nums = [] + for num in existing: + suffix = num[len(prefix):] + if suffix.isdigit(): + existing_nums.append(int(suffix)) + next_num = max(existing_nums, default=0) + 1 + candidate = f"{prefix}{next_num:03d}" + while cls.objects.filter(order_number=candidate).exists(): + next_num += 1 + candidate = f"{prefix}{next_num:03d}" + return candidate + def save(self, *args, **kwargs): is_new = self.pk is None old_status = None if not is_new: old_status = PreparationOrder.objects.filter(pk=self.pk).values_list('status', flat=True).first() + if not self.order_number: + self.order_number = self.generate_order_number(intervention=self.intervention) + # Ne pas re-générer le QR code lors des saves partiels (update_fields) update_fields = kwargs.get('update_fields') if update_fields is None and self.order_number: diff --git a/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html b/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html index 347d895..3860019 100644 --- a/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html +++ b/loko/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html @@ -139,7 +139,7 @@ {% translate "Numéro" %} - #{{ preparation_order.intervention.id }} + {{ preparation_order.intervention.code|default:preparation_order.intervention.id }} {% if preparation_order.intervention.status == 'canceled' %} {% translate "Annulée" %} diff --git a/loko/stock/templates/stock/preparation_order_add.html b/loko/stock/templates/stock/preparation_order_add.html index a006d68..38c3e34 100644 --- a/loko/stock/templates/stock/preparation_order_add.html +++ b/loko/stock/templates/stock/preparation_order_add.html @@ -28,7 +28,7 @@
diff --git a/loko/stock/tests.py b/loko/stock/tests.py index 5e53e10..58e3f8c 100644 --- a/loko/stock/tests.py +++ b/loko/stock/tests.py @@ -2542,6 +2542,100 @@ class StockMovementMultiLocationBatchTests(TestCase): self.assertTrue(loc1_data["is_default"]) +class PreparationOrderNumberCodificationTests(TestCase): + def setUp(self): + from common.models import UserConfig, Role + from interventions.models import Intervention + self.user = User.objects.create_superuser(username="admin_po_codif", email="admin_po@test.com", password="password") + config, _ = UserConfig.objects.get_or_create(user=self.user) + admin_role, _ = Role.objects.get_or_create(name='admin') + config.roles.add(admin_role) + self.client.force_login(self.user) + self.intervention = Intervention.objects.create( + title="Intervention Signalisation Molenbeek", + code="I113392", + priority="2", + ) + + def test_autonomous_order_number_generation(self): + """Les bons autonomes (sans intervention) utilisent le format BP-YYYYMMDD-001, etc.""" + import datetime + today_str = datetime.date.today().strftime('%Y%m%d') + num1 = PreparationOrder.generate_order_number(intervention=None) + self.assertEqual(num1, f"BP-{today_str}-001") + + po1 = PreparationOrder.objects.create(order_number=num1) + num2 = PreparationOrder.generate_order_number(intervention=None) + self.assertEqual(num2, f"BP-{today_str}-002") + + def test_intervention_order_number_first_and_sequential(self): + """Le 1er bon lié à une intervention est BP-I113392-01, le 2ème est BP-I113392-02, etc.""" + num1 = PreparationOrder.generate_order_number(intervention=self.intervention) + self.assertEqual(num1, "BP-I113392-01") + + po1 = PreparationOrder.objects.create(order_number=num1, intervention=self.intervention) + num2 = PreparationOrder.generate_order_number(intervention=self.intervention) + self.assertEqual(num2, "BP-I113392-02") + + po2 = PreparationOrder.objects.create(order_number=num2, intervention=self.intervention) + num3 = PreparationOrder.generate_order_number(intervention=self.intervention) + self.assertEqual(num3, "BP-I113392-03") + + def test_auto_generate_on_save_if_blank(self): + """La méthode save() génère automatiquement le bon numéro si order_number est vide.""" + po = PreparationOrder(intervention=self.intervention) + po.save() + self.assertEqual(po.order_number, "BP-I113392-01") + + po2 = PreparationOrder(intervention=self.intervention) + po2.save() + self.assertEqual(po2.order_number, "BP-I113392-02") + + def test_handles_gaps_and_existing_legacy_numbers(self): + """Gère correctement les trous d'index et la présence d'anciens bons avec l'ancien format.""" + # Un bon ancien existe pour cette intervention + PreparationOrder.objects.create( + order_number="BP-20260930-I43047", + intervention=self.intervention + ) + # Le prochain bon détecte qu'il y a déjà 1 bon pour cette intervention et propose 02 + num = PreparationOrder.generate_order_number(intervention=self.intervention) + self.assertEqual(num, "BP-I113392-02") + + def test_new_preparation_order_from_intervention_view_get(self): + """La vue GET pré-remplit le formulaire avec BP--01.""" + url = reverse("stock:new_preparation_order_from_intervention", args=[self.intervention.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "BP-I113392-01") + self.assertContains(response, "I113392") + + def test_recodify_command(self): + """La commande management recodify_preparation_orders met à jour les anciens formats.""" + from django.core.management import call_command + from io import StringIO + + po_old = PreparationOrder.objects.create( + order_number="BP-20260930-I43047", + intervention=self.intervention + ) + + out = StringIO() + # Test dry-run d'abord + call_command("recodify_preparation_orders", dry_run=True, stdout=out) + self.assertIn("BP-20260930-I43047", out.getvalue()) + self.assertIn("BP-I113392-01", out.getvalue()) + po_old.refresh_from_db() + self.assertEqual(po_old.order_number, "BP-20260930-I43047") + + # Test exécution réelle + out = StringIO() + call_command("recodify_preparation_orders", stdout=out) + self.assertIn("1 bon(s) renommé(s) avec succès", out.getvalue()) + po_old.refresh_from_db() + self.assertEqual(po_old.order_number, "BP-I113392-01") + + diff --git a/loko/stock/views.py b/loko/stock/views.py index 68d4dda..9734429 100644 --- a/loko/stock/views.py +++ b/loko/stock/views.py @@ -3585,7 +3585,10 @@ def new_preparation_order(request): form = PreparationOrderForm(request.POST) formset = preparation_order_item_formset(request.POST) if form.is_valid() and formset.is_valid(): - preparation_order = form.save() + preparation_order = form.save(commit=False) + if not preparation_order.order_number: + preparation_order.order_number = PreparationOrder.generate_order_number(intervention=None) + preparation_order.save() formset.instance = preparation_order formset.save() @@ -3610,17 +3613,7 @@ def new_preparation_order(request): messages.success(request, _("Commande de préparation enregistrée.")) return redirect("stock:stock_movements") else: - import datetime - today = datetime.date.today() - prefix = f"BP-{today.strftime('%Y%m%d')}-" - existing = PreparationOrder.objects.filter(order_number__startswith=prefix).values_list('order_number', flat=True) - existing_nums = [] - for num in existing: - suffix = num[len(prefix):] - if suffix.isdigit(): - existing_nums.append(int(suffix)) - next_num = max(existing_nums, default=0) + 1 - order_number = f"{prefix}{next_num:03d}" + order_number = PreparationOrder.generate_order_number(intervention=None) form = PreparationOrderForm(initial={'order_number': order_number, 'status': 'to_process'}) formset = preparation_order_item_formset() categories = list(ProductCategory.objects.order_by('name_fr').values('id', 'code', 'name_fr')) @@ -3714,7 +3707,8 @@ def new_preparation_order_from_intervention(request, intervention_id): return redirect(redirect_target, intervention_id=intervention_id) can_create_from_intervention = bool( - user_config.has_role('admin') + request.user.is_superuser + or user_config.has_role('admin') or ( can_edit_intervention(request.user, intervention, user_config=user_config) and user_config.can_manage_preparation_orders_any() @@ -3735,6 +3729,8 @@ def new_preparation_order_from_intervention(request, intervention_id): if form.is_valid() and formset.is_valid(): preparation_order = form.save(commit=False) preparation_order.intervention = intervention + if not preparation_order.order_number: + preparation_order.order_number = PreparationOrder.generate_order_number(intervention=intervention) preparation_order.save() formset.instance = preparation_order formset.save() @@ -3750,22 +3746,21 @@ def new_preparation_order_from_intervention(request, intervention_id): name=f.name, ) + interv_ref = intervention.code or f"#{intervention.id}" preparation_order.log_history( action="created", description=_("Création du bon de préparation depuis l'intervention #{intervention_id}").format( - intervention_id=intervention.id + intervention_id=interv_ref ), user=request.user, - details={'order_number': preparation_order.order_number, 'intervention_id': intervention.id}, + details={'order_number': preparation_order.order_number, 'intervention_id': intervention.id, 'intervention_code': intervention.code}, ) messages.success(request, _("Bon de préparation créé avec succès.")) return redirect(redirect_target, intervention_id=intervention_id) else: # Générer un numéro de bon automatique - import datetime - today = datetime.date.today() - order_number = f"BP-{today.strftime('%Y%m%d')}-I{intervention.id}" + order_number = PreparationOrder.generate_order_number(intervention=intervention) form = PreparationOrderForm(initial={ 'order_number': order_number, 'status': 'to_process',