feat: add maintenance dates synchronization and options to tree import command

This commit is contained in:
kdeterme 2026-10-10 13:50:02 +02:00
parent 9af4f99f17
commit 74ef7d3a34
2 changed files with 156 additions and 5 deletions

View file

@ -83,6 +83,11 @@ class Command(BaseCommand):
action='store_true', action='store_true',
help="Ignorer l'importation des interventions" help="Ignorer l'importation des interventions"
) )
parser.add_argument(
'--sync-tree-dates',
action='store_true',
help="Synchroniser uniquement les dates de maintenance sur les arbres (prochaine inspection, diagnostic, périodicité, dernière taille)"
)
parser.add_argument( parser.add_argument(
'--limit', '--limit',
type=int, type=int,
@ -115,6 +120,12 @@ class Command(BaseCommand):
alt = os.path.expanduser('~/Downloads/fulcrum_interventions.geojson') alt = os.path.expanduser('~/Downloads/fulcrum_interventions.geojson')
if os.path.exists(alt): interventions_file = alt if os.path.exists(alt): interventions_file = alt
if options.get('sync_tree_dates'):
self.log("=== SYNCHRONISATION DES DATES DE MAINTENANCE SUR LES ARBRES ===")
self.sync_tree_maintenance_dates(gestion_file)
self.log("=== FIN DE LA SYNCHRONISATION DES DATES ===")
return
self.log("=== DÉBUT IMPORTATION DONNÉES FULCRUM (ARBRES & INTERVENTIONS) ===") self.log("=== DÉBUT IMPORTATION DONNÉES FULCRUM (ARBRES & INTERVENTIONS) ===")
self.log(f"Fichier arbres : {arbes_file}") self.log(f"Fichier arbres : {arbes_file}")
self.log(f"Fichier gestion : {gestion_file}") self.log(f"Fichier gestion : {gestion_file}")
@ -179,6 +190,11 @@ class Command(BaseCommand):
order_map, order_contract_map, tree_uuid_to_id, sit_pos_to_tree_id, limit order_map, order_contract_map, tree_uuid_to_id, sit_pos_to_tree_id, limit
) )
# -------------------------------------------------------------
# ÉTAPE 4 : SYNCHRONISATION DES DATES SUR LES ARBRES
# -------------------------------------------------------------
self.sync_tree_maintenance_dates(gestion_file)
self.log("=== FIN DE L'IMPORTATION AVEC SUCCÈS ===") self.log("=== FIN DE L'IMPORTATION AVEC SUCCÈS ===")
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -556,6 +572,15 @@ class Command(BaseCommand):
if matched_id: break if matched_id: break
if matched_id: break if matched_id: break
# Date de diagnostic / dernière mise à jour
maj_date = props.get('maj_date')
last_phytosanitary_date = None
if maj_date:
try:
last_phytosanitary_date = datetime.fromisoformat(maj_date.replace('Z', '+00:00')).date()
except Exception:
pass
sit_pos_key = f"{sit_id}.{pos_id}" if sit_id and pos_id else None sit_pos_key = f"{sit_id}.{pos_id}" if sit_id and pos_id else None
if matched_id: if matched_id:
@ -587,6 +612,7 @@ class Command(BaseCommand):
'development_stage': dev_stage, 'development_stage': dev_stage,
'pruning_type': pruning, 'pruning_type': pruning,
'planting_date': planting_date, 'planting_date': planting_date,
'last_phytosanitary_date': last_phytosanitary_date,
'model_id': asset_model.id if asset_model else None, 'model_id': asset_model.id if asset_model else None,
'location_id': location.id if location else None, 'location_id': location.id if location else None,
'lon': lon, 'lon': lon,
@ -628,6 +654,7 @@ class Command(BaseCommand):
development_stage=dev_stage, development_stage=dev_stage,
pruning_type=pruning, pruning_type=pruning,
planting_date=planting_date, planting_date=planting_date,
last_phytosanitary_date=last_phytosanitary_date,
model=asset_model, model=asset_model,
location=location, location=location,
lon=lon, lon=lon,
@ -668,7 +695,7 @@ class Command(BaseCommand):
'amenity_value', 'carbon_stock', 'cooling_indicator', 'cooling_energy_indicator', 'biodiversity_index', 'amenity_value', 'carbon_stock', 'cooling_indicator', 'cooling_energy_indicator', 'biodiversity_index',
'managing_authority', 'management_type', 'status', 'vitality', 'is_dead', 'is_stump', 'managing_authority', 'management_type', 'status', 'vitality', 'is_dead', 'is_stump',
'is_remarkable', 'is_chronoxyle', 'development_stage', 'pruning_type', 'is_remarkable', 'is_chronoxyle', 'development_stage', 'pruning_type',
'planting_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson' 'planting_date', 'last_phytosanitary_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson'
], ],
batch_size=BATCH_SIZE batch_size=BATCH_SIZE
) )
@ -796,7 +823,7 @@ class Command(BaseCommand):
status = 'to_be_planned' status = 'to_be_planned'
status_order = 5 status_order = 5
# Date limite # Date limite & Date de diagnostic
deadline_str = props.get('in_deadline') deadline_str = props.get('in_deadline')
expected_end_time = None expected_end_time = None
if deadline_str: if deadline_str:
@ -805,6 +832,14 @@ class Command(BaseCommand):
except Exception: except Exception:
pass pass
dia_date_str = props.get('dia_date')
expected_begin_time = None
if dia_date_str:
try:
expected_begin_time = datetime.fromisoformat(dia_date_str.replace('Z', '+00:00'))
except Exception:
pass
# Géométrie MultiPolygon SRID 3812 # Géométrie MultiPolygon SRID 3812
pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True) pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
buffered_geom = MultiPolygon(pt_3812.buffer(1)) buffered_geom = MultiPolygon(pt_3812.buffer(1))
@ -828,6 +863,7 @@ class Command(BaseCommand):
existing_itv.type = action_type existing_itv.type = action_type
existing_itv.status = status existing_itv.status = status
existing_itv.status_order = status_order existing_itv.status_order = status_order
existing_itv.expected_begin_time = expected_begin_time
existing_itv.expected_end_time = expected_end_time existing_itv.expected_end_time = expected_end_time
existing_itv.order_id = order_id existing_itv.order_id = order_id
existing_itv.contract_id = contract_id existing_itv.contract_id = contract_id
@ -849,6 +885,7 @@ class Command(BaseCommand):
type=action_type, type=action_type,
status=status, status=status,
status_order=status_order, status_order=status_order,
expected_begin_time=expected_begin_time,
expected_end_time=expected_end_time, expected_end_time=expected_end_time,
thematic=thematic_nature, thematic=thematic_nature,
asset_category=cat_arbres, asset_category=cat_arbres,
@ -876,7 +913,8 @@ class Command(BaseCommand):
chunk, chunk,
fields=[ fields=[
'title', 'description', 'address', 'location_code', 'priority', 'title', 'description', 'address', 'location_code', 'priority',
'maintain_type', 'type', 'status', 'status_order', 'expected_end_time', 'maintain_type', 'type', 'status', 'status_order',
'expected_begin_time', 'expected_end_time',
'order_id', 'contract_id', 'source_detail', 'lon', 'lat', 'geom' 'order_id', 'contract_id', 'source_detail', 'lon', 'lat', 'geom'
], ],
batch_size=BATCH_SIZE batch_size=BATCH_SIZE
@ -935,3 +973,104 @@ class Command(BaseCommand):
self.log(f" Insertion liens : {min(i + BATCH_SIZE, len(assets_to_create))} / {len(assets_to_create)}") self.log(f" Insertion liens : {min(i + BATCH_SIZE, len(assets_to_create))} / {len(assets_to_create)}")
self.log(f"Importation des interventions terminée. Total en base : {Intervention.objects.count()}") self.log(f"Importation des interventions terminée. Total en base : {Intervention.objects.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)
"""
self.log("--- Synchronisation des dates de maintenance sur les arbres ---")
from django.db import connection
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)
cur.execute("""
UPDATE assets_naturetree t
SET pruning_frequency_years = sub.freq
FROM (
SELECT DISTINCT ON (ia.object_id)
ia.object_id AS tree_id,
CAST(SUBSTRING(i.title FROM 'Dans ([0-9]+) an') AS INTEGER) AS freq
FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND i.title ~ 'Dans [0-9]+ an'
ORDER BY ia.object_id, i.id DESC
) sub
WHERE t.id = sub.tree_id;
""", [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
cur.execute("""
UPDATE assets_naturetree t
SET last_phytosanitary_date = GREATEST(COALESCE(t.last_phytosanitary_date, sub.max_dia), sub.max_dia)
FROM (
SELECT ia.object_id AS tree_id, MAX(i.expected_begin_time)::date AS max_dia
FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND i.expected_begin_time IS NOT NULL
GROUP BY ia.object_id
) sub
WHERE t.id = sub.tree_id;
""", [content_type_tree.id])
self.log(f" Dernier diagnostic (last_phytosanitary_date) consolidé : {cur.rowcount} arbres")
# 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...")
try:
pruning_updates = {}
with open(gestion_file, mode='r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for r in reader:
if r.get('_status') in ('Execute', 'Controle', 'Reception provisoire', 'Valide'):
pos_lk = r.get('com_pos_lk')
date_exec = r.get('pla_fin') or r.get('co_dat') or r.get('com_dat')
cat = r.get('com_cat') or ''
if pos_lk and date_exec and ('TRA' in cat or 'ELAG' in cat.upper()):
try:
d = datetime.strptime(date_exec[:10], '%Y-%m-%d').date()
if pos_lk not in pruning_updates or d > pruning_updates[pos_lk]:
pruning_updates[pos_lk] = d
except Exception:
pass
if pruning_updates:
trees_to_update = []
for t in NatureTree.objects.filter(external_reference__in=pruning_updates.keys()):
pdate = pruning_updates[t.external_reference]
if not t.last_pruning_date or pdate > t.last_pruning_date:
t.last_pruning_date = pdate
trees_to_update.append(t)
if trees_to_update:
BATCH_SIZE = 2000
for i in range(0, len(trees_to_update), BATCH_SIZE):
NatureTree.objects.bulk_update(trees_to_update[i:i+BATCH_SIZE], fields=['last_pruning_date'])
self.log(f" Dernière taille (last_pruning_date) mise à jour : {len(trees_to_update)} arbres")
except Exception as e:
self.log(f" Erreur lors de la lecture des tailles dans gestion.csv : {e}")

View file

@ -66,7 +66,8 @@ class ImportFulcrumTreesCommandTest(TestCase):
"arb_ont": "Ad", "arb_ont": "Ad",
"ges_conduite": "LI", "ges_conduite": "LI",
"cf_sit": 4.5, "cf_sit": 4.5,
"arb_anpla": "1995" "arb_anpla": "1995",
"maj_date": "2024-05-10T00:00:00.000Z"
} }
} }
] ]
@ -88,13 +89,15 @@ class ImportFulcrumTreesCommandTest(TestCase):
}, },
"properties": { "properties": {
"_record_id": mock_itv_uuid, "_record_id": mock_itv_uuid,
"_title": "Élagage de formation", "_title": "099.01.001, Formation, Dans 1 an",
"rec_interv_com": "Taille branches basses", "rec_interv_com": "Taille branches basses",
"sit_nom": "Boulevard du Test", "sit_nom": "Boulevard du Test",
"comm_commune": "Bruxelles", "comm_commune": "Bruxelles",
"sit_id": "099.01", "sit_id": "099.01",
"pos_id": "001", "pos_id": "001",
"dia_comma": "BC-TEST-001", "dia_comma": "BC-TEST-001",
"dia_date": "2024-05-10T00:00:00.000Z",
"in_deadline": "2025-05-10T00:00:00.000Z",
"rec_delai": "Pla-1", "rec_delai": "Pla-1",
"rec_interv": "FO", "rec_interv": "FO",
"in_fin_yn": "no", "in_fin_yn": "no",
@ -145,6 +148,15 @@ class ImportFulcrumTreesCommandTest(TestCase):
self.assertEqual(itv.order_id, order.id) self.assertEqual(itv.order_id, order.id)
self.assertEqual(itv.priority, "3") # Pla-1 -> 3 self.assertEqual(itv.priority, "3") # Pla-1 -> 3
self.assertEqual(itv.status, "to_be_planned") self.assertEqual(itv.status, "to_be_planned")
self.assertIsNotNone(itv.expected_begin_time)
self.assertEqual(itv.expected_begin_time.date(), date(2024, 5, 10))
self.assertIsNotNone(itv.expected_end_time)
self.assertEqual(itv.expected_end_time.date(), date(2025, 5, 10))
# Vérifications dates de maintenance sur l'arbre
self.assertEqual(tree.last_phytosanitary_date, date(2024, 5, 10))
self.assertEqual(tree.next_inspection_date, date(2025, 5, 10))
self.assertEqual(tree.pruning_frequency_years, 1)
# Vérification liaison InterventionAsset # Vérification liaison InterventionAsset
links = InterventionAsset.objects.filter(intervention=itv) links = InterventionAsset.objects.filter(intervention=itv)