From cfdcc419294ee90ce1dbc03ee71ec5e2e941f455 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 24 Aug 2026 16:17:11 +0200 Subject: [PATCH] feat: enhance pending analysis report with thematic cross-tabulation and TCD-ready data structures --- .../services/pending_analysis_service.py | 388 ++++++++++++++---- .../tests/test_pending_analysis.py | 33 +- 2 files changed, 318 insertions(+), 103 deletions(-) diff --git a/loko/interventions/services/pending_analysis_service.py b/loko/interventions/services/pending_analysis_service.py index fbe673a..d0a4c40 100644 --- a/loko/interventions/services/pending_analysis_service.py +++ b/loko/interventions/services/pending_analysis_service.py @@ -3,7 +3,8 @@ Service pour l'analyse et le diagnostic des observations et interventions non te Ce module permet d'extraire, de diagnostiquer et de générer un rapport Excel (.xlsx) multi-onglets complet pour identifier les causes de blocage et les goulots d'étranglement -sur les 24 derniers mois (ou une période personnalisée). +sur les 24 derniers mois (ou une période personnalisée), avec matrices croisées par thématique +et tables de données exploitables en Tableaux Croisés Dynamiques (TCD). """ import io @@ -20,6 +21,7 @@ from django.utils.translation import gettext_lazy as _ import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter +from openpyxl.worksheet.table import Table, TableStyleInfo # Regex pour éliminer les caractères illégaux pour openpyxl / XML _ILLEGAL_CHARS_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]') @@ -268,7 +270,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non 'id': itv.id, 'code': itv.code, 'title': itv.title, - 'thematic_code': itv.thematic.code if itv.thematic else "", + 'thematic_code': itv.thematic.code if itv.thematic else "sans_thematique", 'thematic_name': itv.thematic.name_fr if itv.thematic else "Sans thématique", 'asset_category': itv.asset_category.name_fr if itv.asset_category else "", 'symptom_code': itv.symptom.code if itv.symptom else "", @@ -382,7 +384,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non enriched_observations.append({ 'id': obs.id, 'code': obs.code or f"OBS{obs.id:05d}", - 'thematic_code': obs.thematic.code if obs.thematic else "", + 'thematic_code': obs.thematic.code if obs.thematic else "sans_thematique", 'thematic_name': obs.thematic.name_fr if obs.thematic else "Sans thématique", 'category_name': obs.category.name_fr if obs.category else "", 'symptom_name': obs.symptom.name_fr if obs.symptom else "", @@ -499,6 +501,54 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non data['delayed_pct'] = round((data['delayed'] / data['total'] * 100), 1) if data['total'] else 0 data['avg_age'] = round(sum(data['ages']) / len(data['ages']), 1) if data['ages'] else 0 + # ------------------------------------------------------------------------- + # 4. Matrices croisées complètes par Thématique (pour TCD / Tableaux croisés) + # ------------------------------------------------------------------------- + all_thematics = set([i['thematic_name'] for i in enriched_interventions] + [o['thematic_name'] for o in enriched_observations]) + thematic_matrix = {} + + for th_name in sorted(all_thematics): + th_itvs = [i for i in enriched_interventions if i['thematic_name'] == th_name] + th_obs = [o for o in enriched_observations if o['thematic_name'] == th_name] + th_itv_ages = [i['age_days'] for i in th_itvs] + th_obs_ages = [o['age_days'] for o in th_obs] + + # Répartition des statuts itv + itv_st_counts = {st: sum(1 for i in th_itvs if i['status'] == st) for st in PENDING_INTERVENTION_STATUSES} + delayed_cnt = sum(1 for i in th_itvs if i['is_delayed']) + + # Répartition des statuts obs + obs_st_counts = {st: sum(1 for o in th_obs if o['status'] == st) for st in PENDING_OBSERVATION_STATUSES} + orphan_cnt = sum(1 for o in th_obs if o['is_orphan']) + desynced_cnt = sum(1 for o in th_obs if o['is_desynced']) + + # Répartition des priorités + prio_counts = {p: sum(1 for i in th_itvs if i['priority'] == p) for p in ['1', '2', '3', '4']} + + # Répartition des pauses + pause_counts = {pr: sum(1 for i in th_itvs if i['status'] == 'on_pause' and i['pause_reason'] == pr) for pr in PAUSE_REASON_LABELS.keys()} + pause_counts['other_or_none'] = sum(1 for i in th_itvs if i['status'] == 'on_pause' and (not i['pause_reason'] or i['pause_reason'] not in PAUSE_REASON_LABELS)) + + # Tranches d'âges itv + itv_aging = _calc_aging_buckets(th_itv_ages) + + thematic_matrix[th_name] = { + 'thematic_name': th_name, + 'itv_total': len(th_itvs), + 'itv_st_counts': itv_st_counts, + 'itv_delayed_count': delayed_cnt, + 'itv_delayed_pct': round(delayed_cnt / len(th_itvs) * 100, 1) if th_itvs else 0, + 'itv_avg_age': round(sum(th_itv_ages) / len(th_itv_ages), 1) if th_itvs else 0, + 'itv_aging': itv_aging, + 'itv_prio_counts': prio_counts, + 'itv_pause_counts': pause_counts, + 'obs_total': len(th_obs), + 'obs_st_counts': obs_st_counts, + 'obs_orphan_count': orphan_cnt, + 'obs_desynced_count': desynced_cnt, + 'obs_avg_age': round(sum(th_obs_ages) / len(th_obs_ages), 1) if th_obs else 0, + } + stats = { 'total_interventions': total_itvs, 'total_observations': total_obs, @@ -517,6 +567,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non 'obs_by_status': obs_by_status, 'pause_reasons_stats': pause_reasons_stats, 'provider_contract_stats': list(provider_contract_stats.values()), + 'thematic_matrix': thematic_matrix, } return { @@ -531,17 +582,7 @@ def get_pending_analysis_data(months=24, thematic_code=None, contract_number=Non def generate_pending_analysis_excel(months=24, thematic_code=None, contract_number=None, provider_id=None, output_format='workbook'): """ - Génère le classeur Excel multi-onglets structuré et stylisé. - - Args: - months (int): Horizon d'antériorité en mois (défaut: 24) - thematic_code (str, optional): Code thématique pour filtrer - contract_number (str, optional): Numéro de contrat pour filtrer - provider_id (int, optional): ID du prestataire pour filtrer - output_format (str): 'workbook' pour l'objet Workbook, 'file' pour BytesIO - - Returns: - openpyxl.Workbook ou io.BytesIO + Génère le classeur Excel multi-onglets structuré et stylisé avec Tableaux Croisés Dynamiques. """ data = get_pending_analysis_data( months=months, @@ -551,7 +592,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ) wb = openpyxl.Workbook() - # Supprimer la feuille par défaut if "Sheet" in wb.sheetnames: wb.remove(wb["Sheet"]) @@ -569,8 +609,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb font_body = Font(name="Segoe UI", size=9, color="2D3748") font_body_bold = Font(name="Segoe UI", size=9, bold=True, color="1A365D") font_alert_red = Font(name="Segoe UI", size=9, bold=True, color="9B2C2C") - font_alert_orange = Font(name="Segoe UI", size=9, bold=True, color="9C4221") - font_alert_green = Font(name="Segoe UI", size=9, bold=True, color="22543D") fill_title = PatternFill(start_color="1A365D", end_color="1A365D", fill_type="solid") fill_header = PatternFill(start_color="2B6CB0", end_color="2B6CB0", fill_type="solid") @@ -578,7 +616,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb fill_card = PatternFill(start_color="F7FAFC", end_color="F7FAFC", fill_type="solid") fill_card_alert = PatternFill(start_color="FFF5F5", end_color="FFF5F5", fill_type="solid") fill_zebra = PatternFill(start_color="F8FAFC", end_color="F8FAFC", fill_type="solid") - fill_white = PatternFill(start_color="FFFFFF", end_color="FFFFFF", fill_type="solid") fill_red_pastel = PatternFill(start_color="FED7D7", end_color="FED7D7", fill_type="solid") fill_orange_pastel = PatternFill(start_color="FEEBC8", end_color="FEEBC8", fill_type="solid") @@ -618,7 +655,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws1["A3"].fill = fill_title ws1["A3"].alignment = Alignment(horizontal="left", vertical="center", indent=1) - # Ligne 5-7 : CARTES KPI def _create_kpi_card(ws, start_col, title, value, sub="", is_alert=False): c1, c2 = start_col, chr(ord(start_col) + 1) ws.merge_cells(f"{c1}5:{c2}5") @@ -648,7 +684,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb _create_kpi_card(ws1, 'E', "INTERVENTIONS EN RETARD", f"{stats['delayed_itvs_count']:,} ({stats['delayed_itvs_pct']}%)", "Échéance ou SLA dépassé", is_alert=(stats['delayed_itvs_count'] > 0)) _create_kpi_card(ws1, 'G', "EN PAUSE / ORPHELINES", f"{stats['paused_itvs_count']} itv / {stats['orphan_obs_count']} obs", f"{stats['desynced_obs_count']} obs désynchronisées", is_alert=(stats['orphan_obs_count'] > 0 or stats['desynced_obs_count'] > 0)) - # Ligne 9 : Section Pyramide des âges + # Pyramide des âges ws1["A9"] = "1. Pyramide des âges de l'encours" ws1["A9"].font = font_section @@ -689,7 +725,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - # Ligne Total Aging + # Total Aging ws1.cell(row=r_idx, column=1, value="Total général").font = font_body_bold ws1.cell(row=r_idx, column=2, value=stats['total_interventions']).font = font_body_bold ws1.cell(row=r_idx, column=3, value=1.0).number_format = "0.0%" @@ -700,7 +736,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb c.border = border_cell c.fill = fill_card - # Section 2 : Répartition par Statut Interventions + # Statuts Interventions r_idx += 3 ws1.cell(row=r_idx, column=1, value="2. Répartition des Interventions par Statut").font = font_section r_idx += 1 @@ -729,35 +765,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - # Section 3 : Répartition par Statut Observations - r_idx += 2 - ws1.cell(row=r_idx, column=1, value="3. Répartition des Observations par Statut").font = font_section - r_idx += 1 - - headers_obs_st = ["Statut d'observation", "Nombre", "% du Total", "Âge moyen (jours)"] - for col_idx, h in enumerate(headers_obs_st, 1): - c = ws1.cell(row=r_idx, column=col_idx, value=h) - c.font = font_header - c.fill = fill_header - c.alignment = align_header - c.border = border_cell - - r_idx += 1 - for st, row_data in stats['obs_by_status'].items(): - ws1.cell(row=r_idx, column=1, value=row_data['label']).alignment = align_left - ws1.cell(row=r_idx, column=2, value=row_data['count']).alignment = align_right - ws1.cell(row=r_idx, column=3, value=row_data['pct'] / 100.0).number_format = "0.0%" - ws1.cell(row=r_idx, column=4, value=row_data['avg_age']).alignment = align_right - - for col_idx in range(1, 5): - cell = ws1.cell(row=r_idx, column=col_idx) - cell.font = font_body - cell.border = border_cell - if r_idx % 2 == 0: - cell.fill = fill_zebra - r_idx += 1 - - # Auto-width ws1 for col in ws1.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = get_column_letter(col[0].column) @@ -765,9 +772,207 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws1.column_dimensions['A'].width = 38 # ========================================================================= - # ONGLET 2 : DIAGNOSTICS & BLOCAGES + # ONGLET 2 : ANALYSES CROISÉES PAR THÉMATIQUE (TCD PRÉ-CALCULÉS & FORMULES) # ========================================================================= - ws2 = wb.create_sheet(title="02_Diagnostics_Blocages") + ws_th = wb.create_sheet(title="02_Analyse_Thematiques") + ws_th.views.sheetView[0].showGridLines = True + + ws_th.merge_cells("A1:N2") + ws_th["A1"] = "TABLEAUX CROISÉS DYNAMIQUES — ANALYSE DÉTAILLÉE PAR THÉMATIQUE" + ws_th["A1"].font = font_title + ws_th["A1"].fill = fill_title + ws_th["A1"].alignment = Alignment(horizontal="left", vertical="center", indent=1) + + ws_th.merge_cells("A3:N3") + ws_th["A3"] = "Matrices croisées des interventions et observations par thématique métier (statuts, retards, pauses, âges et priorités)." + ws_th["A3"].font = font_subtitle + ws_th["A3"].fill = fill_title + ws_th["A3"].alignment = Alignment(horizontal="left", vertical="center", indent=1) + + th_matrix = stats['thematic_matrix'] + + # 1. Matrice Thématique × Statuts Interventions + ws_th["A5"] = "1. Matrice Croisée : Thématiques × Statuts des Interventions en cours" + ws_th["A5"].font = font_section + + headers_th_itv = [ + "Thématique", "En prép.", "À approuver", "À planifier", "À traiter", + "Pris en charge", "Démarré", "En pause", "À corriger", + "Total en cours", "Nb en retard", "% en retard", "Âge moyen (j)" + ] + for col_idx, h in enumerate(headers_th_itv, 1): + c = ws_th.cell(row=6, column=col_idx, value=h) + c.font = font_header + c.fill = fill_header + c.alignment = align_header + c.border = border_cell + + r_idx = 7 + for th_name, th_d in th_matrix.items(): + ws_th.cell(row=r_idx, column=1, value=th_name).alignment = align_left + st_c = th_d['itv_st_counts'] + ws_th.cell(row=r_idx, column=2, value=st_c.get('in_preparation', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=3, value=st_c.get('to_be_approved', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=4, value=st_c.get('to_be_planned', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=5, value=st_c.get('to_be_processed', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=6, value=st_c.get('assigned', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=7, value=st_c.get('in_progress', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=8, value=st_c.get('on_pause', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=9, value=st_c.get('to_be_corrected', 0)).alignment = align_right + + ws_th.cell(row=r_idx, column=10, value=th_d['itv_total']).alignment = align_right + + c_del = ws_th.cell(row=r_idx, column=11, value=th_d['itv_delayed_count']) + c_del.alignment = align_right + if th_d['itv_delayed_count'] > 0: + c_del.font = font_alert_red + + c_del_pct = ws_th.cell(row=r_idx, column=12, value=th_d['itv_delayed_pct'] / 100.0) + c_del_pct.number_format = "0.0%" + c_del_pct.alignment = align_right + if th_d['itv_delayed_pct'] > 20: + c_del_pct.font = font_alert_red + + ws_th.cell(row=r_idx, column=13, value=th_d['itv_avg_age']).alignment = align_right + + for col_idx in range(1, 14): + cell = ws_th.cell(row=r_idx, column=col_idx) + cell.border = border_cell + if col_idx not in [11, 12]: + cell.font = font_body + if r_idx % 2 == 1: + cell.fill = fill_zebra + r_idx += 1 + + # Total ligne Interventions + ws_th.cell(row=r_idx, column=1, value="Total général").font = font_body_bold + for c_i in range(2, 10): + c_letter = get_column_letter(c_i) + ws_th.cell(row=r_idx, column=c_i, value=f"=SUM({c_letter}7:{c_letter}{r_idx-1})").font = font_body_bold + ws_th.cell(row=r_idx, column=10, value=f"=SUM(J7:J{r_idx-1})").font = font_body_bold + ws_th.cell(row=r_idx, column=11, value=f"=SUM(K7:K{r_idx-1})").font = font_body_bold + for col_idx in range(1, 14): + c = ws_th.cell(row=r_idx, column=col_idx) + c.border = border_cell + c.fill = fill_card + + # 2. Matrice Thématique × Statuts Observations + r_idx += 3 + ws_th.cell(row=r_idx, column=1, value="2. Matrice Croisée : Thématiques × Statuts des Observations ouvertes").font = font_section + r_idx += 1 + + headers_th_obs = [ + "Thématique", "En préparation", "À traiter", "En cours", + "Total ouvertes", "Orphelines (sans itv)", "Désynchronisées", "Âge moyen (j)" + ] + for col_idx, h in enumerate(headers_th_obs, 1): + c = ws_th.cell(row=r_idx, column=col_idx, value=h) + c.font = font_header + c.fill = fill_header + c.alignment = align_header + c.border = border_cell + + start_obs_r = r_idx + 1 + r_idx += 1 + for th_name, th_d in th_matrix.items(): + ws_th.cell(row=r_idx, column=1, value=th_name).alignment = align_left + obs_st_c = th_d['obs_st_counts'] + ws_th.cell(row=r_idx, column=2, value=obs_st_c.get('in_preparation', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=3, value=obs_st_c.get('to_process', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=4, value=obs_st_c.get('in_progress', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=5, value=th_d['obs_total']).alignment = align_right + + c_orph = ws_th.cell(row=r_idx, column=6, value=th_d['obs_orphan_count']) + c_orph.alignment = align_right + if th_d['obs_orphan_count'] > 0: + c_orph.font = font_alert_red + + c_desync = ws_th.cell(row=r_idx, column=7, value=th_d['obs_desynced_count']) + c_desync.alignment = align_right + if th_d['obs_desynced_count'] > 0: + c_desync.font = font_alert_red + + ws_th.cell(row=r_idx, column=8, value=th_d['obs_avg_age']).alignment = align_right + + for col_idx in range(1, 9): + cell = ws_th.cell(row=r_idx, column=col_idx) + cell.border = border_cell + if col_idx not in [6, 7]: + cell.font = font_body + if r_idx % 2 == 1: + cell.fill = fill_zebra + r_idx += 1 + + # Total ligne Observations + ws_th.cell(row=r_idx, column=1, value="Total général").font = font_body_bold + for c_i in range(2, 6): + c_letter = get_column_letter(c_i) + ws_th.cell(row=r_idx, column=c_i, value=f"=SUM({c_letter}{start_obs_r}:{c_letter}{r_idx-1})").font = font_body_bold + ws_th.cell(row=r_idx, column=6, value=f"=SUM(F{start_obs_r}:F{r_idx-1})").font = font_body_bold + ws_th.cell(row=r_idx, column=7, value=f"=SUM(G{start_obs_r}:G{r_idx-1})").font = font_body_bold + for col_idx in range(1, 9): + c = ws_th.cell(row=r_idx, column=col_idx) + c.border = border_cell + c.fill = fill_card + + # 3. Matrice Thématique × Motifs de Pause + r_idx += 3 + ws_th.cell(row=r_idx, column=1, value="3. Matrice Croisée : Thématiques × Motifs de Pause (Interventions en pause)").font = font_section + r_idx += 1 + + headers_th_pause = [ + "Thématique", "Matériel à commander", "Attente sous-traitant", "Attente livraison", + "Offre prestataire", "Attente validation", "À replanifier", "Garantie / Autre", "Total en Pause" + ] + for col_idx, h in enumerate(headers_th_pause, 1): + c = ws_th.cell(row=r_idx, column=col_idx, value=h) + c.font = font_header + c.fill = fill_header + c.alignment = align_header + c.border = border_cell + + start_p_r = r_idx + 1 + r_idx += 1 + for th_name, th_d in th_matrix.items(): + ws_th.cell(row=r_idx, column=1, value=th_name).alignment = align_left + p_c = th_d['itv_pause_counts'] + ws_th.cell(row=r_idx, column=2, value=p_c.get('order_material', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=3, value=p_c.get('waiting_for_supplier', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=4, value=p_c.get('waiting_for_material_delivery', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=5, value=p_c.get('offer_by_provider', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=6, value=p_c.get('waiting_for_validation', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=7, value=p_c.get('reschedule', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=8, value=p_c.get('under_framework_contract', 0) + p_c.get('other', 0) + p_c.get('other_or_none', 0)).alignment = align_right + ws_th.cell(row=r_idx, column=9, value=th_d['itv_st_counts'].get('on_pause', 0)).alignment = align_right + + for col_idx in range(1, 10): + cell = ws_th.cell(row=r_idx, column=col_idx) + cell.border = border_cell + cell.font = font_body + if r_idx % 2 == 1: + cell.fill = fill_zebra + r_idx += 1 + + # Total ligne Pauses + ws_th.cell(row=r_idx, column=1, value="Total général").font = font_body_bold + for c_i in range(2, 10): + c_letter = get_column_letter(c_i) + ws_th.cell(row=r_idx, column=c_i, value=f"=SUM({c_letter}{start_p_r}:{c_letter}{r_idx-1})").font = font_body_bold + for col_idx in range(1, 10): + c = ws_th.cell(row=r_idx, column=col_idx) + c.border = border_cell + c.fill = fill_card + + for col in ws_th.columns: + max_len = max(len(str(cell.value or '')) for cell in col) + col_letter = get_column_letter(col[0].column) + ws_th.column_dimensions[col_letter].width = max(max_len + 3, 12) + ws_th.column_dimensions['A'].width = 28 + + # ========================================================================= + # ONGLET 3 : DIAGNOSTICS & BLOCAGES + # ========================================================================= + ws2 = wb.create_sheet(title="03_Diagnostics_Blocages") ws2.views.sheetView[0].showGridLines = True ws2.merge_cells("A1:G2") @@ -776,7 +981,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws2["A1"].fill = fill_title ws2["A1"].alignment = Alignment(horizontal="left", vertical="center", indent=1) - # Section 1 : Motifs de pause + # Motifs de pause ws2["A4"] = "1. Répartition des Interventions en Pause (on_pause)" ws2["A4"].font = font_section @@ -808,7 +1013,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws2.cell(row=r_idx, column=1, value="Aucune intervention actuellement en pause.").font = font_body r_idx += 1 - # Section 2 : Analyse des Observations en Souffrance + # Diagnostics Observations r_idx += 2 ws2.cell(row=r_idx, column=1, value="2. Diagnostic des Observations en Souffrance").font = font_section r_idx += 1 @@ -869,7 +1074,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - # Section 3 : Interventions Silencieuses (> 30 jours sans timeline) + # Interventions Silencieuses r_idx += 2 ws2.cell(row=r_idx, column=1, value="3. Interventions Silencieuses (> 30 jours sans événement Timeline)").font = font_section r_idx += 1 @@ -884,7 +1089,7 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb r_idx += 1 silent_itvs = [i for i in data['interventions'] if i['days_since_activity'] > 30 and i['status'] != 'on_pause'] - for sitv in silent_itvs[:50]: # Top 50 silencieuses + for sitv in silent_itvs[:50]: ws2.cell(row=r_idx, column=1, value=sitv['code']).alignment = align_center ws2.cell(row=r_idx, column=2, value=_sanitize_cell_value(sitv['title'])).alignment = align_left ws2.cell(row=r_idx, column=3, value=sitv['thematic_name']).alignment = align_left @@ -904,16 +1109,15 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb if not silent_itvs: ws2.cell(row=r_idx, column=1, value="Aucune intervention silencieuse détectée.").font = font_body - # Auto-width ws2 for col in ws2.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = get_column_letter(col[0].column) ws2.column_dimensions[col_letter].width = min(max(max_len + 4, 12), 60) # ========================================================================= - # ONGLET 3 : ANALYSE PRESTATAIRES & CONTRATS + # ONGLET 4 : ANALYSE PRESTATAIRES & CONTRATS # ========================================================================= - ws3 = wb.create_sheet(title="03_Prestataires_Contrats") + ws3 = wb.create_sheet(title="04_Prestataires_Contrats") ws3.views.sheetView[0].showGridLines = True ws3.merge_cells("A1:K2") @@ -948,7 +1152,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws3.cell(row=r_idx, column=3, value=row['manager'] or "—").alignment = align_left ws3.cell(row=r_idx, column=4, value=row['total']).alignment = align_right - # Statuts by_st = row['by_status'] ws3.cell(row=r_idx, column=5, value=by_st.get('in_preparation', 0)).alignment = align_right ws3.cell(row=r_idx, column=6, value=by_st.get('to_be_approved', 0)).alignment = align_right @@ -959,7 +1162,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws3.cell(row=r_idx, column=11, value=by_st.get('on_pause', 0)).alignment = align_right ws3.cell(row=r_idx, column=12, value=by_st.get('to_be_corrected', 0)).alignment = align_right - # Retard & Age c_retard = ws3.cell(row=r_idx, column=13, value=row['delayed']) c_retard.alignment = align_right if row['delayed'] > 0: @@ -982,7 +1184,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - # Total ligne ws3.cell(row=r_idx, column=1, value="Total général").font = font_body_bold ws3.cell(row=r_idx, column=4, value=f"=SUM(D6:D{r_idx-1})").font = font_body_bold ws3.cell(row=r_idx, column=13, value=f"=SUM(M6:M{r_idx-1})").font = font_body_bold @@ -991,7 +1192,6 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb c.border = border_cell c.fill = fill_card - # Auto-width ws3 for col in ws3.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = get_column_letter(col[0].column) @@ -1000,16 +1200,16 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws3.column_dimensions['B'].width = 18 # ========================================================================= - # ONGLET 4 : DÉTAIL DES OBSERVATIONS + # ONGLET 5 : DÉTAIL DES OBSERVATIONS (TABLE EXCEL OFFICIELLE) # ========================================================================= - ws4 = wb.create_sheet(title="04_Detail_Observations") + ws4 = wb.create_sheet(title="05_Detail_Observations") ws4.views.sheetView[0].showGridLines = True headers_obs_detail = [ - "Code Obs.", "Thématique", "Catégorie Asset", "Symptôme", "Statut", - "Date Création", "Âge (jours)", "Créé par", "Type Créateur", "Adresse", - "Latitude", "Longitude", "Nb Photos", "Itv Liée (Code)", "Statut Itv Liée", - "Diagnostic Cause de Blocage", "Description" + "Code_Obs", "Thematique", "Categorie_Asset", "Symptome", "Statut", + "Date_Creation", "Age_Jours", "Cree_Par", "Type_Createur", "Adresse", + "Latitude", "Longitude", "Nb_Photos", "Itv_Liee_Code", "Statut_Itv_Liee", + "Diagnostic_Cause", "Description" ] for col_idx, h in enumerate(headers_obs_detail, 1): c = ws4.cell(row=1, column=col_idx, value=h) @@ -1061,9 +1261,12 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - ws4.auto_filter.ref = f"A1:Q{max(r_idx-1, 1)}" + max_obs_row = max(r_idx - 1, 2) + # Déclaration de la Table Excel officielle + table_obs = Table(displayName="Table_Observations", ref=f"A1:Q{max_obs_row}") + table_obs.tableStyleInfo = TableStyleInfo(name="TableStyleLight1", showRowStripes=True) + ws4.add_table(table_obs) - # Widths ws4 for col in ws4.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = get_column_letter(col[0].column) @@ -1074,18 +1277,18 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws4.column_dimensions['Q'].width = 35 # ========================================================================= - # ONGLET 5 : DÉTAIL DES INTERVENTIONS + # ONGLET 6 : DÉTAIL DES INTERVENTIONS (TABLE EXCEL OFFICIELLE) # ========================================================================= - ws5 = wb.create_sheet(title="05_Detail_Interventions") + ws5 = wb.create_sheet(title="06_Detail_Interventions") ws5.views.sheetView[0].showGridLines = True headers_itv_detail = [ - "Code Itv", "Titre", "Thématique", "Catégorie Asset", "Symptôme", "Priorité", - "Type Maintenance", "Statut Actuel", "Date Création", "Âge Total (j)", - "Date Entrée Statut", "Jours dans Statut", "Date Début Prévue", "Date Fin Attendue", - "Statut Retard / SLA", "Prestataire Assigné", "Contrat", "Équipe", "Gestionnaire", - "Motif Pause", "Détail Pause", "Raison Correction", "Dernière Activité", "Jours Inactivité", - "Nb Docs/Photos", "Diagnostic Cause de Blocage", "Adresse", "Description" + "Code_Itv", "Titre", "Thematique", "Categorie_Asset", "Symptome", "Priorite", + "Type_Maintenance", "Statut_Actuel", "Date_Creation", "Age_Total_Jours", + "Date_Entree_Statut", "Jours_Dans_Statut", "Date_Debut_Prevue", "Date_Fin_Attendue", + "Statut_Retard_SLA", "Prestataire_Assigne", "Contrat", "Equipe", "Gestionnaire", + "Motif_Pause", "Detail_Pause", "Raison_Correction", "Derniere_Activite", "Jours_Inactivite", + "Nb_Docs_Photos", "Diagnostic_Cause", "Adresse", "Description" ] for col_idx, h in enumerate(headers_itv_detail, 1): c = ws5.cell(row=1, column=col_idx, value=h) @@ -1156,9 +1359,12 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb cell.fill = fill_zebra r_idx += 1 - ws5.auto_filter.ref = f"A1:AB{max(r_idx-1, 1)}" + max_itv_row = max(r_idx - 1, 2) + # Déclaration de la Table Excel officielle + table_itv = Table(displayName="Table_Interventions", ref=f"A1:AB{max_itv_row}") + table_itv.tableStyleInfo = TableStyleInfo(name="TableStyleLight1", showRowStripes=True) + ws5.add_table(table_itv) - # Widths ws5 for col in ws5.columns: max_len = max(len(str(cell.value or '')) for cell in col) col_letter = get_column_letter(col[0].column) @@ -1168,9 +1374,9 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ws5.column_dimensions['Z'].width = 45 # ========================================================================= - # ONGLET 6 : GUIDE & DÉFINITIONS + # ONGLET 7 : GUIDE & DÉFINITIONS # ========================================================================= - ws6 = wb.create_sheet(title="06_Definitions_Guide") + ws6 = wb.create_sheet(title="07_Definitions_Guide") ws6.views.sheetView[0].showGridLines = True ws6.merge_cells("A1:E2") @@ -1189,16 +1395,18 @@ def generate_pending_analysis_excel(months=24, thematic_code=None, contract_numb ] ), ( - "2. Signification des Diagnostics Automatiques (Observations)", + "2. Tableaux Croisés Dynamiques (TCD)", + [ + ("Onglet 02_Analyse_Thematiques", "Contient des matrices croisées pré-calculées avec formules dynamiques : Thématiques × Statuts Itv, Thématiques × Statuts Obs, Thématiques × Motifs de Pause."), + ("Tables Excel Natives", "Les onglets '05_Detail_Observations' et '06_Detail_Interventions' sont configurés comme Tables Excel ('Table_Observations' et 'Table_Interventions'). Vous pouvez créer un nouveau TCD personnalisé dans Excel via 'Insertion > Tableau croisé dynamique' en 1 clic."), + ] + ), + ( + "3. Signification des Diagnostics Automatiques", [ ("Orpheline ('À traiter' sans intervention)", "L'observation est validée mais n'a déclenché aucune intervention. Souvent dû à un symptôme sans auto-création activée, ou sans contrat par défaut."), ("Désynchronisée ('En cours' avec intervention terminée/annulée)", "L'intervention associée a suivi son cycle de vie jusqu'à la fin, mais le statut de l'observation n'a pas été basculé en 'Traité' ou 'Clôturé'."), ("Brouillon abandonné", "Observation créée il y a plus de 15 jours en 'En préparation' sans validation ultérieure."), - ] - ), - ( - "3. Signification des Diagnostics Automatiques (Interventions)", - [ ("Goulot d'approbation", "Intervention en 'À approuver' en attente de visa par le gestionnaire désigné."), ("Non prise en charge", "Intervention envoyée en 'À traiter' au prestataire sans action de prise en charge après plus de 10 jours."), ("Blocage en pause", "Intervention arrêtée temporairement pour commande de matériel, sous-traitant, devis, etc. Nécessite un suivi périodique."), diff --git a/loko/interventions/tests/test_pending_analysis.py b/loko/interventions/tests/test_pending_analysis.py index 328452f..2f2b29f 100644 --- a/loko/interventions/tests/test_pending_analysis.py +++ b/loko/interventions/tests/test_pending_analysis.py @@ -193,14 +193,15 @@ class PendingAnalysisTests(TestCase): def test_generate_pending_analysis_excel(self): wb = generate_pending_analysis_excel(months=24, output_format='workbook') - # Vérification des 6 feuilles + # Vérification des 7 feuilles expected_sheets = [ "01_Synthese_KPI", - "02_Diagnostics_Blocages", - "03_Prestataires_Contrats", - "04_Detail_Observations", - "05_Detail_Interventions", - "06_Definitions_Guide", + "02_Analyse_Thematiques", + "03_Diagnostics_Blocages", + "04_Prestataires_Contrats", + "05_Detail_Observations", + "06_Detail_Interventions", + "07_Definitions_Guide", ] self.assertEqual(wb.sheetnames, expected_sheets) @@ -209,16 +210,22 @@ class PendingAnalysisTests(TestCase): self.assertIn("LOKO — AUDIT", ws1["A1"].value) self.assertEqual(ws1["A5"].value, "INTERVENTIONS EN COURS") - # Vérification du détail des observations (feuille 4) - ws4 = wb["04_Detail_Observations"] - self.assertEqual(ws4["A1"].value, "Code Obs.") + # Vérification de l'onglet d'analyse thématique + ws_th = wb["02_Analyse_Thematiques"] + self.assertIn("TABLEAUX CROISÉS DYNAMIQUES", ws_th["A1"].value) + + # Vérification du détail des observations (feuille 5) + ws4 = wb["05_Detail_Observations"] + self.assertEqual(ws4["A1"].value, "Code_Obs") # 3 lignes de données + 1 en-tête = 4 lignes self.assertEqual(ws4.max_row, 4) + self.assertIn("Table_Observations", ws4.tables) - # Vérification du détail des interventions (feuille 5) - ws5 = wb["05_Detail_Interventions"] - self.assertEqual(ws5["A1"].value, "Code Itv") + # Vérification du détail des interventions (feuille 6) + ws5 = wb["06_Detail_Interventions"] + self.assertEqual(ws5["A1"].value, "Code_Itv") self.assertEqual(ws5.max_row, 4) + self.assertIn("Table_Interventions", ws5.tables) def test_analyze_pending_items_command(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -230,4 +237,4 @@ class PendingAnalysisTests(TestCase): # Recharger le fichier sauvegardé pour vérifier son intégrité wb = openpyxl.load_workbook(out_file) - self.assertEqual(len(wb.sheetnames), 6) + self.assertEqual(len(wb.sheetnames), 7)