- Create InterventionPlanificationTimeLine events (expected_begin and expected_end) during Fulcrum tree interventions import and maintenance dates synchronization - Display planned/completion date instead of creation_time in asset_interventions.html - Add unit test assertions for planning timeline events
1144 lines
52 KiB
Python
1144 lines
52 KiB
Python
import os
|
|
import csv
|
|
import json
|
|
import re
|
|
from datetime import datetime, date
|
|
from collections import defaultdict
|
|
|
|
from django.core.management.base import BaseCommand
|
|
from django.db import transaction
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.gis.geos import Point, MultiPolygon
|
|
|
|
from common.models import Thematic, Municipality
|
|
from assets.models import AssetCategory, NatureTree, NatureAssetModel, NatureLocation
|
|
from interventions.models import (
|
|
Intervention, InterventionAsset, SourceCategory, Symptom, InterventionSequence
|
|
)
|
|
from contracts.models import Contract, ContractOrder, Company
|
|
|
|
try:
|
|
from assets.utils.tree_ecoservices import compute_tree_ecoservices
|
|
except Exception:
|
|
compute_tree_ecoservices = None
|
|
|
|
|
|
class Command(BaseCommand):
|
|
help = "Importe ou met à jour l'inventaire Fulcrum des arbres, commandes/gestion et interventions."
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument(
|
|
'--arbes-file',
|
|
default='/tmp/fulcrum_arbes.geojson',
|
|
help="Chemin vers fulcrum_arbes.geojson"
|
|
)
|
|
parser.add_argument(
|
|
'--gestion-file',
|
|
default='/tmp/fulcrum_gestion.csv',
|
|
help="Chemin vers fulcrum_gestion.csv"
|
|
)
|
|
parser.add_argument(
|
|
'--interventions-file',
|
|
default='/tmp/fulcrum_interventions.geojson',
|
|
help="Chemin vers fulcrum_interventions.geojson"
|
|
)
|
|
parser.add_argument(
|
|
'--managing-authority',
|
|
default='Bruxelles Mobilité',
|
|
help="Autorité gestionnaire (défaut: Bruxelles Mobilité)"
|
|
)
|
|
parser.add_argument(
|
|
'--management-type',
|
|
default='regional',
|
|
choices=['municipal', 'regional', 'community', 'private'],
|
|
help="Type de gestionnaire (municipal, regional, community, private)"
|
|
)
|
|
parser.add_argument(
|
|
'--code-prefix',
|
|
default='BM-ARB-',
|
|
help="Préfixe pour les codes d'arbres générés (défaut: BM-ARB-)"
|
|
)
|
|
parser.add_argument(
|
|
'--company-name',
|
|
default='Krinkels',
|
|
help="Nom de l'entreprise prestataire par défaut (défaut: Krinkels)"
|
|
)
|
|
parser.add_argument(
|
|
'--fallback-contract',
|
|
default='X00.007',
|
|
help="Numéro de contrat de repli (défaut: X00.007)"
|
|
)
|
|
parser.add_argument(
|
|
'--skip-trees',
|
|
action='store_true',
|
|
help="Ignorer l'importation des arbres"
|
|
)
|
|
parser.add_argument(
|
|
'--skip-gestion',
|
|
action='store_true',
|
|
help="Ignorer l'importation de la gestion/commandes"
|
|
)
|
|
parser.add_argument(
|
|
'--skip-interventions',
|
|
action='store_true',
|
|
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(
|
|
'--limit',
|
|
type=int,
|
|
default=0,
|
|
help="Nombre maximal d'éléments à traiter par étape (0 = tout)"
|
|
)
|
|
|
|
def log(self, msg):
|
|
self.stdout.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
|
|
|
|
def handle(self, *args, **options):
|
|
arbes_file = options['arbes_file']
|
|
gestion_file = options['gestion_file']
|
|
interventions_file = options['interventions_file']
|
|
managing_authority = options['managing_authority']
|
|
management_type = options['management_type']
|
|
code_prefix = options['code_prefix']
|
|
company_name = options['company_name']
|
|
fallback_contract_num = options['fallback_contract']
|
|
limit = options['limit']
|
|
|
|
# Fallbacks locaux si chemins /tmp n'existent pas
|
|
if not os.path.exists(arbes_file):
|
|
alt = os.path.expanduser('~/Downloads/fulcrum_arbes.geojson')
|
|
if os.path.exists(alt): arbes_file = alt
|
|
if not os.path.exists(gestion_file):
|
|
alt = os.path.expanduser('~/Downloads/fulcrum_gestion.csv')
|
|
if os.path.exists(alt): gestion_file = alt
|
|
if not os.path.exists(interventions_file):
|
|
alt = os.path.expanduser('~/Downloads/fulcrum_interventions.geojson')
|
|
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(f"Fichier arbres : {arbes_file}")
|
|
self.log(f"Fichier gestion : {gestion_file}")
|
|
self.log(f"Fichier interventions : {interventions_file}")
|
|
self.log(f"Gestionnaire : {managing_authority} ({management_type})")
|
|
self.log(f"Préfixe code : {code_prefix}")
|
|
|
|
thematic_nature, _ = Thematic.objects.get_or_create(
|
|
name_fr="Nature",
|
|
defaults={"name_nl": "Natuur"}
|
|
)
|
|
cat_arbres = AssetCategory.objects.filter(id=60).first() or AssetCategory.objects.filter(name_fr="Arbres").first()
|
|
if not cat_arbres:
|
|
cat_arbres = AssetCategory.objects.create(id=60, name_fr="Arbres", name_nl="Bomen")
|
|
|
|
# -------------------------------------------------------------
|
|
# ÉTAPE 1 : COMMANDES / GESTION
|
|
# -------------------------------------------------------------
|
|
order_map = {} # order_code -> ContractOrder.id
|
|
order_contract_map = {} # order_code -> Contract.id
|
|
|
|
if not options['skip_gestion'] and os.path.exists(gestion_file):
|
|
self.log("--- ÉTAPE 1 : Importation de gestion.csv ---")
|
|
self.import_gestion(
|
|
gestion_file, thematic_nature, company_name, fallback_contract_num,
|
|
order_map, order_contract_map, limit
|
|
)
|
|
else:
|
|
self.log("Étape 1 sautée. Chargement des commandes existantes...")
|
|
|
|
# Toujours s'assurer que toutes les commandes existantes sont en mémoire pour les étapes suivantes
|
|
for o in ContractOrder.objects.filter(contract__thematics=thematic_nature).values('id', 'order_code', 'contract_id'):
|
|
order_map[o['order_code']] = o['id']
|
|
order_contract_map[o['order_code']] = o['contract_id']
|
|
|
|
# -------------------------------------------------------------
|
|
# ÉTAPE 2 : ARBRES
|
|
# -------------------------------------------------------------
|
|
tree_uuid_to_id = {} # Fulcrum _record_id -> NatureTree.id
|
|
sit_pos_to_tree_id = {} # f"{sit_id}.{pos_id}" -> NatureTree.id
|
|
|
|
if not options['skip_trees'] and os.path.exists(arbes_file):
|
|
self.log("--- ÉTAPE 2 : Importation de arbes.geojson ---")
|
|
self.import_trees(
|
|
arbes_file, cat_arbres, managing_authority, management_type,
|
|
code_prefix, tree_uuid_to_id, sit_pos_to_tree_id, limit
|
|
)
|
|
else:
|
|
self.log("Étape 2 sautée. Chargement des arbres existants...")
|
|
|
|
# Toujours s'assurer que les arbres référencés sont en mémoire
|
|
for t in NatureTree.objects.exclude(external_reference__isnull=True).exclude(external_reference="").values('id', 'external_reference', 'tag_number'):
|
|
tree_uuid_to_id[t['external_reference']] = t['id']
|
|
|
|
# -------------------------------------------------------------
|
|
# ÉTAPE 3 : INTERVENTIONS
|
|
# -------------------------------------------------------------
|
|
if not options['skip_interventions'] and os.path.exists(interventions_file):
|
|
self.log("--- ÉTAPE 3 : Importation de interventions.geojson ---")
|
|
self.import_interventions(
|
|
interventions_file, thematic_nature, cat_arbres, company_name, fallback_contract_num,
|
|
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 ===")
|
|
|
|
# -----------------------------------------------------------------
|
|
# Gestion des contrats & Commandes
|
|
# -----------------------------------------------------------------
|
|
def get_or_create_contract(self, number, title, thematic, company_name):
|
|
company = Company.objects.filter(name__icontains=company_name).first()
|
|
if not company:
|
|
company = Company.objects.create(name=company_name)
|
|
|
|
contract = Contract.objects.filter(contract_number=number).first()
|
|
if not contract:
|
|
contract = Contract.objects.create(
|
|
company=company,
|
|
contract_number=number,
|
|
description=title,
|
|
start_date=date(2020, 1, 1),
|
|
end_date=date(2030, 12, 31)
|
|
)
|
|
contract.thematics.add(thematic)
|
|
return contract
|
|
|
|
def import_gestion(self, gestion_file, thematic_nature, company_name, fallback_contract_num, order_map, order_contract_map, limit=0):
|
|
# Contrats connus fréquents
|
|
c_e21_088 = self.get_or_create_contract("E21.088", "Marché E21.088 - Soins Nature et Ville (SNV)", thematic_nature, company_name)
|
|
c_e21_089 = self.get_or_create_contract("E21.089", "Marché E21.089 - Laanbomen / Arbres d'alignement", thematic_nature, company_name)
|
|
c_e19_051 = self.get_or_create_contract("E19.051", "Marché E19.051 - Diagnostic arboricole", thematic_nature, company_name)
|
|
c_e20_086 = self.get_or_create_contract("E20.086", "Marché E20.086 - Stock d'arbres", thematic_nature, company_name)
|
|
c_fallback = Contract.objects.filter(contract_number=fallback_contract_num).first() or self.get_or_create_contract(
|
|
fallback_contract_num, f"Contrat {fallback_contract_num} - {company_name}", thematic_nature, company_name
|
|
)
|
|
|
|
regie = Company.objects.filter(name__icontains="Régie").first()
|
|
c_r00_001 = Contract.objects.filter(contract_number="R00.001").first()
|
|
if not c_r00_001 and regie:
|
|
c_r00_001 = Contract.objects.create(
|
|
company=regie,
|
|
contract_number="R00.001",
|
|
description="Régie",
|
|
start_date=date(2020, 1, 1),
|
|
end_date=date(2030, 12, 31)
|
|
)
|
|
c_r00_001.thematics.add(thematic_nature)
|
|
|
|
with open(gestion_file, mode='r', encoding='utf-8') as f:
|
|
reader = csv.DictReader(f)
|
|
rows = list(reader)
|
|
|
|
self.log(f"Nombre total de lignes dans gestion.csv : {len(rows)}")
|
|
|
|
# Agréger par (contract, com_id)
|
|
orders_data = {}
|
|
for r in rows:
|
|
com_id = (r.get('com_id') or '').strip()
|
|
if not com_id:
|
|
continue
|
|
|
|
com_typ = r.get('com_typ') or ''
|
|
if 'E21.088' in com_typ:
|
|
contract = c_e21_088
|
|
elif 'E21.089' in com_typ:
|
|
contract = c_e21_089
|
|
elif 'E19.051' in com_typ:
|
|
contract = c_e19_051
|
|
elif 'E20.086' in com_typ:
|
|
contract = c_e20_086
|
|
elif ('INTERNE' in com_typ or 'REGIE' in com_typ) and c_r00_001:
|
|
contract = c_r00_001
|
|
else:
|
|
contract_match = re.search(r'\b([A-Z]\d{2}\.\d{3})\b', com_typ)
|
|
if contract_match:
|
|
c_num = contract_match.group(1)
|
|
contract = Contract.objects.filter(contract_number=c_num).first() or self.get_or_create_contract(
|
|
c_num, f"Marché {c_num}", thematic_nature, company_name
|
|
)
|
|
else:
|
|
contract = c_fallback
|
|
|
|
key = (contract.id, com_id)
|
|
if key not in orders_data:
|
|
orders_data[key] = {
|
|
'contract': contract,
|
|
'order_code': com_id,
|
|
'description': r.get('com_nom') or r.get('_title') or com_id,
|
|
'status_raw': r.get('_status') or '',
|
|
'date_raw': r.get('com_dat') or r.get('_created_at') or '',
|
|
'ech_raw': r.get('com_ech') or r.get('pla_fin') or ''
|
|
}
|
|
|
|
self.log(f"Nombre de bons de commande uniques identifiés : {len(orders_data)}")
|
|
|
|
existing_orders = {
|
|
(o.contract_id, o.order_code): o
|
|
for o in ContractOrder.objects.filter(contract__thematics=thematic_nature)
|
|
}
|
|
|
|
created_cnt = 0
|
|
updated_cnt = 0
|
|
|
|
for key, odata in orders_data.items():
|
|
if limit and (created_cnt + updated_cnt) >= limit:
|
|
break
|
|
|
|
status_raw = odata['status_raw'].lower()
|
|
if status_raw in ('execute', 'controle', 'reception provisoire', 'valide'):
|
|
order_status = 'completed'
|
|
elif status_raw in ('commande', 'en cours'):
|
|
order_status = 'sent'
|
|
elif status_raw in ('annulee', 'annulée'):
|
|
order_status = 'cancelled'
|
|
else:
|
|
order_status = 'pending'
|
|
|
|
# Parse dates
|
|
order_date = date(2020, 1, 1)
|
|
try:
|
|
d_str = odata['date_raw'][:10]
|
|
order_date = datetime.strptime(d_str, '%Y-%m-%d').date()
|
|
except Exception:
|
|
pass
|
|
|
|
delivery_date = order_date
|
|
try:
|
|
e_str = odata['ech_raw'][:10]
|
|
delivery_date = datetime.strptime(e_str, '%Y-%m-%d').date()
|
|
except Exception:
|
|
delivery_date = order_date
|
|
|
|
contract = odata['contract']
|
|
order_code = odata['order_code']
|
|
|
|
existing = existing_orders.get(key)
|
|
if existing:
|
|
existing.description = existing.description or odata['description']
|
|
existing.order_status = order_status
|
|
existing.order_date = order_date
|
|
existing.delivery_date = delivery_date
|
|
existing.save(update_fields=['description', 'order_status', 'order_date', 'delivery_date'])
|
|
order_map[order_code] = existing.id
|
|
order_contract_map[order_code] = contract.id
|
|
updated_cnt += 1
|
|
else:
|
|
new_order = ContractOrder.objects.create(
|
|
contract=contract,
|
|
order_code=order_code,
|
|
description=odata['description'],
|
|
order_status=order_status,
|
|
order_date=order_date,
|
|
delivery_date=delivery_date
|
|
)
|
|
order_map[order_code] = new_order.id
|
|
order_contract_map[order_code] = contract.id
|
|
existing_orders[key] = new_order
|
|
created_cnt += 1
|
|
|
|
self.log(f"Gestion terminée : {created_cnt} créés, {updated_cnt} mis à jour.")
|
|
|
|
# -----------------------------------------------------------------
|
|
# Importation des Arbres
|
|
# -----------------------------------------------------------------
|
|
def import_trees(
|
|
self, arbes_file, cat_arbres, managing_authority, management_type,
|
|
code_prefix, tree_uuid_to_id, sit_pos_to_tree_id, limit=0
|
|
):
|
|
self.log("Lecture du fichier GeoJSON des arbres...")
|
|
with open(arbes_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
features = data.get('features', [])
|
|
total_feats = len(features)
|
|
self.log(f"Nombre total d'arbres dans GeoJSON : {total_feats}")
|
|
|
|
# Indexer les modèles botaniques par genre
|
|
models_by_genus = {}
|
|
for m in NatureAssetModel.objects.all():
|
|
first_word = m.name_fr.strip().split()[0] if m.name_fr else ''
|
|
if first_word and first_word not in models_by_genus:
|
|
models_by_genus[first_word.lower()] = m
|
|
|
|
# Indexer les NatureLocation existantes
|
|
locations_by_code = {loc.code: loc for loc in NatureLocation.objects.exclude(code__isnull=True).exclude(code="")}
|
|
locations_by_name = {loc.name_fr.lower(): loc for loc in NatureLocation.objects.exclude(name_fr__isnull=True)}
|
|
|
|
# Indexer les Communes
|
|
munis = {m.name_fr.lower(): m for m in Municipality.objects.all()}
|
|
|
|
# Pré-charger tous les NatureTree existants
|
|
self.log("Indexation des arbres existants en base de données...")
|
|
existing_by_ext_ref = {}
|
|
existing_by_code = {}
|
|
spatial_grid = defaultdict(list)
|
|
|
|
db_trees_qs = NatureTree.objects.all().values('id', 'code', 'external_reference', 'tag_number', 'lon', 'lat')
|
|
for t in db_trees_qs:
|
|
tid = t['id']
|
|
code = t['code']
|
|
ext_ref = t['external_reference']
|
|
lon = t['lon']
|
|
lat = t['lat']
|
|
|
|
existing_by_code[code] = tid
|
|
if ext_ref:
|
|
existing_by_ext_ref[ext_ref] = tid
|
|
tree_uuid_to_id[ext_ref] = tid
|
|
|
|
if lon is not None and lat is not None:
|
|
gx = int(lon * 10000)
|
|
gy = int(lat * 10000)
|
|
spatial_grid[(gx, gy)].append((lon, lat, tid, code))
|
|
|
|
self.log(f"Arbres existants indexés : {len(existing_by_code)}")
|
|
|
|
# Traitement des entités Fulcrum
|
|
trees_to_update = []
|
|
trees_to_create = []
|
|
|
|
processed = 0
|
|
|
|
for feat in features:
|
|
if limit and processed >= limit:
|
|
break
|
|
processed += 1
|
|
|
|
props = feat.get('properties') or {}
|
|
geom = feat.get('geometry') or {}
|
|
coords = geom.get('coordinates')
|
|
if not coords or len(coords) < 2:
|
|
continue
|
|
|
|
lon, lat = float(coords[0]), float(coords[1])
|
|
rec_id = props.get('_record_id')
|
|
|
|
id_complet = (props.get('id_complet') or '').strip()
|
|
id_sit_pos = (props.get('id_sit_pos') or '').strip()
|
|
id_visuel = (props.get('id_visuel') or '').strip()
|
|
pos_id = str(props.get('pos_id') or '').strip()
|
|
sit_id = (props.get('sit_id') or '').strip()
|
|
sit_nom = (props.get('sit_nom') or '').strip()
|
|
comm_commune = (props.get('comm_commune') or '').strip()
|
|
|
|
arb_ess = (props.get('arb_ess') or '').strip()
|
|
arb_vern = (props.get('arb_vern') or '').strip()
|
|
arb_tax = (props.get('arb_tax') or '').strip()
|
|
arb_cult = (props.get('arb_cult') or '').strip()
|
|
|
|
genus = ''
|
|
if arb_tax:
|
|
genus = arb_tax.split()[0].capitalize()
|
|
elif arb_ess:
|
|
genus = arb_ess.split()[0].capitalize()
|
|
|
|
# Modèle botanique
|
|
asset_model = None
|
|
if genus and genus.lower() in models_by_genus:
|
|
asset_model = models_by_genus[genus.lower()]
|
|
|
|
# Emplacement (NatureLocation)
|
|
location = None
|
|
if sit_id and sit_id in locations_by_code:
|
|
location = locations_by_code[sit_id]
|
|
elif sit_nom and sit_nom.lower() in locations_by_name:
|
|
location = locations_by_name[sit_nom.lower()]
|
|
|
|
if not location and sit_nom:
|
|
muni = munis.get(comm_commune.lower()) if comm_commune else None
|
|
loc_pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
|
|
loc_poly = MultiPolygon(loc_pt_3812.buffer(5))
|
|
location = NatureLocation.objects.create(
|
|
code=sit_id or f"LOC-{comm_commune[:3].upper()}-{processed}",
|
|
name_fr=sit_nom,
|
|
name_nl=sit_nom,
|
|
municipality=muni,
|
|
geom=loc_poly,
|
|
geojson=f'{{"type":"Point","coordinates":[{lon},{lat}]}}'
|
|
)
|
|
if sit_id:
|
|
locations_by_code[sit_id] = location
|
|
locations_by_name[sit_nom.lower()] = location
|
|
|
|
# Statut biologique et sanitaire
|
|
st_raw = props.get('arb_st')
|
|
asset_status = 'active'
|
|
is_dead = False
|
|
is_stump = False
|
|
vitality = 'normal'
|
|
|
|
vit_map = {'Bo': 'normal', 'Mo': 'declining', 'De': 'weak', 'TB': 'strong'}
|
|
if props.get('arb_vital') in vit_map:
|
|
vitality = vit_map[props.get('arb_vital')]
|
|
|
|
if st_raw == 'V':
|
|
asset_status = 'active'
|
|
elif st_raw == 'M':
|
|
asset_status = 'active'
|
|
is_dead = True
|
|
vitality = 'dead'
|
|
elif st_raw == 'S':
|
|
asset_status = 'active'
|
|
is_dead = True
|
|
is_stump = True
|
|
elif st_raw == 'E':
|
|
asset_status = 'to_replace'
|
|
elif st_raw in ('A', 'H'):
|
|
asset_status = 'archived'
|
|
elif st_raw == 'T':
|
|
asset_status = 'removed'
|
|
|
|
# Stade ontogénique
|
|
ont_map = {'Je': 'young', 'Sm': 'semi_mature', 'Ad': 'mature', 'Se': 'senescent', 'Re': 'remarkable'}
|
|
ont_raw = props.get('arb_ont')
|
|
dev_stage = ont_map.get(ont_raw, 'mature')
|
|
is_remarkable = (ont_raw == 'Re')
|
|
is_chronoxyle = (ont_raw == 'Se')
|
|
|
|
# Conduite
|
|
prun_map = {'SE-LI': 'semi_free', 'LI': 'free_crown', 'AR-MA': 'architectured_head', 'AR-RI': 'architectured_curtain', 'FO': 'formation', 'NE': 'none'}
|
|
pruning = prun_map.get(props.get('ges_conduite'), 'free_crown')
|
|
|
|
# Dendrométrie
|
|
haut = float(props['arb_haut']) if props.get('arb_haut') is not None else None
|
|
circ = float(props['arb_circ']) if props.get('arb_circ') is not None else None
|
|
diam = float(props['arb_diam_tmp']) if props.get('arb_diam_tmp') is not None else None
|
|
hsh = float(props['arb_hsh']) if props.get('arb_hsh') is not None else None
|
|
brin = int(props['arb_brin_nb']) if props.get('arb_brin_nb') is not None else 1
|
|
cf_sit = float(props['cf_sit']) if props.get('cf_sit') is not None else None
|
|
|
|
# Date de plantation
|
|
anpla = str(props.get('arb_anpla') or '')
|
|
anpla_match = re.search(r'\b(18\d\d|19\d\d|20[0-2]\d)\b', anpla)
|
|
planting_date = None
|
|
if anpla_match:
|
|
planting_date = date(int(anpla_match.group(1)), 1, 1)
|
|
|
|
# Géométrie EPSG:3812
|
|
pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
|
|
geojson_str = f'{{"type":"Point","coordinates":[{lon},{lat}]}}'
|
|
|
|
tag_no = id_visuel or pos_id
|
|
name_label = arb_vern or arb_ess or id_visuel or id_complet
|
|
|
|
# ---------------------------------------------------------
|
|
# DÉTECTION D'ARBRE EXISTANT (Pas de suppression, mise à jour)
|
|
# ---------------------------------------------------------
|
|
matched_id = None
|
|
|
|
# 1. Par UUID Fulcrum
|
|
if rec_id and rec_id in existing_by_ext_ref:
|
|
matched_id = existing_by_ext_ref[rec_id]
|
|
|
|
# 2. Par code exact
|
|
if not matched_id and id_sit_pos:
|
|
cand_code = f"{code_prefix}{id_sit_pos}"
|
|
if cand_code in existing_by_code:
|
|
matched_id = existing_by_code[cand_code]
|
|
|
|
# 3. Par numéro de visuel
|
|
if not matched_id and id_visuel:
|
|
if id_visuel in existing_by_code:
|
|
matched_id = existing_by_code[id_visuel]
|
|
else:
|
|
cand_visuel = f"{code_prefix}{id_visuel}"
|
|
if cand_visuel in existing_by_code:
|
|
matched_id = existing_by_code[cand_visuel]
|
|
|
|
# 4. Par proximité spatiale fine (< 1.2 mètre)
|
|
if not matched_id:
|
|
gx = int(lon * 10000)
|
|
gy = int(lat * 10000)
|
|
for dx in (-1, 0, 1):
|
|
for dy in (-1, 0, 1):
|
|
for elon, elat, etid, ecode in spatial_grid.get((gx + dx, gy + dy), []):
|
|
d_m = (((lon - elon) * 70000) ** 2 + ((lat - elat) * 111000) ** 2) ** 0.5
|
|
if d_m <= 1.2:
|
|
matched_id = etid
|
|
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
|
|
|
|
if matched_id:
|
|
tree_uuid_to_id[rec_id] = matched_id
|
|
if sit_pos_key:
|
|
sit_pos_to_tree_id[sit_pos_key] = matched_id
|
|
|
|
trees_to_update.append({
|
|
'id': matched_id,
|
|
'external_reference': rec_id,
|
|
'tag_number': tag_no,
|
|
'scientific_name': arb_tax or arb_ess,
|
|
'vernacular_name': arb_vern or arb_ess,
|
|
'genus': genus,
|
|
'height': haut,
|
|
'circumference': circ,
|
|
'crown_diameter': diam,
|
|
'crown_clearance': hsh,
|
|
'number_of_trunks': brin,
|
|
'situation_coefficient': cf_sit,
|
|
'managing_authority': managing_authority,
|
|
'management_type': management_type,
|
|
'status': asset_status,
|
|
'vitality': vitality,
|
|
'is_dead': is_dead,
|
|
'is_stump': is_stump,
|
|
'is_remarkable': is_remarkable,
|
|
'is_chronoxyle': is_chronoxyle,
|
|
'development_stage': dev_stage,
|
|
'pruning_type': pruning,
|
|
'planting_date': planting_date,
|
|
'last_phytosanitary_date': last_phytosanitary_date,
|
|
'model_id': asset_model.id if asset_model else None,
|
|
'location_id': location.id if location else None,
|
|
'lon': lon,
|
|
'lat': lat,
|
|
'geom': pt_3812,
|
|
'geojson': geojson_str,
|
|
})
|
|
else:
|
|
code = f"{code_prefix}{id_sit_pos}" if id_sit_pos else (
|
|
f"{code_prefix}{id_visuel}" if id_visuel else f"{code_prefix}{processed}"
|
|
)
|
|
existing_by_code[code] = None
|
|
|
|
new_tree = NatureTree(
|
|
code=code,
|
|
name_fr=name_label or f"Arbre {code}",
|
|
name_nl=name_label or f"Boom {code}",
|
|
external_reference=rec_id,
|
|
tag_number=tag_no,
|
|
scientific_name=arb_tax or arb_ess,
|
|
vernacular_name=arb_vern or arb_ess,
|
|
genus=genus,
|
|
cultivar=arb_cult,
|
|
height=haut,
|
|
circumference=circ,
|
|
crown_diameter=diam,
|
|
crown_clearance=hsh,
|
|
number_of_trunks=brin,
|
|
situation_coefficient=cf_sit,
|
|
managing_authority=managing_authority,
|
|
management_type=management_type,
|
|
category=cat_arbres,
|
|
status=asset_status,
|
|
vitality=vitality,
|
|
is_dead=is_dead,
|
|
is_stump=is_stump,
|
|
is_remarkable=is_remarkable,
|
|
is_chronoxyle=is_chronoxyle,
|
|
development_stage=dev_stage,
|
|
pruning_type=pruning,
|
|
planting_date=planting_date,
|
|
last_phytosanitary_date=last_phytosanitary_date,
|
|
model=asset_model,
|
|
location=location,
|
|
lon=lon,
|
|
lat=lat,
|
|
geom=pt_3812,
|
|
geojson=geojson_str,
|
|
)
|
|
if compute_tree_ecoservices:
|
|
compute_tree_ecoservices(new_tree, overwrite_existing=False)
|
|
|
|
trees_to_create.append((rec_id, sit_pos_key, new_tree))
|
|
|
|
self.log(f"Arbres identifiés pour mise à jour : {len(trees_to_update)}")
|
|
self.log(f"Arbres identifiés pour création : {len(trees_to_create)}")
|
|
|
|
# Exécution des mises à jour en lots
|
|
BATCH_SIZE = 2000
|
|
for i in range(0, len(trees_to_update), BATCH_SIZE):
|
|
chunk = trees_to_update[i:i + BATCH_SIZE]
|
|
chunk_ids = [c['id'] for c in chunk]
|
|
trees_objs = {t.id: t for t in NatureTree.objects.filter(id__in=chunk_ids)}
|
|
to_save = []
|
|
for c in chunk:
|
|
obj = trees_objs.get(c['id'])
|
|
if obj:
|
|
for k, v in c.items():
|
|
if k != 'id':
|
|
setattr(obj, k, v)
|
|
if compute_tree_ecoservices:
|
|
compute_tree_ecoservices(obj, overwrite_existing=False)
|
|
to_save.append(obj)
|
|
NatureTree.objects.bulk_update(
|
|
to_save,
|
|
fields=[
|
|
'external_reference', 'tag_number', 'scientific_name', 'vernacular_name',
|
|
'genus', 'height', 'circumference', 'crown_diameter', 'crown_clearance',
|
|
'number_of_trunks', 'situation_coefficient', 'plantation_coefficient',
|
|
'amenity_value', 'carbon_stock', 'cooling_indicator', 'cooling_energy_indicator', 'biodiversity_index',
|
|
'managing_authority', 'management_type', 'status', 'vitality', 'is_dead', 'is_stump',
|
|
'is_remarkable', 'is_chronoxyle', 'development_stage', 'pruning_type',
|
|
'planting_date', 'last_phytosanitary_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson'
|
|
],
|
|
batch_size=BATCH_SIZE
|
|
)
|
|
self.log(f" Mise à jour arbres : {min(i + BATCH_SIZE, len(trees_to_update))} / {len(trees_to_update)}")
|
|
|
|
# Exécution des créations en lots
|
|
for i in range(0, len(trees_to_create), BATCH_SIZE):
|
|
chunk = trees_to_create[i:i + BATCH_SIZE]
|
|
objs = [item[2] for item in chunk]
|
|
created_objs = NatureTree.objects.bulk_create(objs, batch_size=BATCH_SIZE)
|
|
for (rec_id, sit_pos_key, _), obj in zip(chunk, created_objs):
|
|
if rec_id: tree_uuid_to_id[rec_id] = obj.id
|
|
if sit_pos_key: sit_pos_to_tree_id[sit_pos_key] = obj.id
|
|
self.log(f" Création arbres : {min(i + BATCH_SIZE, len(trees_to_create))} / {len(trees_to_create)}")
|
|
|
|
self.log(f"Importation des arbres terminée. Total en base : {NatureTree.objects.count()}")
|
|
|
|
# -----------------------------------------------------------------
|
|
# Importation des Interventions
|
|
# -----------------------------------------------------------------
|
|
def import_interventions(
|
|
self, interventions_file, thematic_nature, cat_arbres, company_name, fallback_contract_num,
|
|
order_map, order_contract_map, tree_uuid_to_id, sit_pos_to_tree_id, limit=0
|
|
):
|
|
self.log("Lecture du fichier GeoJSON des interventions...")
|
|
with open(interventions_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
features = data.get('features', [])
|
|
total_feats = len(features)
|
|
self.log(f"Nombre total d'interventions dans GeoJSON : {total_feats}")
|
|
|
|
company = Company.objects.filter(name__icontains=company_name).first()
|
|
fallback_contract = Contract.objects.filter(contract_number=fallback_contract_num).first()
|
|
|
|
source_fulcrum, _ = SourceCategory.objects.get_or_create(name_fr="Fulcrum", defaults={"name_nl": "Fulcrum"})
|
|
|
|
# Symptômes
|
|
symptom_elagage = Symptom.objects.filter(name_fr__icontains="élaguer").first()
|
|
symptom_abattage = Symptom.objects.filter(name_fr__icontains="Arbre endommagé ou tombé").first()
|
|
symptom_bois_mort = Symptom.objects.filter(name_fr__icontains="Branche(s) au sol").first()
|
|
symptom_autre = Symptom.objects.filter(name_fr__icontains="Arbre - Autre").first()
|
|
|
|
content_type_tree = ContentType.objects.get_for_model(NatureTree)
|
|
|
|
self.log("Indexation des interventions existantes en base...")
|
|
existing_itvs = {
|
|
itv.source_ref: itv
|
|
for itv in Intervention.objects.exclude(source_ref__isnull=True).exclude(source_ref="")
|
|
}
|
|
self.log(f"Interventions existantes indexées : {len(existing_itvs)}")
|
|
|
|
itvs_to_update = []
|
|
itvs_to_create = []
|
|
itv_tree_links = []
|
|
|
|
processed = 0
|
|
|
|
for feat in features:
|
|
if limit and processed >= limit:
|
|
break
|
|
processed += 1
|
|
|
|
props = feat.get('properties') or {}
|
|
geom = feat.get('geometry') or {}
|
|
coords = geom.get('coordinates')
|
|
if not coords or len(coords) < 2:
|
|
continue
|
|
|
|
lon, lat = float(coords[0]), float(coords[1])
|
|
rec_id = props.get('_record_id')
|
|
title = props.get('_title') or "Intervention arboricole Fulcrum"
|
|
desc = props.get('rec_interv_com') or title
|
|
sit_nom = props.get('sit_nom') or ''
|
|
comm_commune = props.get('comm_commune') or ''
|
|
sit_id = props.get('sit_id') or ''
|
|
pos_id = str(props.get('pos_id') or '')
|
|
dia_comma = (props.get('dia_comma') or '').strip()
|
|
|
|
address = f"{sit_nom} ({comm_commune})" if sit_nom and comm_commune else (sit_nom or comm_commune)
|
|
|
|
# Identification du bon de commande et du contrat
|
|
order_id = order_map.get(dia_comma)
|
|
contract_id = order_contract_map.get(dia_comma) or (fallback_contract.id if fallback_contract else None)
|
|
|
|
# Priorité
|
|
rec_delai = props.get('rec_delai')
|
|
if rec_delai in ('Urg-0', 'Urg-1'):
|
|
priority = '1'
|
|
elif rec_delai == 'Urg-2':
|
|
priority = '2'
|
|
elif rec_delai == 'Pla-1':
|
|
priority = '3'
|
|
else:
|
|
priority = '4'
|
|
|
|
# Type d'intervention
|
|
rec_interv = props.get('rec_interv') or ''
|
|
if rec_interv in ('AR', 'AA'):
|
|
maintain_type = 'corrective'
|
|
action_type = 'replace'
|
|
symptom = symptom_abattage or symptom_autre
|
|
elif rec_interv in ('EB', 'BO'):
|
|
maintain_type = 'corrective'
|
|
action_type = 'care'
|
|
symptom = symptom_bois_mort or symptom_elagage or symptom_autre
|
|
elif rec_interv in ('FO', 'EN', 'ED', 'TA', 'BM'):
|
|
maintain_type = 'preventive'
|
|
action_type = 'care'
|
|
symptom = symptom_elagage or symptom_autre
|
|
elif rec_interv in ('ET', 'HM'):
|
|
maintain_type = 'ameliorative'
|
|
action_type = 'repair'
|
|
symptom = symptom_autre
|
|
else:
|
|
maintain_type = 'corrective'
|
|
action_type = 'care'
|
|
symptom = symptom_autre
|
|
|
|
# Statut
|
|
in_fin_yn = props.get('in_fin_yn')
|
|
if in_fin_yn == 'yes':
|
|
status = 'finished'
|
|
status_order = 25
|
|
else:
|
|
status = 'to_be_planned'
|
|
status_order = 5
|
|
|
|
# Date limite & Date de diagnostic
|
|
deadline_str = props.get('in_deadline')
|
|
expected_end_time = None
|
|
if deadline_str:
|
|
try:
|
|
expected_end_time = datetime.fromisoformat(deadline_str.replace('Z', '+00:00'))
|
|
except Exception:
|
|
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
|
|
pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
|
|
buffered_geom = MultiPolygon(pt_3812.buffer(1))
|
|
|
|
# Recherche de l'arbre associé
|
|
tree_uuid_raw = props.get('mater_arbres_lk')
|
|
tree_uuid = tree_uuid_raw[0] if isinstance(tree_uuid_raw, list) and tree_uuid_raw else tree_uuid_raw
|
|
matched_tree_id = tree_uuid_to_id.get(tree_uuid)
|
|
if not matched_tree_id and sit_id and pos_id:
|
|
matched_tree_id = sit_pos_to_tree_id.get(f"{sit_id}.{pos_id}")
|
|
|
|
existing_itv = existing_itvs.get(rec_id)
|
|
|
|
if existing_itv:
|
|
existing_itv.title = title
|
|
existing_itv.description = desc
|
|
existing_itv.address = address
|
|
existing_itv.location_code = sit_id
|
|
existing_itv.priority = priority
|
|
existing_itv.maintain_type = maintain_type
|
|
existing_itv.type = action_type
|
|
existing_itv.status = status
|
|
existing_itv.status_order = status_order
|
|
existing_itv.expected_begin_time = expected_begin_time
|
|
existing_itv.expected_end_time = expected_end_time
|
|
existing_itv.order_id = order_id
|
|
existing_itv.contract_id = contract_id
|
|
existing_itv.source_detail = dia_comma
|
|
existing_itv.lon = lon
|
|
existing_itv.lat = lat
|
|
existing_itv.geom = buffered_geom
|
|
itvs_to_update.append(existing_itv)
|
|
if matched_tree_id:
|
|
itv_tree_links.append((existing_itv.id, matched_tree_id))
|
|
else:
|
|
new_itv = Intervention(
|
|
title=title,
|
|
description=desc,
|
|
address=address,
|
|
location_code=sit_id,
|
|
priority=priority,
|
|
maintain_type=maintain_type,
|
|
type=action_type,
|
|
status=status,
|
|
status_order=status_order,
|
|
expected_begin_time=expected_begin_time,
|
|
expected_end_time=expected_end_time,
|
|
thematic=thematic_nature,
|
|
asset_category=cat_arbres,
|
|
symptom=symptom,
|
|
order_id=order_id,
|
|
contract_id=contract_id,
|
|
assigned_provider=company,
|
|
source_category=source_fulcrum,
|
|
source_ref=rec_id,
|
|
source_detail=dia_comma,
|
|
lon=lon,
|
|
lat=lat,
|
|
geom=buffered_geom
|
|
)
|
|
itvs_to_create.append((new_itv, matched_tree_id))
|
|
|
|
self.log(f"Interventions à mettre à jour : {len(itvs_to_update)}")
|
|
self.log(f"Interventions à créer : {len(itvs_to_create)}")
|
|
|
|
# Mettre à jour en lots
|
|
BATCH_SIZE = 2000
|
|
for i in range(0, len(itvs_to_update), BATCH_SIZE):
|
|
chunk = itvs_to_update[i:i + BATCH_SIZE]
|
|
Intervention.objects.bulk_update(
|
|
chunk,
|
|
fields=[
|
|
'title', 'description', 'address', 'location_code', 'priority',
|
|
'maintain_type', 'type', 'status', 'status_order',
|
|
'expected_begin_time', 'expected_end_time',
|
|
'order_id', 'contract_id', 'source_detail', 'lon', 'lat', 'geom'
|
|
],
|
|
batch_size=BATCH_SIZE
|
|
)
|
|
self.log(f" Mise à jour interventions : {min(i + BATCH_SIZE, len(itvs_to_update))} / {len(itvs_to_update)}")
|
|
|
|
# Création des nouvelles interventions
|
|
if itvs_to_create:
|
|
self.log("Attribution des codes d'intervention séquentiels...")
|
|
with transaction.atomic():
|
|
seq, _ = InterventionSequence.objects.select_for_update().get_or_create(id=1)
|
|
start_num = seq.current_number + 1
|
|
seq.current_number += len(itvs_to_create)
|
|
seq.save(update_fields=['current_number'])
|
|
|
|
for idx, (itv, _) in enumerate(itvs_to_create):
|
|
code_num = start_num + idx
|
|
itv.code = f"I{str(code_num).zfill(6)}"
|
|
|
|
created_objs = []
|
|
for i in range(0, len(itvs_to_create), BATCH_SIZE):
|
|
chunk = itvs_to_create[i:i + BATCH_SIZE]
|
|
objs = [c[0] for c in chunk]
|
|
batch_created = Intervention.objects.bulk_create(objs, batch_size=BATCH_SIZE)
|
|
created_objs.extend(batch_created)
|
|
for (itv, tree_id), obj in zip(chunk, batch_created):
|
|
if tree_id:
|
|
itv_tree_links.append((obj.id, tree_id))
|
|
self.log(f" Création interventions : {min(i + BATCH_SIZE, len(itvs_to_create))} / {len(itvs_to_create)}")
|
|
|
|
# Lier les interventions aux arbres via InterventionAsset
|
|
self.log(f"Création des liens Intervention <-> Arbre ({len(itv_tree_links)} liens)...")
|
|
existing_links = set(
|
|
InterventionAsset.objects.filter(
|
|
content_type=content_type_tree,
|
|
intervention_id__in=[l[0] for l in itv_tree_links[:50000]]
|
|
).values_list('intervention_id', 'object_id')
|
|
)
|
|
|
|
assets_to_create = []
|
|
for itv_id, tree_id in itv_tree_links:
|
|
if (itv_id, tree_id) not in existing_links:
|
|
assets_to_create.append(
|
|
InterventionAsset(
|
|
intervention_id=itv_id,
|
|
content_type=content_type_tree,
|
|
object_id=tree_id
|
|
)
|
|
)
|
|
existing_links.add((itv_id, tree_id))
|
|
|
|
self.log(f"Nouveaux liens InterventionAsset à insérer : {len(assets_to_create)}")
|
|
for i in range(0, len(assets_to_create), BATCH_SIZE):
|
|
chunk = assets_to_create[i:i + BATCH_SIZE]
|
|
InterventionAsset.objects.bulk_create(chunk, batch_size=BATCH_SIZE)
|
|
self.log(f" Insertion liens : {min(i + BATCH_SIZE, len(assets_to_create))} / {len(assets_to_create)}")
|
|
|
|
# Synchronisation des événements de planification timeline
|
|
self.sync_intervention_planning_events()
|
|
|
|
self.log(f"Importation des interventions terminée. Total en base : {Intervention.objects.count()}")
|
|
|
|
def sync_intervention_planning_events(self):
|
|
"""
|
|
Crée ou met à jour les événements de la timeline de planification (InterventionPlanificationTimeLine) :
|
|
- 'expected_end' : échéance prévisionnelle calculée depuis in_deadline
|
|
- 'expected_begin' : date de début / diagnostic initial depuis dia_date
|
|
"""
|
|
self.log("--- Synchronisation des événements de planification (timeline) ---")
|
|
from django.db import connection
|
|
|
|
with connection.cursor() as cur:
|
|
# 1. Échéances prévisionnelles (expected_end)
|
|
cur.execute("""
|
|
INSERT INTO interventions_interventionplanificationtimeline (
|
|
intervention_id, event_time, event_type, event_description
|
|
)
|
|
SELECT i.id, i.expected_end_time, 'expected_end', 'Échéance prévisionnelle'
|
|
FROM interventions_intervention i
|
|
WHERE i.expected_end_time IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM interventions_interventionplanificationtimeline p
|
|
WHERE p.intervention_id = i.id AND p.event_type = 'expected_end'
|
|
);
|
|
""")
|
|
created_end = cur.rowcount
|
|
|
|
cur.execute("""
|
|
UPDATE interventions_interventionplanificationtimeline p
|
|
SET event_time = i.expected_end_time
|
|
FROM interventions_intervention i
|
|
WHERE p.intervention_id = i.id
|
|
AND p.event_type = 'expected_end'
|
|
AND p.event_time != i.expected_end_time;
|
|
""")
|
|
updated_end = cur.rowcount
|
|
self.log(f" Échéance prévisionnelle (expected_end) : {created_end} créés, {updated_end} mis à jour")
|
|
|
|
# 2. Diagnostics initiaux (expected_begin)
|
|
cur.execute("""
|
|
INSERT INTO interventions_interventionplanificationtimeline (
|
|
intervention_id, event_time, event_type, event_description
|
|
)
|
|
SELECT i.id, i.expected_begin_time, 'expected_begin', 'Diagnostic initial'
|
|
FROM interventions_intervention i
|
|
WHERE i.expected_begin_time IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM interventions_interventionplanificationtimeline p
|
|
WHERE p.intervention_id = i.id AND p.event_type = 'expected_begin'
|
|
);
|
|
""")
|
|
created_begin = cur.rowcount
|
|
|
|
cur.execute("""
|
|
UPDATE interventions_interventionplanificationtimeline p
|
|
SET event_time = i.expected_begin_time
|
|
FROM interventions_intervention i
|
|
WHERE p.intervention_id = i.id
|
|
AND p.event_type = 'expected_begin'
|
|
AND p.event_time != i.expected_begin_time;
|
|
""")
|
|
updated_begin = cur.rowcount
|
|
self.log(f" Diagnostic initial (expected_begin) : {created_begin} créés, {updated_begin} mis à jour")
|
|
|
|
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
|
|
"""
|
|
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)
|
|
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}")
|
|
|