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
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue