561 lines
20 KiB
Python
561 lines
20 KiB
Python
"""
|
||
Service d'import d'interventions préventives depuis un fichier Excel.
|
||
|
||
Ce module expose la fonction principale `process_worksheet` utilisée à la fois
|
||
par la commande de management `import_preventive_interventions` et par la vue
|
||
d'administration dédiée.
|
||
|
||
Format du fichier Excel attendu :
|
||
- B1 : code de la thématique (ex: 'structures')
|
||
- B2 : code du contrat
|
||
- B3 : code de la commande (order)
|
||
- Ligne 5 : codes de localisations en colonnes (B5, C5, D5, …)
|
||
- Colonne A à partir de la ligne 6 : codes de templates d'opérations
|
||
- Cellules : 'x', '1' ou 'v' = créer une intervention
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Callable, Optional
|
||
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.contrib.gis.geos import MultiPolygon, Point
|
||
from django.db.models import QuerySet
|
||
from django.utils import timezone
|
||
|
||
from common.models import Thematic
|
||
from contracts.models import Contract, ContractOrder
|
||
from interventions.models import (
|
||
Intervention,
|
||
InterventionAsset,
|
||
InterventionLocation,
|
||
InterventionPlanificationTimeLine,
|
||
InterventionTimeLine,
|
||
Operation,
|
||
OperationTemplate,
|
||
OperationTemplateItem,
|
||
STATUS_ORDERS,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constantes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CHECKED_VALUES = {"x", "1", "v", "oui", "yes", "true", "✓", "✔"}
|
||
|
||
THEMATIC_LOCATION_MODELS = {
|
||
'structures': ('assets.models.structures', 'StructureLocation'),
|
||
'trafficlights': ('assets.models.trafficlights', 'TrafficLightIntersection'),
|
||
'publiclighting': ('assets.models.publiclighting','PublicLightingStreet'),
|
||
'nature': ('assets.models.nature', 'NatureLocation'),
|
||
'roads': ('assets.models.roads', 'RoadStreet'),
|
||
'sign': ('assets.models.sign', 'SignStreet'),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers — géométrie
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _to_multipolygon(geom):
|
||
"""Convertit une géométrie en MultiPolygon (SRID 3812)."""
|
||
if geom is None or (hasattr(geom, 'empty') and geom.empty):
|
||
return None
|
||
gtype = geom.geom_type
|
||
if gtype == 'Polygon':
|
||
return MultiPolygon(geom)
|
||
if gtype == 'MultiPolygon':
|
||
return geom
|
||
if gtype == 'GeometryCollection':
|
||
polys = [g for g in geom if g.geom_type == 'Polygon']
|
||
return MultiPolygon(polys) if polys else None
|
||
return None
|
||
|
||
|
||
def _build_geom(location):
|
||
geom_raw = getattr(location, 'geom', None)
|
||
if geom_raw is None:
|
||
lon = getattr(location, 'lon', None)
|
||
lat = getattr(location, 'lat', None)
|
||
if lon is not None and lat is not None:
|
||
pt = Point(lon, lat, srid=4326)
|
||
pt.transform(3812)
|
||
return _to_multipolygon(pt.buffer(1))
|
||
return None
|
||
if geom_raw.geom_type in ('Point', 'MultiPoint', 'LineString', 'MultiLineString'):
|
||
return _to_multipolygon(geom_raw.buffer(1))
|
||
return _to_multipolygon(geom_raw)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers — assets enfants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _flatten_asset_collection(collection):
|
||
"""Aplatit récursivement une collection d'assets (dict, list, QuerySet, …)."""
|
||
if collection is None:
|
||
return
|
||
if isinstance(collection, dict):
|
||
for value in collection.values():
|
||
yield from _flatten_asset_collection(value)
|
||
return
|
||
if isinstance(collection, (list, tuple, set)):
|
||
for value in collection:
|
||
yield from _flatten_asset_collection(value)
|
||
return
|
||
if isinstance(collection, QuerySet):
|
||
for item in collection.iterator():
|
||
yield item
|
||
return
|
||
yield collection
|
||
|
||
|
||
def _extract_assets_from_location(location):
|
||
"""Retourne les assets actifs d'une localisation (via get_active_assets)."""
|
||
getter = getattr(location, 'get_active_assets', None)
|
||
if not callable(getter):
|
||
return []
|
||
return _flatten_asset_collection(getter())
|
||
|
||
|
||
def _asset_matches_template_filters(asset_obj, template, allow_unfiltered: bool = True) -> bool:
|
||
"""
|
||
Vérifie si un asset correspond aux filtres catégorie/modèle d'un template.
|
||
|
||
- allow_unfiltered=True : si aucun filtre n'est défini, l'asset est accepté
|
||
(cas : asset sélectionné explicitement par l'utilisateur)
|
||
- allow_unfiltered=False : si aucun filtre n'est défini, l'asset est rejeté
|
||
(cas : parcours automatique des assets enfants d'une localisation)
|
||
"""
|
||
category_ids = template.get_category_ids()
|
||
model_filters = template.get_asset_model_filters()
|
||
|
||
if not category_ids and not model_filters:
|
||
return allow_unfiltered
|
||
|
||
if category_ids:
|
||
asset_category = getattr(asset_obj, 'category', None)
|
||
if not asset_category or asset_category.id not in category_ids:
|
||
return False
|
||
|
||
if model_filters:
|
||
asset_model = getattr(asset_obj, 'model', None)
|
||
if not asset_model:
|
||
return False
|
||
model_name = asset_model._meta.model_name.lower()
|
||
allowed_ids = model_filters.get(model_name)
|
||
if not allowed_ids or asset_model.pk not in allowed_ids:
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def _get_templates_with_equipment_filters(templates):
|
||
"""Retourne uniquement les templates ayant des filtres catégorie ou modèle."""
|
||
return [t for t in templates if t.get_category_ids() or t.get_asset_model_filters()]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers — opérations
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _add_operations_from_template(
|
||
intervention: Intervention,
|
||
template: OperationTemplate,
|
||
asset_obj=None,
|
||
asset_ct=None,
|
||
only_generic: Optional[bool] = None,
|
||
log: Optional[Callable] = None,
|
||
):
|
||
"""
|
||
Crée les opérations d'un template pour une intervention.
|
||
|
||
Args:
|
||
only_generic : True → uniquement génériques ; False → uniquement équipement ;
|
||
None → toutes (en respectant is_generic).
|
||
asset_obj / asset_ct : requis quand only_generic=False.
|
||
"""
|
||
items = OperationTemplateItem.objects.filter(template=template).order_by('order')
|
||
|
||
# Dédoublonnage génériques
|
||
existing_generic = set(
|
||
Operation.objects.filter(
|
||
intervention=intervention,
|
||
template_group=template.name,
|
||
is_generic=True,
|
||
).values_list('name_fr', flat=True)
|
||
)
|
||
|
||
# Dédoublonnage équipement
|
||
existing_equipment = set()
|
||
if asset_obj is not None and asset_ct is not None:
|
||
existing_equipment = set(
|
||
Operation.objects.filter(
|
||
intervention=intervention,
|
||
template_group=template.name,
|
||
asset_content_type=asset_ct,
|
||
asset_object_id=asset_obj.pk,
|
||
is_generic=False,
|
||
).values_list('name_fr', flat=True)
|
||
)
|
||
|
||
ops = []
|
||
for item in items:
|
||
if only_generic is True and not item.is_generic:
|
||
continue
|
||
if only_generic is False and item.is_generic:
|
||
continue
|
||
|
||
if item.is_generic:
|
||
if item.name_fr in existing_generic:
|
||
continue
|
||
else:
|
||
if item.name_fr in existing_equipment:
|
||
continue
|
||
|
||
op = Operation(
|
||
intervention=intervention,
|
||
name_fr=item.name_fr,
|
||
name_nl=item.name_nl,
|
||
status_set=item.status_set,
|
||
measurement_definition=item.measurement_definition,
|
||
order=item.order,
|
||
template_group=template.name,
|
||
requires_photo=item.requires_photo,
|
||
is_generic=item.is_generic,
|
||
)
|
||
if not item.is_generic and asset_obj is not None and asset_ct is not None:
|
||
op.asset_content_type = asset_ct
|
||
op.asset_object_id = asset_obj.pk
|
||
|
||
ops.append(op)
|
||
|
||
if ops:
|
||
Operation.objects.bulk_create(ops)
|
||
if log:
|
||
scope = "génériques" if only_generic else ("équipement" if only_generic is False else "")
|
||
log(f" → {len(ops)} opération(s) {scope} créée(s) depuis '{template.name}'")
|
||
|
||
return ops
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers — thématique / localisation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def resolve_location_model(thematic_code: str):
|
||
"""
|
||
Retourne la classe modèle de localisation correspondant au code thématique.
|
||
Lève ValueError si le code est inconnu.
|
||
"""
|
||
entry = THEMATIC_LOCATION_MODELS.get(thematic_code)
|
||
if entry is None:
|
||
raise ValueError(
|
||
f"Thématique inconnue : '{thematic_code}'. "
|
||
f"Valeurs acceptées : {', '.join(THEMATIC_LOCATION_MODELS.keys())}."
|
||
)
|
||
module_path, class_name = entry
|
||
import importlib
|
||
mod = importlib.import_module(f"assets.models.{module_path.split('.')[-1]}")
|
||
return getattr(mod, class_name)
|
||
|
||
|
||
def _build_title(location, template) -> str:
|
||
loc_code = getattr(location, 'code', '') or ''
|
||
loc_name = location.get_name() if hasattr(location, 'get_name') else ''
|
||
tmpl_name = template.name or ''
|
||
if loc_code and loc_name:
|
||
return f"{loc_code} - {tmpl_name} - {loc_name}"
|
||
if loc_code:
|
||
return f"{loc_code} - {tmpl_name}"
|
||
if loc_name:
|
||
return f"{tmpl_name} - {loc_name}"
|
||
return tmpl_name
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lecture Excel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def cell_is_checked(value) -> bool:
|
||
if value is None:
|
||
return False
|
||
return str(value).strip().lower() in CHECKED_VALUES
|
||
|
||
|
||
def cell_value(ws, row: int, col: int):
|
||
try:
|
||
return ws.cell(row=row, column=col).value
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fonction principale
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ImportResult:
|
||
"""Résultat de l'import."""
|
||
def __init__(self):
|
||
self.created: int = 0
|
||
self.skipped: int = 0
|
||
self.warnings: list[str] = []
|
||
self.errors: list[str] = []
|
||
self.dry_run: bool = False
|
||
|
||
|
||
def process_worksheet(
|
||
ws,
|
||
user=None,
|
||
expected_begin=None,
|
||
expected_end=None,
|
||
status: str = 'in_preparation',
|
||
dry_run: bool = False,
|
||
log: Optional[Callable] = None,
|
||
) -> ImportResult:
|
||
"""
|
||
Traite une feuille Excel et crée les interventions préventives.
|
||
|
||
Args:
|
||
ws : feuille openpyxl (openpyxl.worksheet.worksheet.Worksheet)
|
||
user : instance User (created_by) ou None
|
||
expected_begin: datetime aware ou None
|
||
expected_end : datetime aware ou None
|
||
status : code statut des interventions à créer
|
||
dry_run : si True, aucune écriture en base
|
||
log : callable(message) pour les logs (optionnel)
|
||
|
||
Returns:
|
||
ImportResult
|
||
"""
|
||
result = ImportResult()
|
||
result.dry_run = dry_run
|
||
|
||
def _log(msg):
|
||
if log:
|
||
log(msg)
|
||
|
||
# --- Lecture des métadonnées ---
|
||
thematic_code = cell_value(ws, 1, 2) # B1
|
||
contract_code = cell_value(ws, 2, 2) # B2
|
||
order_code = cell_value(ws, 3, 2) # B3
|
||
|
||
if not thematic_code:
|
||
result.errors.append("Cellule B1 vide — code thématique manquant.")
|
||
return result
|
||
|
||
thematic_code = str(thematic_code).strip()
|
||
_log(f"Thématique : {thematic_code}")
|
||
|
||
# --- Résolution des objets DB ---
|
||
thematic = Thematic.objects.filter(code=thematic_code).first()
|
||
if thematic is None:
|
||
result.errors.append(f"Thématique '{thematic_code}' introuvable en base.")
|
||
return result
|
||
|
||
contract = None
|
||
if contract_code:
|
||
contract = Contract.objects.filter(contract_number=str(contract_code).strip()).first()
|
||
if contract is None:
|
||
result.warnings.append(
|
||
f"Contrat '{contract_code}' introuvable — interventions créées sans contrat."
|
||
)
|
||
|
||
order = None
|
||
if order_code:
|
||
qs = ContractOrder.objects.filter(order_code=str(order_code).strip())
|
||
if contract:
|
||
qs = qs.filter(contract=contract)
|
||
order = qs.first()
|
||
if order is None:
|
||
result.warnings.append(
|
||
f"Commande '{order_code}' introuvable — interventions créées sans commande."
|
||
)
|
||
|
||
try:
|
||
LocationModel = resolve_location_model(thematic_code)
|
||
except ValueError as e:
|
||
result.errors.append(str(e))
|
||
return result
|
||
|
||
location_ct = ContentType.objects.get_for_model(LocationModel)
|
||
|
||
# --- Ligne 5 : codes de localisations ---
|
||
location_codes = [] # [(col_index, code)]
|
||
for col in range(2, ws.max_column + 1):
|
||
val = cell_value(ws, 5, col)
|
||
if val is not None and str(val).strip():
|
||
location_codes.append((col, str(val).strip()))
|
||
|
||
if not location_codes:
|
||
result.errors.append("Aucun code de localisation trouvé en ligne 5 (à partir de B5).")
|
||
return result
|
||
|
||
# --- Colonne A à partir de la ligne 6 : codes de templates ---
|
||
template_codes = [] # [(row_index, code)]
|
||
for row in range(6, ws.max_row + 1):
|
||
val = cell_value(ws, row, 1)
|
||
if val is not None and str(val).strip():
|
||
template_codes.append((row, str(val).strip()))
|
||
|
||
if not template_codes:
|
||
result.errors.append("Aucun code de template trouvé en colonne A (à partir de A6).")
|
||
return result
|
||
|
||
_log(f"Localisations : {len(location_codes)} | Templates : {len(template_codes)}")
|
||
|
||
# --- Pré-chargement ---
|
||
all_loc_codes = [c for _, c in location_codes]
|
||
all_tmpl_codes = [c for _, c in template_codes]
|
||
|
||
locations_map = {
|
||
loc.code: loc
|
||
for loc in LocationModel.objects.filter(code__in=all_loc_codes)
|
||
}
|
||
templates_map = {
|
||
t.code: t
|
||
for t in OperationTemplate.objects.prefetch_related(
|
||
'items', 'categories', 'asset_model_filters'
|
||
).filter(code__in=all_tmpl_codes)
|
||
}
|
||
|
||
for code in all_loc_codes:
|
||
if code not in locations_map:
|
||
result.warnings.append(f"Localisation '{code}' introuvable — ignorée.")
|
||
for code in all_tmpl_codes:
|
||
if code not in templates_map:
|
||
result.warnings.append(f"Template '{code}' introuvable — ignoré.")
|
||
|
||
# --- Parcours et création ---
|
||
now = timezone.now()
|
||
|
||
for row_idx, tmpl_code in template_codes:
|
||
template = templates_map.get(tmpl_code)
|
||
if template is None:
|
||
continue
|
||
|
||
templates_with_filters = _get_templates_with_equipment_filters([template])
|
||
|
||
for col_idx, loc_code in location_codes:
|
||
if not cell_is_checked(cell_value(ws, row_idx, col_idx)):
|
||
result.skipped += 1
|
||
continue
|
||
|
||
location = locations_map.get(loc_code)
|
||
if location is None:
|
||
result.skipped += 1
|
||
continue
|
||
|
||
_log(f" ✔ template='{tmpl_code}' × localisation='{loc_code}'")
|
||
|
||
if dry_run:
|
||
result.created += 1
|
||
continue
|
||
|
||
# --- Création de l'intervention ---
|
||
title = _build_title(location, template)
|
||
geom = _build_geom(location)
|
||
lon = getattr(location, 'lon', None)
|
||
lat = getattr(location, 'lat', None)
|
||
|
||
intervention = Intervention(
|
||
title=title,
|
||
status=status,
|
||
status_order=STATUS_ORDERS.get(status, 1),
|
||
contract=contract,
|
||
order=order,
|
||
assigned_provider=contract.company if contract else None,
|
||
maintain_type="preventive",
|
||
type="care",
|
||
created_by=user,
|
||
thematic=thematic,
|
||
location_code=getattr(location, 'code', None) or None,
|
||
geom=geom,
|
||
)
|
||
if lon is not None:
|
||
intervention.lon = lon
|
||
if lat is not None:
|
||
intervention.lat = lat
|
||
if expected_begin:
|
||
intervention.expected_begin_time = expected_begin
|
||
if expected_end:
|
||
intervention.expected_end_time = expected_end
|
||
|
||
intervention.save()
|
||
|
||
# Lien template d'opérations
|
||
intervention.equipment_operation_templates.add(template)
|
||
from interventions.views.helpers import _assign_team_from_templates, _assign_contract_posts_from_templates
|
||
_assign_team_from_templates(intervention, [template])
|
||
_assign_contract_posts_from_templates(intervention, [template])
|
||
|
||
# Timelines
|
||
InterventionTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=user,
|
||
event_time=now,
|
||
event_type='creation',
|
||
from_status=None,
|
||
to_status=intervention.status,
|
||
)
|
||
if expected_begin:
|
||
InterventionPlanificationTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=user,
|
||
event_type='expected_begin',
|
||
event_time=expected_begin,
|
||
event_description="Début prévisionnel",
|
||
)
|
||
if expected_end:
|
||
InterventionPlanificationTimeLine.objects.create(
|
||
intervention=intervention,
|
||
event_user=user,
|
||
event_type='expected_end',
|
||
event_time=expected_end,
|
||
event_description="Fin prévisionnelle",
|
||
)
|
||
|
||
# Lien localisation
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type=location_ct,
|
||
object_id=location.pk,
|
||
)
|
||
|
||
# --- Opérations génériques ---
|
||
_add_operations_from_template(
|
||
intervention, template, only_generic=True, log=log
|
||
)
|
||
|
||
# --- Opérations équipement (assets enfants de la localisation) ---
|
||
if templates_with_filters:
|
||
ct_cache = {}
|
||
for child_asset in _extract_assets_from_location(location):
|
||
asset_class = child_asset.__class__
|
||
if asset_class not in ct_cache:
|
||
ct_cache[asset_class] = ContentType.objects.get_for_model(asset_class)
|
||
ct_asset = ct_cache[asset_class]
|
||
|
||
applicable = [
|
||
t for t in templates_with_filters
|
||
if _asset_matches_template_filters(child_asset, t, allow_unfiltered=False)
|
||
]
|
||
if not applicable:
|
||
continue
|
||
|
||
InterventionAsset.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type=ct_asset,
|
||
object_id=child_asset.pk,
|
||
)
|
||
for t in applicable:
|
||
_add_operations_from_template(
|
||
intervention, t,
|
||
asset_obj=child_asset, asset_ct=ct_asset,
|
||
only_generic=False, log=log,
|
||
)
|
||
|
||
result.created += 1
|
||
|
||
return result
|