""" 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) # 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