loko/loko/assets/management/commands/cancel_obsolete_tree_interventions.py

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 !"))