feat: standardize preparation order numbering format and add recodification command
This commit is contained in:
parent
dc6638c4a5
commit
71154a0fb4
8 changed files with 305 additions and 22 deletions
|
|
@ -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):
|
||||
|
|
|
|||
102
loko/stock/management/commands/recodify_preparation_orders.py
Normal file
102
loko/stock/management/commands/recodify_preparation_orders.py
Normal 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."))
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue