feat: enhance pending analysis report with thematic cross-tabulation and TCD-ready data structures

This commit is contained in:
kdeterme 2026-08-24 16:17:11 +02:00
parent f72fe1bfd7
commit cfdcc41929
2 changed files with 318 additions and 103 deletions

View file

@ -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."),

View file

@ -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)