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:
parent
7761f22b77
commit
34e9743cc3
2 changed files with 190 additions and 28 deletions
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue