feat(nature): cancel obsolete tree interventions and compute theoretical next inspection

- Automatically cancel pending interventions whose deadline is earlier than the tree's last diagnostic
- Record cancellation reason in InterventionTimeLine
- Compute theoretical next inspection date as last_phytosanitary_date + pruning_frequency_years
- Add unit test coverage for obsolete intervention cancellation and theoretical inspection calculation
This commit is contained in:
kdeterme 2026-10-10 14:29:57 +02:00
parent 7761f22b77
commit 34e9743cc3
2 changed files with 190 additions and 28 deletions

View file

@ -1039,41 +1039,91 @@ class Command(BaseCommand):
updated_begin = cur.rowcount updated_begin = cur.rowcount
self.log(f" Diagnostic initial (expected_begin) : {created_begin} créés, {updated_begin} mis à jour") 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): def sync_tree_maintenance_dates(self, gestion_file=None):
""" """
Synchronise les dates et périodicités de maintenance sur les arbres : Synchronise les dates et périodicités de maintenance sur les arbres :
- next_inspection_date : min(expected_end_time) des interventions en cours 1. Périodicité de taille en années (pruning_frequency_years)
- pruning_frequency_years : périodicité extraite du délai/titre (ex: Dans 1 an -> 1) 2. Dernier diagnostic phytosanitaire (last_phytosanitary_date)
- last_phytosanitary_date : max(expected_begin_time) des interventions de diagnostic 3. Annulation des interventions caduques antérieures au dernier diagnostic
- last_pruning_date : date d'exécution de la dernière taille (depuis gestion.csv ou interventions passées) 4. Prochaine inspection préconisée (next_inspection_date) :
- timeline de planification : synchronisation des événements expected_begin / expected_end - 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 ---") self.log("--- Synchronisation des dates de maintenance sur les arbres ---")
from django.db import connection from django.db import connection
self.sync_intervention_planning_events()
content_type_tree = ContentType.objects.get_for_model(NatureTree) content_type_tree = ContentType.objects.get_for_model(NatureTree)
with connection.cursor() as cur: with connection.cursor() as cur:
# 1. Prochaine échéance prévisionnelle (next_inspection_date) # 1. Périodicité de taille en années (pruning_frequency_years)
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)
cur.execute(""" cur.execute("""
UPDATE assets_naturetree t UPDATE assets_naturetree t
SET pruning_frequency_years = sub.freq SET pruning_frequency_years = sub.freq
@ -1091,7 +1141,7 @@ class Command(BaseCommand):
""", [content_type_tree.id]) """, [content_type_tree.id])
self.log(f" Périodicité de taille (pruning_frequency_years) mise à jour : {cur.rowcount} arbres") 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(""" cur.execute("""
UPDATE assets_naturetree t UPDATE assets_naturetree t
SET last_phytosanitary_date = GREATEST(COALESCE(t.last_phytosanitary_date, sub.max_dia), sub.max_dia) 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]) """, [content_type_tree.id])
self.log(f" Dernier diagnostic (last_phytosanitary_date) consolidé : {cur.rowcount} arbres") 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 # 4. Dernière taille (last_pruning_date) depuis fulcrum_gestion.csv si disponible
if gestion_file and os.path.exists(gestion_file): if gestion_file and os.path.exists(gestion_file):
self.log(" Recherche des tailles exécutées dans gestion.csv...") self.log(" Recherche des tailles exécutées dans gestion.csv...")
@ -1142,3 +1246,6 @@ class Command(BaseCommand):
except Exception as e: except Exception as e:
self.log(f" Erreur lors de la lecture des tailles dans gestion.csv : {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()

View file

@ -1,10 +1,10 @@
import json import json
import tempfile import tempfile
import os import os
from datetime import date from datetime import date, datetime
from django.test import TestCase from django.test import TestCase
from django.core.management import call_command 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 common.models import Thematic
from assets.models import NatureTree, AssetCategory from assets.models import NatureTree, AssetCategory
@ -233,5 +233,60 @@ class ImportFulcrumTreesCommandTest(TestCase):
self.assertEqual(tree.location_id, loc.id) self.assertEqual(tree.location_id, loc.id)
self.assertEqual(loc.nature_trees.count(), 1) 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))