279 lines
15 KiB
Python
279 lines
15 KiB
Python
from datetime import timedelta
|
|
from django.core.management.base import BaseCommand
|
|
from django.db import connection, transaction
|
|
from django.utils import timezone
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
from assets.models import NatureTree
|
|
from interventions.models import Intervention, SourceCategory
|
|
|
|
|
|
class Command(BaseCommand):
|
|
help = (
|
|
"Annule les interventions arboricoles caduques ou périmées :\n"
|
|
"1. Interventions prescrites avant un diagnostic plus récent sur l'arbre (remplacées par ré-inspection).\n"
|
|
"2. Prescriptions actives dont la date limite est échue depuis plus de X mois (défaut : 12 mois).\n"
|
|
"Met à jour les événements de timeline et recalcule automatiquement les dates de prochaine inspection."
|
|
)
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument(
|
|
'--months',
|
|
type=int,
|
|
default=12,
|
|
help="Délai en mois après échéance au-delà duquel une prescription non exécutée est annulée (défaut : 12).",
|
|
)
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help="Simuler l'opération et afficher les comptages sans modifier la base de données.",
|
|
)
|
|
parser.add_argument(
|
|
'--no-recalc-trees',
|
|
action='store_true',
|
|
help="Ne pas recalculer next_inspection_date sur les arbres après l'annulation.",
|
|
)
|
|
|
|
def handle(self, *args, **options):
|
|
months = options['months']
|
|
dry_run = options['dry_run']
|
|
recalc_trees = not options['no_recalc_trees']
|
|
|
|
now = timezone.now()
|
|
# Calcul de la date seuil d'échéance (ex: il y a 12 mois)
|
|
cutoff_date = (now - timedelta(days=int(months * 30.4375))).date()
|
|
|
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
|
f"=== Nettoyage des interventions d'arbres caduques et échues ==="
|
|
))
|
|
self.stdout.write(f"Date de référence : {now.strftime('%d/%m/%Y %H:%M')}")
|
|
self.stdout.write(f"Seuil d'échéance ({months} mois) : interventions échues avant le {cutoff_date.strftime('%d/%m/%Y')}\n")
|
|
|
|
if dry_run:
|
|
self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera enregistrée.\n"))
|
|
|
|
content_type_tree = ContentType.objects.get_for_model(NatureTree)
|
|
source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first()
|
|
source_fulcrum_id = source_fulcrum.id if source_fulcrum else None
|
|
|
|
with connection.cursor() as cur:
|
|
# -------------------------------------------------------------
|
|
# COMPTAGE RÈGLE 1 : Diagnostic plus récent sur l'arbre
|
|
# -------------------------------------------------------------
|
|
cur.execute("""
|
|
SELECT COUNT(DISTINCT i.id)
|
|
FROM interventions_intervention i
|
|
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
|
|
JOIN assets_naturetree t ON t.id = ia.object_id
|
|
WHERE ia.content_type_id = %s
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND t.last_phytosanitary_date IS NOT NULL
|
|
AND (
|
|
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
|
|
);
|
|
""", [content_type_tree.id])
|
|
count_rule_diag = cur.fetchone()[0]
|
|
|
|
# -------------------------------------------------------------
|
|
# COMPTAGE RÈGLE 2 : Échéance dépassée > X mois (sans être dans règle 1)
|
|
# -------------------------------------------------------------
|
|
cur.execute("""
|
|
SELECT COUNT(DISTINCT i.id)
|
|
FROM interventions_intervention i
|
|
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
|
|
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
|
|
WHERE i.status NOT IN ('finished', 'canceled')
|
|
AND (ia.content_type_id = %s OR i.source_category_id = %s)
|
|
AND i.expected_end_time IS NOT NULL
|
|
AND i.expected_end_time::date < %s
|
|
AND NOT (
|
|
t.last_phytosanitary_date IS NOT NULL
|
|
AND (
|
|
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
|
|
OR (i.expected_end_time::date < t.last_phytosanitary_date)
|
|
)
|
|
);
|
|
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
|
|
count_rule_expired = cur.fetchone()[0]
|
|
|
|
# Total combiné
|
|
cur.execute("""
|
|
SELECT COUNT(DISTINCT i.id)
|
|
FROM interventions_intervention i
|
|
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
|
|
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
|
|
WHERE i.status NOT IN ('finished', 'canceled')
|
|
AND (ia.content_type_id = %s OR i.source_category_id = %s)
|
|
AND (
|
|
(t.last_phytosanitary_date IS NOT NULL AND (
|
|
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
|
|
))
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
|
|
);
|
|
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
|
|
total_to_cancel = cur.fetchone()[0]
|
|
|
|
self.stdout.write(f"1. Prescriptions caduques (arbre ré-inspecté ultérieurement) : {count_rule_diag}")
|
|
self.stdout.write(f"2. Prescriptions échues depuis plus de {months} mois sans nouveau diag : {count_rule_expired}")
|
|
self.stdout.write(self.style.MIGRATE_LABEL(f"Total des interventions à annuler : {total_to_cancel}\n"))
|
|
|
|
if total_to_cancel == 0:
|
|
self.stdout.write(self.style.SUCCESS("Aucune intervention à annuler. La base est déjà assainie."))
|
|
return
|
|
|
|
if dry_run:
|
|
self.stdout.write(self.style.WARNING(
|
|
f"[MODE SIMULATION] {total_to_cancel} interventions auraient été passées au statut 'canceled'.\n"
|
|
"Exécutez sans --dry-run pour appliquer."
|
|
))
|
|
return
|
|
|
|
# -------------------------------------------------------------
|
|
# EXÉCUTION RÉELLE EN TRANSACTION
|
|
# -------------------------------------------------------------
|
|
self.stdout.write("Application des annulations...")
|
|
with transaction.atomic():
|
|
with connection.cursor() as cur:
|
|
# 1a. Timeline events pour la règle 1 (diagnostic plus récent)
|
|
cur.execute("""
|
|
INSERT INTO interventions_interventiontimeline (
|
|
intervention_id, event_time, event_type, from_status, to_status,
|
|
event_description, field_changes
|
|
)
|
|
SELECT
|
|
i.id,
|
|
%s,
|
|
'status_change',
|
|
i.status,
|
|
'canceled',
|
|
'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' ||
|
|
TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.',
|
|
('{"status": ["' || i.status || '", "canceled"]}')::json
|
|
FROM interventions_intervention i
|
|
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
|
|
JOIN assets_naturetree t ON t.id = ia.object_id
|
|
WHERE ia.content_type_id = %s
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND t.last_phytosanitary_date IS NOT NULL
|
|
AND (
|
|
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM interventions_interventiontimeline tl
|
|
WHERE tl.intervention_id = i.id
|
|
AND tl.to_status = 'canceled'
|
|
);
|
|
""", [now, content_type_tree.id])
|
|
tl_diag_count = cur.rowcount
|
|
|
|
# 1b. Timeline events pour la règle 2 (échues > X mois)
|
|
cur.execute("""
|
|
INSERT INTO interventions_interventiontimeline (
|
|
intervention_id, event_time, event_type, from_status, to_status,
|
|
event_description, field_changes
|
|
)
|
|
SELECT
|
|
i.id,
|
|
%s,
|
|
'status_change',
|
|
i.status,
|
|
'canceled',
|
|
'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' ||
|
|
TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.',
|
|
('{"status": ["' || i.status || '", "canceled"]}')::json
|
|
FROM interventions_intervention i
|
|
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
|
|
WHERE i.status NOT IN ('finished', 'canceled')
|
|
AND (ia.content_type_id = %s OR i.source_category_id = %s)
|
|
AND i.expected_end_time IS NOT NULL
|
|
AND i.expected_end_time::date < %s
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM interventions_interventiontimeline tl
|
|
WHERE tl.intervention_id = i.id
|
|
AND tl.to_status = 'canceled'
|
|
);
|
|
""", [now, months, content_type_tree.id, source_fulcrum_id, cutoff_date])
|
|
tl_expired_count = cur.rowcount
|
|
|
|
# 2. Mise à jour effective du statut vers 'canceled'
|
|
cur.execute("""
|
|
UPDATE interventions_intervention i
|
|
SET status = 'canceled',
|
|
status_order = 200,
|
|
cancellation_type = 'non_relevant'
|
|
FROM interventions_interventionasset ia
|
|
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
|
|
WHERE ia.intervention_id = i.id
|
|
AND (ia.content_type_id = %s OR i.source_category_id = %s)
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND (
|
|
(t.last_phytosanitary_date IS NOT NULL AND (
|
|
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
|
|
))
|
|
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
|
|
);
|
|
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
|
|
updated_count = cur.rowcount
|
|
|
|
# Traitement des interventions orphelines sans lien ia (ex: source Fulcrum sans arbre matché)
|
|
cur.execute("""
|
|
UPDATE interventions_intervention i
|
|
SET status = 'canceled',
|
|
status_order = 200,
|
|
cancellation_type = 'non_relevant'
|
|
WHERE i.source_category_id = %s
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND i.expected_end_time IS NOT NULL
|
|
AND i.expected_end_time::date < %s;
|
|
""", [source_fulcrum_id, cutoff_date])
|
|
updated_count += cur.rowcount
|
|
|
|
self.stdout.write(self.style.SUCCESS(
|
|
f" {updated_count} interventions passées au statut 'canceled'."
|
|
))
|
|
self.stdout.write(f" {tl_diag_count + tl_expired_count} événements d'historique créés dans la timeline.")
|
|
|
|
# 3. Recalcul des prochaines dates d'inspection sur les arbres
|
|
if recalc_trees:
|
|
self.stdout.write("Recalcul des prochaines inspections sur les arbres...")
|
|
# 3a. Arbres avec intervention active résiduelle
|
|
cur.execute("""
|
|
UPDATE assets_naturetree t
|
|
SET next_inspection_date = sub.min_date
|
|
FROM (
|
|
SELECT ia.object_id AS tree_id, MIN(i.expected_end_time)::date AS min_date
|
|
FROM interventions_interventionasset ia
|
|
JOIN interventions_intervention i ON i.id = ia.intervention_id
|
|
WHERE ia.content_type_id = %s
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND i.expected_end_time IS NOT NULL
|
|
GROUP BY ia.object_id
|
|
) sub
|
|
WHERE t.id = sub.tree_id;
|
|
""", [content_type_tree.id])
|
|
trees_active = cur.rowcount
|
|
|
|
# 3b. Arbres sans intervention active : formule théorique = dernier diagnostic + périodicité
|
|
cur.execute("""
|
|
UPDATE assets_naturetree t
|
|
SET next_inspection_date = (t.last_phytosanitary_date + (COALESCE(t.pruning_frequency_years, 3) || ' years')::interval)::date
|
|
WHERE t.last_phytosanitary_date IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM interventions_interventionasset ia
|
|
JOIN interventions_intervention i ON i.id = ia.intervention_id
|
|
WHERE ia.content_type_id = %s
|
|
AND ia.object_id = t.id
|
|
AND i.status NOT IN ('finished', 'canceled')
|
|
AND i.expected_end_time IS NOT NULL
|
|
);
|
|
""", [content_type_tree.id])
|
|
trees_theo = cur.rowcount
|
|
|
|
self.stdout.write(f" {trees_active} arbres mis à jour avec prochaine intervention active.")
|
|
self.stdout.write(f" {trees_theo} arbres recalculés selon cycle théorique (diag + périodicité).")
|
|
|
|
self.stdout.write(self.style.SUCCESS("\nOpération terminée avec succès !"))
|