feat: standardize preparation order numbering format and add recodification command

This commit is contained in:
kdeterme 2026-09-30 11:07:10 +02:00
parent dc6638c4a5
commit 71154a0fb4
8 changed files with 305 additions and 22 deletions

View file

@ -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):

View file

@ -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-<code_intervention>-<index:02d> (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-<interv_code>-<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."))

View file

@ -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),
),
]

View file

@ -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-<code_intervention>-<index:02d>
Ex: BP-I113392-01, BP-I113392-02, etc.
- Si autonome (sans intervention) : BP-YYYYMMDD-<seq:03d>
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:

View file

@ -139,7 +139,7 @@
<span class="info-label"><i class="bi bi-hash me-1"></i>{% translate "Numéro" %}</span>
<span class="info-value">
<a href="{% url 'mobile:intervention_detail_mobile' preparation_order.intervention.id %}">
#{{ preparation_order.intervention.id }}
{{ preparation_order.intervention.code|default:preparation_order.intervention.id }}
</a>
{% if preparation_order.intervention.status == 'canceled' %}
<span class="badge bg-danger ms-1">{% translate "Annulée" %}</span>

View file

@ -28,7 +28,7 @@
<div class="col-md-6">
<strong>{% translate "Intervention" %}:</strong>
<a href="{% url 'interventions:intervention_detail' intervention.id %}" target="_blank">
#{{ intervention.id }} - {{ intervention.title|default:intervention.description|truncatewords:10 }}
{{ intervention.code|default:intervention.id }} - {{ intervention.title|default:intervention.description|truncatewords:10 }}
</a>
</div>
<div class="col-md-6">

View file

@ -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-<code_intervention>-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")

View file

@ -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',