diff --git a/loko/assets/management/commands/import_fulcrum_trees.py b/loko/assets/management/commands/import_fulcrum_trees.py index 295eae0..163e0f9 100644 --- a/loko/assets/management/commands/import_fulcrum_trees.py +++ b/loko/assets/management/commands/import_fulcrum_trees.py @@ -1039,41 +1039,91 @@ class Command(BaseCommand): updated_begin = cur.rowcount self.log(f" Diagnostic initial (expected_begin) : {created_begin} créés, {updated_begin} mis à jour") + def cancel_obsolete_tree_interventions(self): + """ + Annule les interventions en attente dont l'échéance (expected_end_time) + est strictement antérieure au dernier diagnostic phytosanitaire de l'arbre (last_phytosanitary_date). + Enregistre un événement dans la timeline expliquant la raison de l'annulation. + """ + self.log("--- Détection et annulation des interventions caduques antérieures au dernier diagnostic ---") + from django.db import connection + from django.utils import timezone + + content_type_tree = ContentType.objects.get_for_model(NatureTree) + now = timezone.now() + + with connection.cursor() as cur: + # 1. Insertion des événements d'annulation dans InterventionTimeLine + 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 (' || + TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || + ') 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 i.expected_end_time IS NOT NULL + AND t.last_phytosanitary_date 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]) + events_count = cur.rowcount + + # 2. Mise à jour du statut des interventions vers 'canceled' + cur.execute(""" + UPDATE interventions_intervention i + SET status = 'canceled', + status_order = 200, + cancellation_type = 'non_relevant' + FROM interventions_interventionasset ia + JOIN assets_naturetree t ON t.id = ia.object_id + WHERE ia.intervention_id = i.id + AND ia.content_type_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND i.expected_end_time IS NOT NULL + AND t.last_phytosanitary_date IS NOT NULL + AND i.expected_end_time::date < t.last_phytosanitary_date; + """, [content_type_tree.id]) + canceled_count = cur.rowcount + + self.log(f" Interventions caduques annulées : {canceled_count} (événements timeline créés : {events_count})") + def sync_tree_maintenance_dates(self, gestion_file=None): """ Synchronise les dates et périodicités de maintenance sur les arbres : - - next_inspection_date : min(expected_end_time) des interventions en cours - - pruning_frequency_years : périodicité extraite du délai/titre (ex: Dans 1 an -> 1) - - last_phytosanitary_date : max(expected_begin_time) des interventions de diagnostic - - last_pruning_date : date d'exécution de la dernière taille (depuis gestion.csv ou interventions passées) - - timeline de planification : synchronisation des événements expected_begin / expected_end + 1. Périodicité de taille en années (pruning_frequency_years) + 2. Dernier diagnostic phytosanitaire (last_phytosanitary_date) + 3. Annulation des interventions caduques antérieures au dernier diagnostic + 4. Prochaine inspection préconisée (next_inspection_date) : + - Priorité 1 : date de la prochaine intervention active planifiée (non terminée, non annulée) + - Priorité 2 : date théorique calculée = dernier diagnostic + périodicité de taille (ou 3 ans par défaut) + 5. Dernière taille (last_pruning_date) depuis gestion.csv + 6. Synchronisation des événements de planification timeline """ self.log("--- Synchronisation des dates de maintenance sur les arbres ---") from django.db import connection - self.sync_intervention_planning_events() - content_type_tree = ContentType.objects.get_for_model(NatureTree) with connection.cursor() as cur: - # 1. Prochaine échéance prévisionnelle (next_inspection_date) - 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 != 'finished' - AND i.expected_end_time IS NOT NULL - GROUP BY ia.object_id - ) sub - WHERE t.id = sub.tree_id; - """, [content_type_tree.id]) - self.log(f" Prochaine inspection (next_inspection_date) mise à jour : {cur.rowcount} arbres") - - # 2. Périodicité de taille en années (pruning_frequency_years) + # 1. Périodicité de taille en années (pruning_frequency_years) cur.execute(""" UPDATE assets_naturetree t SET pruning_frequency_years = sub.freq @@ -1091,7 +1141,7 @@ class Command(BaseCommand): """, [content_type_tree.id]) self.log(f" Périodicité de taille (pruning_frequency_years) mise à jour : {cur.rowcount} arbres") - # 3. Dernier diagnostic phytosanitaire depuis les interventions + # 2. Dernier diagnostic phytosanitaire depuis les interventions cur.execute(""" UPDATE assets_naturetree t SET last_phytosanitary_date = GREATEST(COALESCE(t.last_phytosanitary_date, sub.max_dia), sub.max_dia) @@ -1107,6 +1157,60 @@ class Command(BaseCommand): """, [content_type_tree.id]) self.log(f" Dernier diagnostic (last_phytosanitary_date) consolidé : {cur.rowcount} arbres") + # 3. Annulation des interventions caduques antérieures au dernier diagnostic + self.cancel_obsolete_tree_interventions() + + with connection.cursor() as cur: + # 4. Prochaine échéance prévisionnelle ou théorique (next_inspection_date) + # 4a. Arbres avec intervention active planifiée (non terminée, non annulée) + 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]) + self.log(f" Prochaine inspection active (next_inspection_date) mise à jour : {cur.rowcount} arbres") + + # 4b. Arbres sans intervention active, calcul 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]) + self.log(f" Prochaine inspection théorique (dernier diag + périodicité) mise à jour : {cur.rowcount} arbres") + + # 4c. Arbres sans intervention active et sans diagnostic : réinitialiser les dates orphelines + cur.execute(""" + UPDATE assets_naturetree t + SET next_inspection_date = NULL + WHERE t.next_inspection_date IS NOT NULL + AND t.last_phytosanitary_date IS 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]) + # 4. Dernière taille (last_pruning_date) depuis fulcrum_gestion.csv si disponible if gestion_file and os.path.exists(gestion_file): self.log(" Recherche des tailles exécutées dans gestion.csv...") @@ -1142,3 +1246,6 @@ class Command(BaseCommand): except Exception as e: self.log(f" Erreur lors de la lecture des tailles dans gestion.csv : {e}") + # 6. Synchronisation des événements de planification timeline + self.sync_intervention_planning_events() + diff --git a/loko/assets/tests_import_fulcrum.py b/loko/assets/tests_import_fulcrum.py index 3ad21ab..3c94710 100644 --- a/loko/assets/tests_import_fulcrum.py +++ b/loko/assets/tests_import_fulcrum.py @@ -1,10 +1,10 @@ import json import tempfile import os -from datetime import date +from datetime import date, datetime from django.test import TestCase from django.core.management import call_command -from django.contrib.gis.geos import Point +from django.contrib.gis.geos import Point, Polygon, MultiPolygon from common.models import Thematic from assets.models import NatureTree, AssetCategory @@ -233,5 +233,60 @@ class ImportFulcrumTreesCommandTest(TestCase): self.assertEqual(tree.location_id, loc.id) self.assertEqual(loc.nature_trees.count(), 1) + def test_cancel_obsolete_interventions_and_theoretical_inspection(self): + from django.contrib.contenttypes.models import ContentType + from interventions.models import Intervention, InterventionAsset, InterventionTimeLine + + # Créer un arbre avec dernier diagnostic en 2025 et périodicité 1 an + tree = NatureTree.objects.create( + code="TR-OBSOLETE-TEST", + name_fr="Arbre test obsolète", + category=self.category, + last_phytosanitary_date=date(2025, 12, 4), + pruning_frequency_years=1, + next_inspection_date=date(2023, 7, 22), + geom=Point(648050, 670050, srid=3812), + lon=4.42, + lat=50.80 + ) + + # Créer une intervention en attente échue en 2023 (antérieure au diagnostic de 2025) + itv = Intervention.objects.create( + code="I999999", + title="003.01.270.(1912).00, Entretien, Dans 1 an", + status="to_be_planned", + status_order=5, + expected_begin_time=datetime(2022, 7, 22, 0, 0), + expected_end_time=datetime(2023, 7, 22, 0, 0), + thematic=self.thematic, + geom=MultiPolygon(Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812)) + ) + ct_tree = ContentType.objects.get_for_model(NatureTree) + InterventionAsset.objects.create( + intervention=itv, + content_type=ct_tree, + object_id=tree.id + ) + + # Exécuter la synchronisation des dates de maintenance + call_command('import_fulcrum_trees', sync_tree_dates=True) + + # Vérifier que l'intervention échue a été annulée + itv.refresh_from_db() + self.assertEqual(itv.status, "canceled") + self.assertEqual(itv.status_order, 200) + self.assertEqual(itv.cancellation_type, "non_relevant") + + # Vérifier que l'événement d'annulation a été consigné dans la timeline + tl = InterventionTimeLine.objects.filter(intervention=itv, to_status="canceled").first() + self.assertIsNotNone(tl) + self.assertIn("caduque", tl.event_description) + self.assertIn("04/12/2025", tl.event_description) + + # Vérifier que la prochaine inspection de l'arbre est calculée théoriquement : 2025-12-04 + 1 an = 2026-12-04 + tree.refresh_from_db() + self.assertEqual(tree.next_inspection_date, date(2026, 12, 4)) + +