andreacalcagni commited on
Commit
e4bb5a2
Β·
1 Parent(s): ae3c1d3

Initial upload: CSV to PowerPoint generator with LFS for binary files

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. README.md +68 -6
  3. app.py +651 -0
  4. requirements.txt +16 -0
  5. src/generator.py +1687 -0
  6. template.pptx +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.pptx filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,75 @@
1
  ---
2
- title: Csv Powerpoint Generator
3
- emoji: 🐠
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 5.34.2
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CSV to PowerPoint Generator
3
+ emoji: 🏭
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 4.44.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
+ python_version: 3.10
12
  ---
13
 
14
+ # 🏭 CSV to PowerPoint Generator
15
+
16
+ Generate branded PowerPoint presentations from manufacturing CSV data with automated KPI calculations.
17
+
18
+ ## πŸš€ Features
19
+
20
+ - **CSV Upload**: Support for large manufacturing datasets (up to 200MB)
21
+ - **Automated KPI Calculation**: Calculate efficiency metrics for Stampaggio, Stampaggio Surlyn, and Decorazioni departments
22
+ - **PowerPoint Generation**: Create professional reports using branded templates
23
+ - **User Authentication**: Secure access with username/password authentication
24
+ - **Multi-language Support**: Italian interface optimized for manufacturing workflows
25
+
26
+ ## πŸ” Authentication
27
+
28
+ This Space uses username/password authentication to control access. Users need valid credentials to access the application.
29
+
30
+ ## πŸ“Š Supported Data
31
+
32
+ The application processes manufacturing data with the following requirements:
33
+
34
+ - **Format**: CSV files with UTF-8 or ISO-8859-1 encoding
35
+ - **Departments**: ST (Stampaggio), MS (Stampaggio Surlyn), DC (Decorazioni)
36
+ - **Data Structure**: Compatible with "Dati_Grezzi" export format
37
+ - **KPIs Calculated**: EFF_REP, EFF_PRO, EFF_SC, EFF_E, OEE
38
+
39
+ ## πŸ› οΈ How to Use
40
+
41
+ 1. Upload your CSV file containing manufacturing data
42
+ 2. Select the month and year for KPI calculation
43
+ 3. Click "Generate PowerPoint" to create the presentation
44
+ 4. Download the generated PowerPoint file
45
+
46
+ ## πŸ“ˆ KPI Targets
47
+
48
+ ### Stampaggio (ST)
49
+ - EFF_REP: > 91%
50
+ - EFF_PRO: > 94%
51
+ - EFF_SC: > 98.5%
52
+ - EFF_E: > 93%
53
+ - OEE: > 80%
54
+
55
+ ### Stampaggio Surlyn (MS)
56
+ - EFF_REP: > 93%
57
+ - EFF_PRO: > 95%
58
+ - EFF_SC: > 96%
59
+ - EFF_E: > 92%
60
+ - OEE: > 85%
61
+
62
+ ### Decorazioni (DC)
63
+ - EFF_REP: > 84%
64
+ - EFF_PRO: > 87%
65
+ - EFF_SC: > 96.5%
66
+ - EFF_E: > 91%
67
+ - OEE: > 80%
68
+
69
+ ## πŸ”§ Technical Details
70
+
71
+ - **Framework**: Gradio 4.44.0
72
+ - **Python**: 3.10
73
+ - **Libraries**: pandas, python-pptx, gradio
74
+ - **Template**: Uses `template.pptx` for branded presentations
75
+ - **Security**: Environment-based authentication with HF Secrets
app.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio Web Interface for Manufacturing KPI PowerPoint Generator
3
+
4
+ This provides a user-friendly web interface for generating PowerPoint presentations
5
+ from CSV manufacturing data without requiring command-line usage.
6
+
7
+ Main features:
8
+ - CSV file upload with validation
9
+ - Month/year selection via dropdowns
10
+ - One-click PowerPoint generation
11
+ - Download functionality for generated presentations
12
+ - Progress feedback and error handling
13
+ - Authentication support via Hugging Face Secrets
14
+ """
15
+
16
+ import os
17
+ import tempfile
18
+ import calendar
19
+ from pathlib import Path
20
+ from typing import Optional, Tuple
21
+
22
+ import gradio as gr
23
+ import pandas as pd
24
+
25
+ from src.generator import make_ppt, validate_ppt_output, load_csv_with_encoding_fallback, validate_csv_schema
26
+
27
+
28
+ # =============================================================================
29
+ # πŸ”§ CONFIGURATION
30
+ # =============================================================================
31
+
32
+ # File size limit (200MB to handle large manufacturing datasets)
33
+ MAX_FILE_SIZE_MB = 200
34
+ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
35
+
36
+ # Year range for dropdown (extended range for historical and future data)
37
+ CURRENT_YEAR = 2024
38
+ YEAR_RANGE = list(range(2020, 2041)) # 2020-2040
39
+
40
+ # Month names for dropdown (Italian as per the system)
41
+ MONTH_NAMES = [
42
+ "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno",
43
+ "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"
44
+ ]
45
+
46
+ # Create month choices for dropdown (display name -> month number)
47
+ MONTH_CHOICES = [(f"{i}. {name}", i) for i, name in enumerate(MONTH_NAMES, 1)]
48
+
49
+
50
+ # =============================================================================
51
+ # πŸ” AUTHENTICATION
52
+ # =============================================================================
53
+
54
+ def get_auth_credentials() -> Optional[list]:
55
+ """
56
+ Get authentication credentials from Hugging Face Secrets.
57
+
58
+ Supports both single user (APP_USERNAME/APP_PASSWORD) and multiple users (HF_APP_USERS_JSON).
59
+
60
+ Returns:
61
+ List of (username, password) tuples if configured, None otherwise
62
+ """
63
+ try:
64
+ # Try to get multiple users from JSON first
65
+ users_json = os.getenv("HF_APP_USERS_JSON")
66
+ if users_json:
67
+ import json
68
+ try:
69
+ users_data = json.loads(users_json)
70
+ auth_list = []
71
+
72
+ # Support both dict format {"user1": "pass1", "user2": "pass2"}
73
+ # and list format [{"username": "user1", "password": "pass1"}, ...]
74
+ if isinstance(users_data, dict):
75
+ auth_list = [(username, password) for username, password in users_data.items()]
76
+ elif isinstance(users_data, list):
77
+ auth_list = [(user["username"], user["password"]) for user in users_data]
78
+ else:
79
+ raise ValueError("HF_APP_USERS_JSON must be a dict or list")
80
+
81
+ if auth_list:
82
+ print(f"πŸ” Loaded {len(auth_list)} user(s) from HF_APP_USERS_JSON")
83
+ return auth_list
84
+ else:
85
+ print("⚠️ HF_APP_USERS_JSON is empty")
86
+
87
+ except json.JSONDecodeError as e:
88
+ print(f"❌ Invalid JSON in HF_APP_USERS_JSON: {e}")
89
+ except (KeyError, TypeError) as e:
90
+ print(f"❌ Invalid format in HF_APP_USERS_JSON: {e}")
91
+
92
+ # Fallback to single user credentials (backward compatibility)
93
+ username = os.getenv("APP_USERNAME")
94
+ password = os.getenv("APP_PASSWORD")
95
+
96
+ if username and password:
97
+ print("πŸ” Loaded single user from APP_USERNAME/APP_PASSWORD")
98
+ return [(username, password)]
99
+
100
+ # No authentication configured
101
+ print("ℹ️ No authentication configured - app will be public")
102
+ print("ℹ️ To enable auth, set HF_APP_USERS_JSON or APP_USERNAME/APP_PASSWORD in HF Secrets")
103
+ return None
104
+
105
+ except Exception as e:
106
+ print(f"⚠️ Error loading auth credentials: {e}")
107
+ return None
108
+
109
+
110
+ def validate_auth_setup() -> bool:
111
+ """
112
+ Validate that authentication is properly configured.
113
+
114
+ Returns:
115
+ True if auth is properly set up, False otherwise
116
+ """
117
+ auth_creds = get_auth_credentials()
118
+ if not auth_creds:
119
+ return False
120
+
121
+ # Validate each credential pair
122
+ for username, password in auth_creds:
123
+ if not username or not password:
124
+ print(f"❌ Invalid credential pair: username='{username}', password={'*' * len(password) if password else 'None'}")
125
+ return False
126
+ if len(username.strip()) == 0 or len(password.strip()) == 0:
127
+ print(f"❌ Empty username or password detected")
128
+ return False
129
+
130
+ return True
131
+
132
+
133
+ # =============================================================================
134
+ # πŸ“ FILE VALIDATION
135
+ # =============================================================================
136
+
137
+ def validate_uploaded_file(file_obj) -> Tuple[bool, str]:
138
+ """
139
+ Validate uploaded CSV file with enhanced error messages.
140
+
141
+ Args:
142
+ file_obj: Gradio file upload object
143
+
144
+ Returns:
145
+ Tuple of (is_valid, error_message)
146
+ """
147
+ if file_obj is None:
148
+ return False, "⚠️ Nessun file caricato"
149
+
150
+ try:
151
+ # Get file info for better error messages
152
+ file_path = file_obj.name
153
+ file_name = os.path.basename(file_path)
154
+ file_extension = os.path.splitext(file_name)[1].lower()
155
+
156
+ # Enhanced file extension validation
157
+ if not file_extension:
158
+ return False, f"❌ File '{file_name}' non ha estensione. Richiesto: file .csv"
159
+
160
+ if file_extension != '.csv':
161
+ supported_types = ['.csv']
162
+ return False, (f"❌ Tipo file non supportato: '{file_extension}'\n"
163
+ f"Tipi supportati: {', '.join(supported_types)}\n"
164
+ f"Rinomina il file con estensione .csv")
165
+
166
+ # Check if file exists and get size
167
+ if not os.path.exists(file_path):
168
+ return False, f"❌ File '{file_name}' non trovato"
169
+
170
+ file_size = os.path.getsize(file_path)
171
+ file_size_mb = file_size / (1024 * 1024)
172
+
173
+ # Enhanced file size validation
174
+ if file_size == 0:
175
+ return False, f"❌ File '{file_name}' è vuoto (0 bytes)"
176
+
177
+ if file_size > MAX_FILE_SIZE_BYTES:
178
+ return False, (f"❌ File troppo grande: {file_size_mb:.1f}MB\n"
179
+ f"Massimo consentito: {MAX_FILE_SIZE_MB}MB\n"
180
+ f"Riduci le dimensioni del file e riprova")
181
+
182
+ # Try to load and validate CSV structure with enhanced error handling
183
+ try:
184
+ df = load_csv_with_encoding_fallback(file_path)
185
+ except Exception as load_error:
186
+ return False, (f"❌ Impossibile leggere il file CSV '{file_name}'\n"
187
+ f"Verifica che sia un file CSV valido\n"
188
+ f"Errore: {str(load_error)}")
189
+
190
+ try:
191
+ validate_csv_schema(df)
192
+ except Exception as schema_error:
193
+ return False, (f"❌ Struttura CSV non valida in '{file_name}'\n"
194
+ f"Il file deve contenere le colonne richieste per i dati di produzione\n"
195
+ f"Errore: {str(schema_error)}")
196
+
197
+ # Enhanced data validation
198
+ if len(df) == 0:
199
+ return False, f"❌ File '{file_name}' non contiene dati (solo intestazioni)"
200
+
201
+ # Enhanced department validation
202
+ available_repartos = df['REPARTO'].unique() if 'REPARTO' in df.columns else []
203
+ required_repartos = ['ST', 'MS', 'DC'] # As per generator.py
204
+ found_repartos = [r for r in required_repartos if r in available_repartos]
205
+
206
+ if not found_repartos:
207
+ available_list = ', '.join(available_repartos) if len(available_repartos) > 0 else 'nessuno'
208
+ return False, (f"❌ Nessun reparto valido trovato in '{file_name}'\n"
209
+ f"Reparti richiesti: {', '.join(required_repartos)}\n"
210
+ f"Reparti trovati: {available_list}")
211
+
212
+ # Success message with detailed info
213
+ print(f"βœ… File CSV validato: {file_name} - {len(df):,} righe, reparti: {', '.join(found_repartos)}")
214
+ return True, (f"βœ… File valido: '{file_name}'\n"
215
+ f"πŸ“Š {len(df):,} righe di dati\n"
216
+ f"🏭 Reparti: {', '.join(found_repartos)}\n"
217
+ f"πŸ’Ύ Dimensione: {file_size_mb:.1f}MB")
218
+
219
+ except Exception as e:
220
+ return False, (f"❌ Errore imprevisto durante la validazione di '{file_name if 'file_name' in locals() else 'file'}'\n"
221
+ f"Dettagli: {str(e)}\n"
222
+ f"Contatta il supporto se il problema persiste")
223
+
224
+
225
+ # =============================================================================
226
+ # 🎯 MAIN PROCESSING FUNCTION
227
+ # =============================================================================
228
+
229
+ def generate_presentation(csv_file, month_choice, year_choice, progress=gr.Progress()) -> Tuple[str, str]:
230
+ """
231
+ Generate PowerPoint presentation from uploaded CSV.
232
+
233
+ Args:
234
+ csv_file: Gradio file upload object
235
+ month_choice: Selected month (1-12)
236
+ year_choice: Selected year
237
+ progress: Gradio progress tracker
238
+
239
+ Returns:
240
+ Tuple of (download_path, status_message)
241
+ """
242
+ try:
243
+ progress(0, desc="Validazione file in corso...")
244
+
245
+ # Validate inputs
246
+ if csv_file is None:
247
+ raise gr.Error("❌ Nessun file CSV caricato")
248
+
249
+ if month_choice is None or year_choice is None:
250
+ raise gr.Error("❌ Seleziona mese e anno")
251
+
252
+ # Validate file
253
+ is_valid, message = validate_uploaded_file(csv_file)
254
+ if not is_valid:
255
+ raise gr.Error(f"❌ {message}")
256
+
257
+ progress(20, desc="File validato, generazione PowerPoint in corso...")
258
+
259
+ # Generate PowerPoint using the existing function
260
+ try:
261
+ output_path = make_ppt(csv_file.name, month_choice, year_choice)
262
+ progress(80, desc="PowerPoint generato, validazione finale...")
263
+
264
+ # Validate generated file
265
+ if not validate_ppt_output(output_path):
266
+ raise gr.Error("❌ Errore durante la validazione del PowerPoint generato")
267
+
268
+ progress(100, desc="Completato!")
269
+
270
+ # Success message
271
+ month_name = MONTH_NAMES[month_choice - 1]
272
+ status_msg = f"βœ… PowerPoint generato con successo per {month_name} {year_choice}!"
273
+
274
+ return output_path, status_msg
275
+
276
+ except Exception as e:
277
+ error_msg = str(e)
278
+ if "No data found for reparto" in error_msg:
279
+ raise gr.Error("❌ Nessun dato trovato per i reparti richiesti nel file CSV")
280
+ elif "Template not found" in error_msg:
281
+ raise gr.Error("❌ Template PowerPoint non trovato. Contatta l'amministratore.")
282
+ else:
283
+ raise gr.Error(f"❌ Errore durante la generazione: {error_msg}")
284
+
285
+ except gr.Error:
286
+ # Re-raise Gradio errors as-is
287
+ raise
288
+ except Exception as e:
289
+ # Catch any other unexpected errors
290
+ raise gr.Error(f"❌ Errore imprevisto: {str(e)}")
291
+
292
+
293
+ # =============================================================================
294
+ # 🎨 GRADIO INTERFACE
295
+ # =============================================================================
296
+
297
+ def create_interface() -> gr.Blocks:
298
+ """
299
+ Create and configure the Gradio interface.
300
+
301
+ Returns:
302
+ Configured Gradio Blocks interface
303
+ """
304
+
305
+ with gr.Blocks(
306
+ title="CSV β†’ PowerPoint Generator",
307
+ theme=gr.themes.Soft(),
308
+ css="""
309
+ .gradio-container {
310
+ max-width: 800px !important;
311
+ margin: auto !important;
312
+ }
313
+ .title-text {
314
+ text-align: center;
315
+ color: #2c3e50;
316
+ margin-bottom: 2rem;
317
+ }
318
+ .info-box {
319
+ background-color: #e8f4fd;
320
+ border: 1px solid #bee5eb;
321
+ border-radius: 8px;
322
+ padding: 1rem;
323
+ margin: 1rem 0;
324
+ }
325
+ .upload-box {
326
+ border: 2px dashed #007acc;
327
+ border-radius: 10px;
328
+ padding: 2rem;
329
+ text-align: center;
330
+ background-color: #f8f9fa;
331
+ }
332
+ """
333
+ ) as interface:
334
+
335
+ # Header
336
+ gr.HTML("""
337
+ <div class="title-text">
338
+ <h1>🏭 Generatore Report Operativo</h1>
339
+ <p>Carica un file CSV e genera automaticamente il PowerPoint con i KPI di produzione</p>
340
+ </div>
341
+ """)
342
+
343
+ # Instructions
344
+ with gr.Accordion("πŸ“‹ Istruzioni d'uso", open=False):
345
+ gr.Markdown("""
346
+ ### Come utilizzare questo strumento:
347
+
348
+ 1. **Carica il file CSV** con i dati di produzione (massimo 200MB)
349
+ 2. **Seleziona il mese** fino al quale calcolare i KPI
350
+ 3. **Seleziona l'anno** di riferimento
351
+ 4. **Clicca "Genera PowerPoint"** e attendi il completamento
352
+ 5. **Scarica il file** usando il pulsante di download
353
+
354
+ ### Formato file richiesto:
355
+ - File CSV con encoding UTF-8 o ISO-8859-1
356
+ - Deve contenere i dati dei reparti: ST (Stampaggio), MS (Stampaggio Surlyn), DC (Decorazioni)
357
+ - Struttura colonne compatibile con export "Dati_Grezzi"
358
+ """)
359
+
360
+ with gr.Row():
361
+ with gr.Column(scale=2):
362
+ # File upload
363
+ csv_input = gr.File(
364
+ label="πŸ“ Carica File CSV",
365
+ file_types=[".csv"],
366
+ file_count="single",
367
+ elem_classes="upload-box"
368
+ )
369
+
370
+ # File validation feedback
371
+ file_status = gr.Textbox(
372
+ label="Stato File",
373
+ interactive=False,
374
+ visible=False
375
+ )
376
+
377
+ with gr.Column(scale=1):
378
+ # Month selection
379
+ month_input = gr.Dropdown(
380
+ choices=MONTH_CHOICES,
381
+ label="πŸ“… Mese",
382
+ info="Seleziona il mese fino al quale calcolare i KPI",
383
+ value=None,
384
+ interactive=True
385
+ )
386
+
387
+ # Year selection
388
+ year_input = gr.Dropdown(
389
+ choices=YEAR_RANGE,
390
+ label="πŸ“… Anno",
391
+ info="Anno di riferimento per il report (2020-2040)",
392
+ value=CURRENT_YEAR,
393
+ interactive=True
394
+ )
395
+
396
+ # Generate button
397
+ generate_btn = gr.Button(
398
+ "πŸš€ Genera PowerPoint",
399
+ variant="primary",
400
+ size="lg",
401
+ interactive=False
402
+ )
403
+
404
+ # Status and download section
405
+ with gr.Column():
406
+ status_output = gr.Textbox(
407
+ label="Stato Generazione",
408
+ interactive=False,
409
+ visible=False
410
+ )
411
+
412
+ download_file = gr.DownloadButton(
413
+ "πŸ’Ύ Scarica PowerPoint",
414
+ visible=False,
415
+ variant="secondary"
416
+ )
417
+
418
+ # =============================================================================
419
+ # πŸ”— EVENT HANDLERS
420
+ # =============================================================================
421
+
422
+ def update_ui_state(csv_file, month, year):
423
+ """Update UI state based on inputs with enhanced validation feedback."""
424
+ # Initialize state variables
425
+ file_valid = False
426
+ file_msg = ""
427
+ button_interactive = False
428
+
429
+ # Validate file if uploaded
430
+ if csv_file is not None:
431
+ file_valid, file_msg = validate_uploaded_file(csv_file)
432
+ else:
433
+ file_msg = "⚠️ Carica un file CSV per iniziare"
434
+
435
+ # Check if all required inputs are provided
436
+ missing_inputs = []
437
+ if csv_file is None:
438
+ missing_inputs.append("file CSV")
439
+ if month is None:
440
+ missing_inputs.append("mese")
441
+ if year is None:
442
+ missing_inputs.append("anno")
443
+
444
+ # Only enable button if all inputs are valid
445
+ if csv_file is not None and month is not None and year is not None and file_valid:
446
+ button_interactive = True
447
+ else:
448
+ button_interactive = False
449
+
450
+ # Create helpful button tooltip based on what's missing
451
+ if not button_interactive:
452
+ if missing_inputs:
453
+ button_title = f"Completa i campi mancanti: {', '.join(missing_inputs)}"
454
+ elif not file_valid:
455
+ button_title = "Correggi gli errori del file prima di continuare"
456
+ else:
457
+ button_title = "Completa tutti i campi richiesti"
458
+ else:
459
+ button_title = "Genera PowerPoint con i dati forniti"
460
+
461
+ return {
462
+ generate_btn: gr.update(
463
+ interactive=button_interactive,
464
+ variant="primary" if button_interactive else "secondary"
465
+ ),
466
+ file_status: gr.update(
467
+ value=file_msg,
468
+ visible=csv_file is not None or len(missing_inputs) > 0,
469
+ label="βœ… File Valido" if file_valid and csv_file is not None else
470
+ "❌ Errore File" if csv_file is not None else
471
+ "πŸ“‹ Stato Validazione"
472
+ )
473
+ }
474
+
475
+ def on_generate_click(csv_file, month, year):
476
+ """Handle generate button click with enhanced error feedback."""
477
+ try:
478
+ # Final validation before generation
479
+ if csv_file is None:
480
+ gr.Error("Carica un file CSV prima di generare il PowerPoint")
481
+ return {
482
+ status_output: gr.update(value="❌ Nessun file CSV caricato", visible=True),
483
+ download_file: gr.update(visible=False)
484
+ }
485
+
486
+ if month is None:
487
+ gr.Error("Seleziona un mese prima di generare il PowerPoint")
488
+ return {
489
+ status_output: gr.update(value="❌ Seleziona un mese", visible=True),
490
+ download_file: gr.update(visible=False)
491
+ }
492
+
493
+ if year is None:
494
+ gr.Error("Seleziona un anno prima di generare il PowerPoint")
495
+ return {
496
+ status_output: gr.update(value="❌ Seleziona un anno", visible=True),
497
+ download_file: gr.update(visible=False)
498
+ }
499
+
500
+ # Generate presentation
501
+ ppt_path, status_msg = generate_presentation(csv_file, month, year)
502
+
503
+ # Show success notification
504
+ gr.Info(f"PowerPoint generato con successo per {MONTH_NAMES[month-1]} {year}!")
505
+
506
+ return {
507
+ status_output: gr.update(value=status_msg, visible=True),
508
+ download_file: gr.update(visible=True, value=ppt_path)
509
+ }
510
+
511
+ except gr.Error as ge:
512
+ # Gradio errors are already properly formatted
513
+ error_msg = str(ge).replace("❌ ", "") # Remove duplicate error prefix
514
+ return {
515
+ status_output: gr.update(value=f"❌ {error_msg}", visible=True),
516
+ download_file: gr.update(visible=False)
517
+ }
518
+ except Exception as e:
519
+ # Unexpected errors
520
+ error_msg = str(e)
521
+ gr.Error(f"Errore imprevisto: {error_msg}")
522
+ return {
523
+ status_output: gr.update(value=f"❌ Errore imprevisto: {error_msg}", visible=True),
524
+ download_file: gr.update(visible=False)
525
+ }
526
+
527
+ def handle_file_upload(file):
528
+ """Handle immediate file upload validation for better UX."""
529
+ if file is None:
530
+ return {
531
+ file_status: gr.update(
532
+ value="⚠️ Carica un file CSV per iniziare",
533
+ visible=True,
534
+ label="πŸ“‹ Stato File"
535
+ )
536
+ }
537
+
538
+ # Quick file type validation for immediate feedback
539
+ file_name = os.path.basename(file.name)
540
+ file_extension = os.path.splitext(file_name)[1].lower()
541
+
542
+ if file_extension != '.csv':
543
+ error_msg = (f"❌ Tipo file non supportato: '{file_extension}'\n"
544
+ f"⚠️ Solo file .csv sono accettati\n"
545
+ f"Seleziona un file CSV valido")
546
+
547
+ # Show error toast for unsupported file types
548
+ gr.Warning(f"Tipo file non supportato: {file_extension}. Solo file CSV (.csv) sono accettati.")
549
+
550
+ return {
551
+ file_status: gr.update(
552
+ value=error_msg,
553
+ visible=True,
554
+ label="❌ Errore File"
555
+ )
556
+ }
557
+
558
+ # If CSV, proceed with full validation
559
+ is_valid, message = validate_uploaded_file(file)
560
+
561
+ if not is_valid:
562
+ # Show error toast for validation failures
563
+ gr.Warning(f"Errore file CSV: {message.split(chr(10))[0]}") # First line of error
564
+
565
+ return {
566
+ file_status: gr.update(
567
+ value=message,
568
+ visible=True,
569
+ label="βœ… File Valido" if is_valid else "❌ Errore File"
570
+ )
571
+ }
572
+
573
+ # Wire up events with enhanced file handling
574
+ csv_input.upload(
575
+ fn=handle_file_upload,
576
+ inputs=[csv_input],
577
+ outputs=[file_status],
578
+ show_progress="hidden" # Hide progress for instant feedback
579
+ )
580
+
581
+ for input_component in [csv_input, month_input, year_input]:
582
+ input_component.change(
583
+ fn=update_ui_state,
584
+ inputs=[csv_input, month_input, year_input],
585
+ outputs=[generate_btn, file_status]
586
+ )
587
+
588
+ generate_btn.click(
589
+ fn=on_generate_click,
590
+ inputs=[csv_input, month_input, year_input],
591
+ outputs=[status_output, download_file]
592
+ )
593
+
594
+ # Footer
595
+ gr.HTML("""
596
+ <div style="text-align: center; margin-top: 2rem; color: #6c757d; font-size: 0.9em;">
597
+ <p>πŸ”’ Applicazione sicura per la generazione di report KPI di produzione</p>
598
+ </div>
599
+ """)
600
+
601
+ return interface
602
+
603
+
604
+ # =============================================================================
605
+ # πŸš€ APPLICATION LAUNCH
606
+ # =============================================================================
607
+
608
+ def main():
609
+ """Launch the Gradio application."""
610
+ print("πŸš€ Avvio applicazione Gradio...")
611
+ print("=" * 60)
612
+
613
+ # Get and validate authentication credentials
614
+ auth_creds = get_auth_credentials()
615
+
616
+ # Create interface
617
+ app = create_interface()
618
+
619
+ # Launch configuration
620
+ launch_kwargs = {
621
+ "server_name": "0.0.0.0", # Listen on all interfaces for HF Spaces
622
+ "server_port": 7860, # Standard port for HF Spaces
623
+ "show_error": True, # Show detailed errors
624
+ "share": False, # Don't create public share link
625
+ "inbrowser": False, # Don't auto-open browser (for deployment)
626
+ }
627
+
628
+ # Add authentication if configured
629
+ if auth_creds and validate_auth_setup():
630
+ launch_kwargs["auth"] = auth_creds
631
+ print(f"πŸ” Autenticazione attivata per {len(auth_creds)} utente(i)")
632
+ print("πŸ“‹ Utenti configurati:")
633
+ for i, (username, _) in enumerate(auth_creds, 1):
634
+ print(f" {i}. {username}")
635
+ else:
636
+ print("🌍 Applicazione pubblica (nessuna autenticazione)")
637
+ print("⚠️ ATTENZIONE: L'app è accessibile a chiunque conosca l'URL")
638
+
639
+ print("=" * 60)
640
+
641
+ # Launch the app
642
+ try:
643
+ print("🌐 Avvio server Gradio...")
644
+ app.launch(**launch_kwargs)
645
+ except Exception as e:
646
+ print(f"❌ Errore durante l'avvio: {e}")
647
+ raise
648
+
649
+
650
+ if __name__ == "__main__":
651
+ main()
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core data processing and manipulation
2
+ pandas>=1.5.0,<3.0.0
3
+ numpy>=1.21.0,<2.0.0
4
+
5
+ # Visualization and charting
6
+ matplotlib>=3.5.0,<4.0.0
7
+ seaborn>=0.11.0,<1.0.0
8
+
9
+ # PowerPoint creation
10
+ python-pptx>=0.6.21,<1.0.0
11
+
12
+ # XML processing (used by python-pptx)
13
+ lxml>=4.6.0,<6.0.0
14
+
15
+ # Web interface
16
+ gradio>=4.0.0,<5.0.0
src/generator.py ADDED
@@ -0,0 +1,1687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PowerPoint Generation Module for Manufacturing KPI Reports
3
+
4
+ This module extracts the core logic from Briva_v6.ipynb notebook to generate
5
+ PowerPoint presentations from CSV manufacturing data.
6
+
7
+ Main function: make_ppt(csv_path, reparto_code) -> str (path to generated .pptx)
8
+ """
9
+
10
+ import os
11
+ import locale
12
+ import tempfile
13
+ import calendar
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Tuple, Union
16
+
17
+ import pandas as pd
18
+ import numpy as np
19
+ import matplotlib.pyplot as plt
20
+ import seaborn as sns
21
+ from pptx import Presentation
22
+ from pptx.util import Inches, Pt
23
+ from pptx.enum.text import PP_ALIGN, MSO_VERTICAL_ANCHOR
24
+ from pptx.enum.shapes import PP_PLACEHOLDER
25
+ from pptx.dml.color import RGBColor
26
+ from pptx.enum.dml import MSO_LINE_DASH_STYLE
27
+ from lxml import etree
28
+ from pptx.oxml.ns import qn
29
+
30
+ # =============================================================================
31
+ # πŸ“‹ CONSTANTS AND CONFIGURATION
32
+ # =============================================================================
33
+
34
+ # Expected CSV columns for validation
35
+ EXPECTED_COLUMNS = [
36
+ 'ANNO', 'MESE', 'MESE_DESCR', 'GIORNO', 'SETT', 'ORE_MESE', 'N_MACCH',
37
+ 'DATA_DA_ORE6_A_6', 'TURNO', 'REPARTO', 'MACCHINA', 'BOLLA', 'FASE',
38
+ 'H_SCHED', 'H_PROG', 'N_PZ', 'QTSCA', 'QTNC', 'H_LAV', 'CAU_FER',
39
+ 'H_FER', 'CAU_ATT', 'OREATT', 'CAU_CAMP', 'ORECAMP', 'CONT_LAV',
40
+ 'IMPRONTE_LAV', 'H_DISP', 'REPARTO_DESCR', 'CAU_F_DESCR', 'H_FER_PRO',
41
+ 'C11FER', 'C20FER', 'ARTICOLO', 'DESCRIZIONE', 'STAMPO', 'IMPT', 'CT',
42
+ 'H_ATT_T', 'H_CAMP_T', 'TLAVTEOR', 'BATTUTE', 'CICLOR', 'PGP',
43
+ 'H_FER_T', 'H_FER_MICRO', 'H_PIAN', 'H_DISP_KPI', 'H_PROG_PIAN',
44
+ 'NPZ_NODICH', 'DATA_AGG'
45
+ ]
46
+
47
+ # Target KPI map per reparto (values in percentage)
48
+ TARGET_KPI_MAP = {
49
+ 'ST': { # STAMPAGGIO
50
+ 'EFF_REP': 91.0,
51
+ 'EFF_PRO': 94.0,
52
+ 'EFF_SC': 98.5,
53
+ 'EFF_E': 93.0,
54
+ 'OEE': 80.0
55
+ },
56
+ 'MS': { # STAMPAGGIO SURLYN
57
+ 'EFF_REP': 93.0,
58
+ 'EFF_PRO': 95.0,
59
+ 'EFF_SC': 96.0,
60
+ 'EFF_E': 92.0,
61
+ 'OEE': 85.0
62
+ },
63
+ 'DC': { # DECORAZIONI
64
+ 'EFF_REP': 84.0,
65
+ 'EFF_PRO': 87.0,
66
+ 'EFF_SC': 96.5,
67
+ 'EFF_E': 91.0,
68
+ 'OEE': 80.0
69
+ },
70
+ 'AS': { # ASSEMBLAGGIO
71
+ 'EFF_REP': 91.0,
72
+ 'EFF_PRO': 94.0,
73
+ 'EFF_SC': 98.5,
74
+ 'EFF_E': 93.0,
75
+ 'OEE': 80.0
76
+ },
77
+ 'IS': { # INIEZIONE SOFFIAGGIO
78
+ 'EFF_REP': 91.0,
79
+ 'EFF_PRO': 94.0,
80
+ 'EFF_SC': 98.5,
81
+ 'EFF_E': 93.0,
82
+ 'OEE': 80.0
83
+ },
84
+ 'PS': { # PRODUZIONE SCOVOLI
85
+ 'EFF_REP': 91.0,
86
+ 'EFF_PRO': 94.0,
87
+ 'EFF_SC': 98.5,
88
+ 'EFF_E': 93.0,
89
+ 'OEE': 80.0
90
+ }
91
+ }
92
+
93
+ # Reparto descriptions
94
+ REPARTO_DESCRIPTIONS = {
95
+ 'AS': 'ASSEMBLAGGIO',
96
+ 'DC': 'DECORAZIONI',
97
+ 'IS': 'INIEZIONE SOFFIAGGIO',
98
+ 'MS': 'STAMPAGGIO SURLYN',
99
+ 'PS': 'PRODUZIONE SCOVOLI',
100
+ 'ST': 'STAMPAGGIO'
101
+ }
102
+
103
+ # Corporate colors for charts
104
+ CHART_COLORS = {
105
+ 'previous_year': '#00AEEF', # Corporate blue
106
+ 'target': '#E6E2E0', # Light taupe
107
+ 'selected_year': '#4D4D4F' # Dark grey
108
+ }
109
+
110
+ # Set Italian locale for month names (with fallback)
111
+ try:
112
+ locale.setlocale(locale.LC_TIME, 'it_IT.UTF-8')
113
+ except locale.Error:
114
+ try:
115
+ locale.setlocale(locale.LC_TIME, 'it_IT')
116
+ except locale.Error:
117
+ pass # Use default locale
118
+
119
+
120
+ # =============================================================================
121
+ # πŸ“Š DATA LOADING AND VALIDATION
122
+ # =============================================================================
123
+
124
+ def load_csv_with_encoding_fallback(csv_path: str) -> pd.DataFrame:
125
+ """
126
+ Load CSV with robust encoding detection and fallback.
127
+
128
+ Args:
129
+ csv_path: Path to the CSV file
130
+
131
+ Returns:
132
+ Loaded DataFrame
133
+
134
+ Raises:
135
+ ValueError: If CSV cannot be loaded with any encoding
136
+ """
137
+ encodings_to_try = ['utf-8', 'iso-8859-1', 'cp1252', 'latin1']
138
+
139
+ for encoding in encodings_to_try:
140
+ try:
141
+ df = pd.read_csv(csv_path, encoding=encoding)
142
+ print(f"βœ… Successfully loaded CSV with encoding: {encoding}")
143
+ return df
144
+ except UnicodeDecodeError:
145
+ continue
146
+ except Exception as e:
147
+ print(f"❌ Error with {encoding}: {e}")
148
+ break
149
+
150
+ raise ValueError(f"❌ Failed to load CSV with any encoding: {csv_path}")
151
+
152
+
153
+ def validate_csv_schema(df: pd.DataFrame) -> None:
154
+ """
155
+ Validate that the DataFrame has all expected columns.
156
+
157
+ Args:
158
+ df: DataFrame to validate
159
+
160
+ Raises:
161
+ ValueError: If required columns are missing
162
+ """
163
+ missing_cols = set(EXPECTED_COLUMNS) - set(df.columns)
164
+ if missing_cols:
165
+ raise ValueError(f"Missing required columns: {missing_cols}")
166
+
167
+ print(f"βœ… All expected columns present. Shape: {df.shape}")
168
+
169
+
170
+ def prepare_dataframe(df: pd.DataFrame) -> pd.DataFrame:
171
+ """
172
+ Clean and prepare the DataFrame for KPI calculations.
173
+
174
+ Args:
175
+ df: Raw DataFrame
176
+
177
+ Returns:
178
+ Cleaned DataFrame
179
+ """
180
+ df = df.copy()
181
+
182
+ # Handle missing values and data type conversions
183
+ df['GIORNO'] = df['GIORNO'].fillna(0).astype(int)
184
+ df['SETT'] = df['SETT'].fillna(0).astype(int)
185
+ df['DATA_DA_ORE6_A_6'] = pd.to_datetime(df['DATA_DA_ORE6_A_6'], errors='coerce')
186
+ df['TURNO'] = df['TURNO'].fillna(0).astype(int)
187
+ df['BOLLA'] = df['BOLLA'].astype(object)
188
+ df['MESE_DESCR'] = df['MESE_DESCR'].str.strip()
189
+
190
+ print("βœ… DataFrame prepared successfully")
191
+ return df
192
+
193
+
194
+ # =============================================================================
195
+ # πŸ“ˆ KPI CALCULATION FUNCTIONS
196
+ # =============================================================================
197
+
198
+ def analyze_reparto_kpi(df: pd.DataFrame, reparto_code: str) -> pd.DataFrame:
199
+ """
200
+ Filter and analyze KPI data for a specific reparto.
201
+
202
+ Args:
203
+ df: Raw DataFrame
204
+ reparto_code: Department code (e.g., 'ST', 'MS', 'DC')
205
+
206
+ Returns:
207
+ Filtered DataFrame for the specified reparto
208
+
209
+ Raises:
210
+ ValueError: If no data found for the reparto
211
+ """
212
+ # Filter by reparto
213
+ df_filtered = df[df['REPARTO'] == reparto_code].copy()
214
+
215
+ if df_filtered.empty:
216
+ raise ValueError(f"No data found for reparto: {reparto_code}")
217
+
218
+ print(f"πŸ“Š Dati {REPARTO_DESCRIPTIONS.get(reparto_code, reparto_code)} ({reparto_code}): {len(df_filtered):,} righe")
219
+ print(f"πŸ“… Range anni: {df_filtered['ANNO'].min()} - {df_filtered['ANNO'].max()}")
220
+ print(f"πŸ“… Range mesi: {df_filtered['MESE'].min()} - {df_filtered['MESE'].max()}")
221
+ print(f"🏭 Macchine coinvolte: {df_filtered['MACCHINA'].nunique()}")
222
+ print(f"πŸ“‹ Articoli prodotti: {df_filtered['ARTICOLO'].nunique()}")
223
+
224
+ return df_filtered
225
+
226
+
227
+ def calcola_kpi(group: pd.DataFrame) -> pd.Series:
228
+ """
229
+ Calculate KPIs for a group of data according to official Brivaplast formulas.
230
+
231
+ Args:
232
+ group: DataFrame group (typically from groupby operation)
233
+
234
+ Returns:
235
+ Series with calculated KPIs
236
+ """
237
+ # Sum the necessary columns
238
+ h_disp = group['H_DISP'].sum()
239
+ h_sched = group['H_SCHED'].sum()
240
+ h_prog = group['H_PROG'].sum()
241
+ h_lav = group['H_LAV'].sum()
242
+ h_fer = group['H_FER'].sum()
243
+ oreatt = group['OREATT'].sum() # Real setup hours
244
+ orecamp = group['ORECAMP'].sum() # Real sampling hours
245
+ h_att_t = group['H_ATT_T'].sum() # Theoretical setup hours
246
+ h_camp_t = group['H_CAMP_T'].sum() # Theoretical sampling hours
247
+ n_pz = group['N_PZ'].sum()
248
+ qtsca = group['QTSCA'].sum() # Scraps
249
+ qtnc = group['QTNC'].sum() # Non-conformities
250
+ npz_nodich = group['NPZ_NODICH'].sum() # Undeclared pieces
251
+ pgp = group['PGP'].sum()
252
+ c11fer = group['C11FER'].sum()
253
+ c20fer = group['C20FER'].sum()
254
+
255
+ # Calculate KPIs according to official formulas
256
+ sfrutt = h_sched / h_disp if h_disp > 0 else 0 # SFRUTT = H_SCHED / H_DISP
257
+ eff_rep = h_lav / (h_sched - oreatt - orecamp) if (h_sched - oreatt - orecamp) > 0 else 0
258
+ eff_pro = h_lav / (h_prog - (c11fer + c20fer)) if (h_prog - (c11fer + c20fer)) > 0 else 0
259
+ eff_sc = (n_pz - qtnc) / (n_pz + qtsca) if (n_pz + qtsca) > 0 else 0
260
+ eff_e = n_pz / pgp if pgp > 0 else 0
261
+ oee = ((h_lav / h_disp) * eff_sc * (n_pz + qtsca) / pgp) if h_disp > 0 and pgp > 0 else 0
262
+
263
+ # Calculate non-conformity percentage
264
+ perc_nc = (qtnc / n_pz) * 100 if n_pz > 0 else 0
265
+
266
+ return pd.Series({
267
+ # Hours
268
+ '_H_DISP': h_disp,
269
+ '_H_SCHED': h_sched,
270
+ '_H_PROG': h_prog,
271
+ '_H_LAV': h_lav,
272
+ '_H_FER': h_fer,
273
+ '_H_ATT_T': h_att_t,
274
+ '_H_ATTR': oreatt,
275
+ '_H_CAMP_T': h_camp_t,
276
+ '_H_CAMP': orecamp,
277
+
278
+ # Main KPIs
279
+ '_SFRUTT': sfrutt,
280
+ '_EFF_REP': eff_rep,
281
+ '_EFF_PRO': eff_pro,
282
+ '_EFF_SC': eff_sc,
283
+ '_EFF_E': eff_e,
284
+ 'OEE': oee,
285
+
286
+ # Pieces
287
+ '_N_PZ': n_pz,
288
+ '_N_SC': qtsca,
289
+ '_PZ_ND': npz_nodich,
290
+ 'N_NC': qtnc,
291
+ '_%NC': perc_nc,
292
+
293
+ # KPIs for compatibility with existing code
294
+ 'H_SCHED': h_sched,
295
+ 'N_PZ': n_pz,
296
+ 'N_SC': qtsca,
297
+ 'EFF_REP': eff_rep,
298
+ 'EFF_PRO': eff_pro,
299
+ 'EFF_SC': eff_sc,
300
+ 'EFF_E': eff_e,
301
+ 'N_pz_tot_h': (n_pz + qtsca) / h_sched if h_sched > 0 else 0,
302
+ 'N_pz_ok_h': n_pz / h_sched if h_sched > 0 else 0
303
+ })
304
+
305
+
306
+ def create_df_eff_reparto_ytd(df: pd.DataFrame, selected_month: int,
307
+ selected_year: int, reparto_code: str) -> pd.DataFrame:
308
+ """
309
+ Creates df_eff_reparto_ytd with YTD metrics up to selected month
310
+ (matches notebook implementation exactly)
311
+
312
+ Args:
313
+ df: Raw data DataFrame
314
+ selected_month: Current month (1-12) - YTD will be calculated up to this month
315
+ selected_year: Current year
316
+ reparto_code: Department code (default 'ST' for STAMPAGGIO)
317
+
318
+ Returns:
319
+ DataFrame with YTD efficiency metrics for current and previous year + YoY deltas
320
+ """
321
+ # Filter data for selected reparto
322
+ df_reparto = df[df['REPARTO'] == reparto_code].copy()
323
+
324
+ # Calculate previous year
325
+ previous_year = selected_year - 1
326
+
327
+ # Calculate YTD metrics for both years
328
+ ytd_results = []
329
+
330
+ for year in [previous_year, selected_year]:
331
+ # Filter data for YTD period (January to selected_month)
332
+ df_ytd = df_reparto[
333
+ (df_reparto['ANNO'] == year) &
334
+ (df_reparto['MESE'] <= selected_month)
335
+ ].copy()
336
+
337
+ if not df_ytd.empty:
338
+ # Calculate YTD KPIs using the same calcola_kpi function
339
+ ytd_kpi = calcola_kpi(df_ytd)
340
+
341
+ # Create period identifier
342
+ period = pd.Period(f"{year}-{selected_month:02d}", freq='M')
343
+
344
+ ytd_results.append({
345
+ 'Period': period,
346
+ 'Year': int(year),
347
+ 'YTD_Month': int(selected_month),
348
+ **ytd_kpi
349
+ })
350
+
351
+ # Create DataFrame with Period index
352
+ if not ytd_results:
353
+ return pd.DataFrame()
354
+
355
+ df_ytd_result = pd.DataFrame(ytd_results)
356
+ df_ytd_result.set_index('Period', inplace=True)
357
+
358
+ # Convert KPIs to percentages and round to 1 decimal
359
+ efficiency_cols = ['EFF_REP', 'EFF_PRO', 'EFF_SC', 'EFF_E', 'OEE']
360
+ for col in efficiency_cols:
361
+ if col in df_ytd_result.columns:
362
+ df_ytd_result[col] = round(df_ytd_result[col] * 100, 1)
363
+
364
+ # Initialize YoY delta columns
365
+ delta_cols = [f'{col}_YoY_Delta' for col in efficiency_cols]
366
+ for col in delta_cols:
367
+ df_ytd_result[col] = np.nan
368
+
369
+ # Calculate Year-over-Year deltas if we have both years
370
+ if len(df_ytd_result) == 2:
371
+ # Sort by year to ensure proper order
372
+ df_ytd_result = df_ytd_result.sort_values('Year')
373
+
374
+ # Get previous and current year data
375
+ prev_year_data = df_ytd_result.iloc[0]
376
+ curr_year_data = df_ytd_result.iloc[1]
377
+
378
+ # Calculate YoY deltas for each KPI
379
+ for col in efficiency_cols:
380
+ if col in df_ytd_result.columns:
381
+ prev_value = prev_year_data[col]
382
+ curr_value = curr_year_data[col]
383
+
384
+ if prev_value != 0:
385
+ delta = ((curr_value - prev_value) / prev_value) * 100
386
+ # Set delta only for current year row
387
+ df_ytd_result.iloc[1, df_ytd_result.columns.get_loc(f'{col}_YoY_Delta')] = round(delta, 1)
388
+
389
+ # Add descriptive labels - fix the formatting issue (matches notebook exactly)
390
+ df_ytd_result['YTD_Label'] = df_ytd_result.apply(
391
+ lambda row: f"YTD {int(row['Year'])} (Gen-{int(row['YTD_Month']):02d})", axis=1
392
+ )
393
+
394
+ # Reorder columns: descriptive info first, then KPIs, then deltas
395
+ info_cols = ['Year', 'YTD_Month', 'YTD_Label']
396
+ column_order = info_cols + efficiency_cols + delta_cols
397
+ available_cols = [col for col in column_order if col in df_ytd_result.columns]
398
+ df_ytd_result = df_ytd_result[available_cols]
399
+
400
+ return df_ytd_result
401
+
402
+
403
+ # =============================================================================
404
+ # πŸ“Š CHART BUILDING FUNCTIONS
405
+ # =============================================================================
406
+
407
+ def build_eff_chart(df: pd.DataFrame, selected_month: int, selected_year: int,
408
+ reparto_code: str) -> plt.Figure:
409
+ """
410
+ Builds a seaborn bar chart comparing selected_year YTD KPIs with previous year and targets.
411
+ (matches notebook implementation exactly)
412
+
413
+ Args:
414
+ df: Raw data DataFrame (Dati_Grezzi)
415
+ selected_month: Month up to which to calculate YTD (1-12)
416
+ selected_year: Year for comparison
417
+ reparto_code: Department code (e.g., 'ST' for Stampaggio)
418
+
419
+ Returns:
420
+ matplotlib.figure.Figure: The created figure object
421
+ """
422
+ import matplotlib.pyplot as plt
423
+ import seaborn as sns
424
+ import pandas as pd
425
+ import numpy as np
426
+ from datetime import datetime
427
+ import locale
428
+
429
+ # Set Italian locale for month names (fallback to default if not available)
430
+ try:
431
+ locale.setlocale(locale.LC_TIME, 'it_IT.UTF-8')
432
+ except:
433
+ try:
434
+ locale.setlocale(locale.LC_TIME, 'it_IT')
435
+ except:
436
+ pass # Use default locale
437
+
438
+ # Get target map from TARGET_KPI_MAP based on reparto_code
439
+ target_map = TARGET_KPI_MAP.get(reparto_code, {})
440
+ if not target_map:
441
+ print(f"⚠️ No target KPIs defined for reparto '{reparto_code}'. Using default values of 0.")
442
+ target_map = {
443
+ 'EFF_REP': 0.0,
444
+ 'EFF_PRO': 0.0,
445
+ 'EFF_SC': 0.0,
446
+ 'EFF_E': 0.0,
447
+ 'OEE': 0.0
448
+ }
449
+
450
+ # Generate YTD data using the existing function
451
+ df_ytd_result = create_df_eff_reparto_ytd(df, selected_month, selected_year, reparto_code)
452
+
453
+ if df_ytd_result.empty:
454
+ print(f"❌ No YTD data available for {reparto_code} up to month {selected_month} of {selected_year}")
455
+ return None
456
+
457
+ # Corporate color palette (matches notebook COLORS exactly)
458
+ COLORS = {
459
+ 'previous_year': '#00AEEF', # Corporate blue
460
+ 'target': '#E6E2E0', # Light taupe
461
+ 'selected_year': '#4D4D4F' # Dark grey
462
+ }
463
+
464
+ # KPI order for x-axis
465
+ kpi_order = ['EFF_REP', 'EFF_PRO', 'EFF_SC', 'EFF_E', 'OEE']
466
+ kpi_labels = ['EFF_Rep', 'EFF_Pro', 'EFF_SC', 'EFF_E', 'OEE']
467
+
468
+ # Get department description
469
+ reparto_descriptions = {
470
+ 'ST': 'Stampaggio',
471
+ 'IS': 'Iniezione Soffiaggio',
472
+ 'AS': 'Assemblaggio',
473
+ 'PS': 'Produzione Scovoli',
474
+ 'MS': 'Stampaggio Surlyn',
475
+ 'DC': 'Decorazioni'
476
+ }
477
+ descrizione_reparto = reparto_descriptions.get(reparto_code, reparto_code)
478
+
479
+ # Prepare data for visualization
480
+ year_prev = selected_year - 1
481
+
482
+ # Extract KPI values from df_ytd_result
483
+ data_viz = []
484
+
485
+ for kpi in kpi_order:
486
+ # Get values for both years and target
487
+ try:
488
+ # Previous year
489
+ prev_year_row = df_ytd_result[df_ytd_result['Year'] == year_prev]
490
+ prev_year_val = prev_year_row[kpi].iloc[0] if len(prev_year_row) > 0 else 0
491
+ except (KeyError, IndexError):
492
+ prev_year_val = 0
493
+
494
+ try:
495
+ # Current year
496
+ curr_year_row = df_ytd_result[df_ytd_result['Year'] == selected_year]
497
+ curr_year_val = curr_year_row[kpi].iloc[0] if len(curr_year_row) > 0 else 0
498
+ except (KeyError, IndexError):
499
+ curr_year_val = 0
500
+
501
+ target_val = target_map.get(kpi, 0)
502
+
503
+ # Add data points for each bar
504
+ data_viz.extend([
505
+ {'KPI': kpi_labels[kpi_order.index(kpi)], 'Tipo': f'{year_prev}', 'Valore': prev_year_val, 'Color': 'previous_year'},
506
+ {'KPI': kpi_labels[kpi_order.index(kpi)], 'Tipo': 'Target', 'Valore': target_val, 'Color': 'target'},
507
+ {'KPI': kpi_labels[kpi_order.index(kpi)], 'Tipo': f'{selected_year}', 'Valore': curr_year_val, 'Color': 'selected_year'}
508
+ ])
509
+
510
+ df_viz = pd.DataFrame(data_viz)
511
+
512
+ # Create figure with exact dimensions (960x540 px at 100 DPI)
513
+ fig, ax = plt.subplots(figsize=(9.6, 5.4), dpi=100, facecolor='white')
514
+
515
+ # Create the bar plot
516
+ bar_plot = sns.barplot(
517
+ data=df_viz,
518
+ x='KPI',
519
+ y='Valore',
520
+ hue='Tipo',
521
+ order=kpi_labels,
522
+ hue_order=[f'{year_prev}', 'Target', f'{selected_year}'],
523
+ palette=[COLORS['previous_year'], COLORS['target'], COLORS['selected_year']],
524
+ ax=ax
525
+ )
526
+
527
+ # Customize the plot
528
+ # Title
529
+ mese_names = ['', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
530
+ 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre']
531
+ mese_nome = mese_names[selected_month] if selected_month <= 12 else f'Mese {selected_month}'
532
+
533
+ title = f"Efficienze {descrizione_reparto} Gennaio/{mese_nome} - {year_prev}/{selected_year}"
534
+ ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
535
+
536
+ # Y-axis configuration
537
+ ax.set_ylim(0, 100)
538
+ ax.set_ylabel('Efficienza (%)', fontsize=12)
539
+ ax.set_xlabel('')
540
+
541
+ # Grid lines at 25% intervals
542
+ ax.set_yticks([0, 25, 50, 75, 100])
543
+ ax.grid(True, axis='y', alpha=0.3, linestyle='-', linewidth=0.5)
544
+ ax.set_axisbelow(True)
545
+
546
+ # Format y-axis labels as percentages
547
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.0f}%'))
548
+
549
+ # Add data labels using helper function
550
+ _annotate_bars(ax, target_map, year_prev, selected_year, kpi_order)
551
+
552
+ # Legend configuration
553
+ legend = ax.legend(title='', loc='upper center', bbox_to_anchor=(0.5, -0.05),
554
+ ncol=3, frameon=False, fontsize=10)
555
+
556
+ # Adjust layout to prevent clipping
557
+ plt.tight_layout()
558
+
559
+ # Set transparent background if needed
560
+ fig.patch.set_alpha(1.0) # Change to 0.0 for transparent
561
+
562
+ return fig
563
+
564
+
565
+ def _annotate_bars(ax, target_map, year_prev, year_curr, kpi_order):
566
+ """
567
+ Annotate the bar-chart produced by `build_eff_chart()`.
568
+
569
+ β€’ Smart placement: puts the label above the bar unless it would overflow,
570
+ in which case it is drawn inside the bar.
571
+ β€’ Colour logic:
572
+ – Target bar β†’ label always black
573
+ – Prev/Cur Yr β†’ green if β‰₯ target, red otherwise
574
+ β€’ Contrast: labels drawn inside bars have a white stroke for readability.
575
+ β€’ Robustness: works even if the number of bars per KPI group β‰  3.
576
+
577
+ Parameters:
578
+ -----------
579
+ ax : matplotlib.axes.Axes
580
+ The axes object containing the bar chart
581
+ target_map : dict
582
+ Dictionary mapping KPI names to target values
583
+ year_prev : int
584
+ Previous year for comparison
585
+ year_curr : int
586
+ Current year for comparison
587
+ kpi_order : list
588
+ List of KPI names in order
589
+
590
+ Returns:
591
+ --------
592
+ matplotlib.axes.Axes
593
+ The same axes, for possible chaining.
594
+ """
595
+ import matplotlib.patheffects as path_effects
596
+
597
+ # --- constants --------------------------------------------------------- #
598
+ GREEN = "#006100"
599
+ RED = "#C00000"
600
+
601
+ y_min, y_max = ax.get_ylim()
602
+ offset = max((y_max - y_min) * 0.015, 2.0) # 1.5 % of range or β‰₯ 2 pt
603
+
604
+ # How many bars compose one KPI group?
605
+ bars_per_group = 0
606
+ if ax.containers:
607
+ bars_per_group = len(ax.containers)
608
+
609
+ if bars_per_group == 0:
610
+ return ax
611
+
612
+ # ----------------------------------------------------------------------- #
613
+ for idx, bar in enumerate(ax.patches):
614
+ height = bar.get_height()
615
+ if height <= 0: # nothing to show
616
+ continue
617
+
618
+ # Identify KPI and bar type (prev-yr / target / curr-yr)
619
+ kpi_idx = idx % len(kpi_order)
620
+ tipo_idx = idx // len(kpi_order)
621
+
622
+ if kpi_idx >= len(kpi_order): # safety net
623
+ continue
624
+
625
+ kpi_name = kpi_order[kpi_idx]
626
+ target_val = target_map.get(kpi_name)
627
+
628
+ if target_val is None:
629
+ target_val = 0 # Default if no target
630
+
631
+ bar_type = ""
632
+ # Determine bar_type from hue order used in the plot
633
+ hue_order = [str(year_prev), 'Target', str(year_curr)]
634
+ if tipo_idx < len(hue_order):
635
+ bar_type = hue_order[tipo_idx]
636
+
637
+ # --- colour selection --------------------------------------------- #
638
+ label_colour = "black" # Default
639
+ if bar_type == "Target":
640
+ label_colour = "black"
641
+ elif bar_type in {str(year_prev), str(year_curr)}:
642
+ label_colour = GREEN if height >= target_val else RED
643
+
644
+ # --- label positioning and contrast effect ------------------------ #
645
+ bbox_props = None
646
+ if height > y_max - (y_max * 0.05): # Overflow if height > 95% of y_max
647
+ y_pos = height - offset
648
+ va = "top"
649
+ # Add a white box for contrast when inside the bar
650
+ bbox_props = dict(boxstyle="square,pad=0.2", fc="white", ec="none", alpha=0.7)
651
+ else: # above the bar
652
+ y_pos = height + offset
653
+ va = "bottom"
654
+
655
+ ax.text(
656
+ bar.get_x() + bar.get_width() / 2.0,
657
+ y_pos,
658
+ f"{height:.1f}%",
659
+ ha="center",
660
+ va=va,
661
+ fontsize=9,
662
+ fontweight="bold",
663
+ color=label_colour,
664
+ bbox=bbox_props
665
+ )
666
+
667
+ return ax
668
+
669
+
670
+ # =============================================================================
671
+ # 🎨 POWERPOINT CREATION FUNCTIONS
672
+ # =============================================================================
673
+
674
+ def create_ops_review_presentation(template_path: str, selected_month: int,
675
+ selected_year: int, output_dir: str = '.') -> Tuple[str, Presentation]:
676
+ """
677
+ Build a two-slide Operations Review deck using template.
678
+
679
+ This adds intro slides to the template without removing any slides
680
+ to completely avoid XML corruption issues.
681
+
682
+ Args:
683
+ template_path: Path to PowerPoint template
684
+ selected_month: Month for the report
685
+ selected_year: Year for the report
686
+ output_dir: Directory to save the presentation
687
+
688
+ Returns:
689
+ Tuple of (output_path, presentation_object)
690
+ """
691
+ # I/O checks
692
+ if not os.path.exists(template_path):
693
+ raise FileNotFoundError(f"Template not found: {template_path}")
694
+ if not 1 <= selected_month <= 12:
695
+ raise ValueError("selected_month must be 1β€’12")
696
+ os.makedirs(output_dir, exist_ok=True)
697
+
698
+ # Load template and use it as-is
699
+ prs = Presentation(template_path)
700
+ original_slide_count = len(prs.slides)
701
+ print(f"βœ… Loaded template with {original_slide_count} original slide(s)")
702
+
703
+ # Find TITLE_AND_BODY layout
704
+ layout = next(
705
+ (lyt for lyt in prs.slide_layouts if lyt.name.upper() == "TITLE_AND_BODY"),
706
+ None
707
+ )
708
+ if layout is None:
709
+ # Fallback: use the first available layout
710
+ layout = prs.slide_layouts[0] if len(prs.slide_layouts) > 0 else None
711
+ if layout is None:
712
+ raise RuntimeError("No slide layouts available in template")
713
+ print(f"⚠️ TITLE_AND_BODY layout not found, using layout: {layout.name}")
714
+
715
+ # Helper to add a styled title slide
716
+ def add_title_slide(text: str) -> None:
717
+ sld = prs.slides.add_slide(layout)
718
+ title_shape = sld.shapes.title
719
+ if title_shape is None:
720
+ for ph in sld.placeholders:
721
+ if ph.placeholder_format.type == PP_PLACEHOLDER.TITLE and ph.has_text_frame:
722
+ title_shape = ph
723
+ break
724
+ if title_shape is None: # fallback textbox
725
+ w, h = Inches(8), Inches(2.5)
726
+ left = (prs.slide_width - w) // 2
727
+ top = (prs.slide_height - h) // 2
728
+ title_shape = sld.shapes.add_textbox(left, top, w, h)
729
+
730
+ title_shape.text_frame.clear()
731
+ p = title_shape.text_frame.paragraphs[0]
732
+ p.text = text
733
+ p.alignment = PP_ALIGN.CENTER
734
+ f = p.font
735
+ f.name = "Arial"
736
+ f.size = Pt(60)
737
+ f.bold = True
738
+ f.color.rgb = RGBColor(0, 112, 192)
739
+ p.line_spacing = 1.15
740
+
741
+ # Add intro slides - they will be at the end, but that's fine
742
+ month_name = calendar.month_name[selected_month].upper()
743
+ add_title_slide(f"Monthly Operations review\n{month_name} {selected_year}")
744
+ add_title_slide("Production KPIs")
745
+
746
+ print(f"βœ… Added 2 intro slides (total slides: {len(prs.slides)})")
747
+ print(f"πŸ“ Note: Intro slides are at positions {len(prs.slides)-1} and {len(prs.slides)}")
748
+
749
+ # Save file
750
+ fname = f"Operations monthly review {selected_month:02d}-{selected_year}.pptx"
751
+ out_path = os.path.join(output_dir, fname)
752
+ prs.save(out_path)
753
+ print(f"πŸ’Ύ Saved β†’ {out_path} (slides: {len(prs.slides)})")
754
+
755
+ return out_path, prs
756
+
757
+
758
+ def create_df_eff_reparto(df: pd.DataFrame, selected_month: int,
759
+ selected_year: int, reparto_code: str) -> pd.DataFrame:
760
+ """
761
+ Creates df_eff_reparto with parametric month and reparto selection
762
+ (matches notebook implementation exactly)
763
+
764
+ Args:
765
+ df: Raw data DataFrame
766
+ selected_month: Current month (1-12)
767
+ selected_year: Current year
768
+ reparto_code: Department code (default 'ST' for STAMPAGGIO)
769
+
770
+ Returns:
771
+ DataFrame with Period[M] index and efficiency metrics + individual deltas
772
+ """
773
+ # Filter data for selected reparto
774
+ df_reparto = df[df['REPARTO'] == reparto_code].copy()
775
+
776
+ # Calculate previous year
777
+ previous_year = selected_year - 1
778
+
779
+ # Filter data for the two-year period and selected months
780
+ df_filtered = df_reparto[
781
+ ((df_reparto['ANNO'] == previous_year) & (df_reparto['MESE'] <= selected_month)) |
782
+ ((df_reparto['ANNO'] == selected_year) & (df_reparto['MESE'] <= selected_month))
783
+ ].copy()
784
+
785
+ # Calculate KPIs grouped by year and month
786
+ kpi_monthly = df_filtered.groupby(['ANNO', 'MESE', 'MESE_DESCR']).apply(calcola_kpi).reset_index()
787
+
788
+ # Create complete date range
789
+ periods = []
790
+ for year in [previous_year, selected_year]:
791
+ for month in range(1, selected_month + 1):
792
+ periods.append(pd.Period(f"{year}-{month:02d}", freq='M'))
793
+
794
+ # Create base dataframe with Period index
795
+ df_eff_reparto = pd.DataFrame(index=periods)
796
+ df_eff_reparto.index.name = 'Period'
797
+
798
+ # Initialize columns with NaN
799
+ efficiency_cols = ['EFF_REP', 'EFF_PRO', 'EFF_SC', 'EFF_E', 'OEE']
800
+ delta_cols = [f'{col}_Delta' for col in efficiency_cols]
801
+
802
+ # Initialize all columns
803
+ for col in efficiency_cols + delta_cols:
804
+ df_eff_reparto[col] = np.nan
805
+
806
+ # Fill in the actual KPI values
807
+ for _, row in kpi_monthly.iterrows():
808
+ period = pd.Period(f"{row['ANNO']}-{row['MESE']:02d}", freq='M')
809
+ if period in df_eff_reparto.index:
810
+ for col in efficiency_cols:
811
+ # Convert to percentage and round to 1 decimal
812
+ df_eff_reparto.loc[period, col] = round(row[col] * 100, 1)
813
+
814
+ # Fill missing values with forward fill for continuity
815
+ for col in efficiency_cols:
816
+ df_eff_reparto[col] = df_eff_reparto[col].ffill().fillna(0)
817
+
818
+ # Calculate % Delta vs previous month for each KPI
819
+ for i, period in enumerate(df_eff_reparto.index):
820
+ if i == 0:
821
+ # First row: no previous month to compare
822
+ for col in efficiency_cols:
823
+ df_eff_reparto.loc[period, f'{col}_Delta'] = np.nan
824
+ else:
825
+ # Calculate delta for each individual KPI
826
+ for col in efficiency_cols:
827
+ prev_value = df_eff_reparto.iloc[i-1][col]
828
+ curr_value = df_eff_reparto.iloc[i][col]
829
+
830
+ if prev_value != 0:
831
+ delta = ((curr_value - prev_value) / prev_value) * 100
832
+ df_eff_reparto.loc[period, f'{col}_Delta'] = round(delta, 1)
833
+ else:
834
+ df_eff_reparto.loc[period, f'{col}_Delta'] = 0.0
835
+
836
+ # Ensure all values are rounded to 1 decimal place
837
+ for col in efficiency_cols:
838
+ df_eff_reparto[col] = df_eff_reparto[col].round(1)
839
+
840
+ # Reorder columns: KPIs first, then all deltas at the end
841
+ column_order = efficiency_cols + delta_cols
842
+ df_eff_reparto = df_eff_reparto[column_order]
843
+
844
+ return df_eff_reparto
845
+
846
+
847
+ def prepare_comparison_data(df: pd.DataFrame, selected_month: int,
848
+ selected_year: int, reparto_code: str) -> Tuple[pd.DataFrame, Dict]:
849
+ """
850
+ Prepares a wide-format DataFrame for the efficiency comparison table with delta calculations.
851
+
852
+ Processes data to create a DataFrame where rows are months and columns are KPIs.
853
+ Each cell contains a dictionary with values for the selected year and the previous year.
854
+ Additionally calculates deltas for the selected month vs its previous month.
855
+
856
+ Args:
857
+ df (pd.DataFrame): The raw 'Dati Grezzi' DataFrame.
858
+ selected_month (int): The month to report up to.
859
+ selected_year (int): The main year for the report.
860
+ reparto_code (str): The department code (e.g., 'ST').
861
+
862
+ Returns:
863
+ tuple: (pd.DataFrame, dict) - The comparison DataFrame and delta calculations.
864
+ """
865
+ previous_year = selected_year - 1
866
+ kpis = ['EFF_REP', 'EFF_PRO', 'EFF_SC', 'EFF_E', 'OEE']
867
+
868
+ # Use the existing function to get monthly KPI data (use 12 to get all months for both years)
869
+ df_eff = create_df_eff_reparto(df, 12, selected_year, reparto_code)
870
+
871
+ # Filter for the relevant years and up to the selected month for the current year
872
+ df_eff = df_eff[df_eff.index.year.isin([previous_year, selected_year])]
873
+
874
+ # Italian month names, uppercase
875
+ month_names = [pd.Timestamp(f'2024-{m}-01').strftime('%B').upper() for m in range(1, 13)]
876
+
877
+ # Pivot the data to get years as columns
878
+ df_pivot = df_eff.reset_index().pivot_table(
879
+ index=df_eff.index.month,
880
+ columns=df_eff.index.year,
881
+ values=kpis
882
+ )
883
+ df_pivot.columns = [f'{kpi}_{year}' for kpi, year in df_pivot.columns]
884
+
885
+ # Create the final structure
886
+ output_df = pd.DataFrame(index=month_names, columns=kpis, dtype=object)
887
+
888
+ # Calculate deltas for selected month vs previous month
889
+ deltas = {}
890
+ if selected_month > 1:
891
+ for kpi in kpis:
892
+ try:
893
+ curr_month_val = df_pivot.loc[selected_month, f'{kpi}_{selected_year}']
894
+ prev_month_val = df_pivot.loc[selected_month - 1, f'{kpi}_{selected_year}']
895
+ if pd.notna(curr_month_val) and pd.notna(prev_month_val):
896
+ deltas[kpi] = curr_month_val - prev_month_val
897
+ else:
898
+ deltas[kpi] = np.nan
899
+ except KeyError:
900
+ deltas[kpi] = np.nan
901
+
902
+ for month_num, month_name in enumerate(month_names, 1):
903
+ for kpi in kpis:
904
+ try:
905
+ prev_val = df_pivot.loc[month_num, f'{kpi}_{previous_year}']
906
+ curr_val = df_pivot.loc[month_num, f'{kpi}_{selected_year}']
907
+ # Only include data up to the selected month for the current year
908
+ if month_num > selected_month:
909
+ curr_val = np.nan
910
+ except KeyError:
911
+ prev_val, curr_val = np.nan, np.nan
912
+
913
+ # Use 'at' instead of 'loc' for dictionary assignment
914
+ output_df.at[month_name, kpi] = {'prev': prev_val, 'curr': curr_val}
915
+
916
+ return output_df, deltas
917
+
918
+
919
+ def set_table_black_borders(table):
920
+ """
921
+ Remove any table style (so PPT won't override) and then
922
+ set all borders to a solid 1 pt black line on every cell.
923
+ """
924
+ from lxml import etree
925
+ from pptx.oxml.ns import qn
926
+
927
+ NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
928
+
929
+ # 1) strip out the <a:tblStyle> so our borders aren't overridden
930
+ # table._tbl is the low-level CT_Table object under the covers
931
+ tbl = table._tbl
932
+ tblPr = tbl.tblPr
933
+ tblStyle = tblPr.find(qn('a:tblStyle'))
934
+ if tblStyle is not None:
935
+ tblPr.remove(tblStyle)
936
+
937
+ # 2) now inject our own <a:lnL>, <a:lnR>, <a:lnT>, <a:lnB> on every cell
938
+ for row in table.rows:
939
+ for cell in row.cells:
940
+ # cell._tc is the CT_Tc element; get or create its <a:tcPr>
941
+ tc = cell._tc
942
+ tcPr = tc.get_or_add_tcPr()
943
+
944
+ for border_dir in ('lnL', 'lnR', 'lnT', 'lnB'):
945
+ # remove any existing
946
+ for elem in tcPr.findall(f'a:{border_dir}', namespaces={'a': NS_A}):
947
+ tcPr.remove(elem)
948
+
949
+ # build <a:lnX> with width=12700 EMU (1 pt), flat cap, solid compound
950
+ ln = etree.SubElement(
951
+ tcPr, f'{{{NS_A}}}{border_dir}',
952
+ {
953
+ 'w': '12700', # 1pt
954
+ 'cap': 'flat',
955
+ 'cmpd': 'sng',
956
+ 'algn': 'ctr'
957
+ }
958
+ )
959
+ # add <a:solidFill><a:srgbClr val="000000"/></a:solidFill>
960
+ solidFill = etree.SubElement(ln, f'{{{NS_A}}}solidFill')
961
+ etree.SubElement(solidFill, f'{{{NS_A}}}srgbClr', val='000000')
962
+ # ensure it's truly solid
963
+ etree.SubElement(ln, f'{{{NS_A}}}prstDash', val='solid')
964
+
965
+
966
+ def add_eff_comparison_table_slide(prs: Presentation, df: pd.DataFrame, selected_year: int,
967
+ selected_month: int, reparto_code: str, template_path: str = None,
968
+ output_dir: str = '.') -> Presentation:
969
+ """
970
+ Adds a new slide with a styled efficiency comparison table to a presentation.
971
+
972
+ If `prs` is None, a new presentation is created using `create_ops_review_presentation`.
973
+ The function adds one slide for the specified department, showing a month-by-month
974
+ KPI comparison for the selected year vs. the previous year.
975
+
976
+ Args:
977
+ prs (pptx.Presentation or None): The presentation object. If None, a new one is created.
978
+ df (pd.DataFrame): The raw 'Dati Grezzi' DataFrame.
979
+ selected_year (int): The primary year for comparison.
980
+ selected_month (int): The month to report up to.
981
+ reparto_code (str): The department code (e.g., 'ST', 'MS').
982
+ template_path (str, optional): Path to the PowerPoint template. Required if prs is None.
983
+ output_dir (str): Directory where the presentation will be saved.
984
+
985
+ Returns:
986
+ prs (pptx.Presentation): The presentation object with the new slide added.
987
+ """
988
+ if prs is None:
989
+ if template_path is None:
990
+ raise ValueError("A template_path must be provided if prs is None.")
991
+ print(f"✨ Creating new presentation using template")
992
+ _, prs = create_ops_review_presentation(template_path, selected_month, selected_year, output_dir)
993
+ else:
994
+ print(f"πŸ“‚ Using provided presentation object")
995
+
996
+ # --- Data and Metadata Preparation ---
997
+ previous_year = selected_year - 1
998
+ reparto_descr = REPARTO_DESCRIPTIONS.get(reparto_code, reparto_code)
999
+ kpi_targets = TARGET_KPI_MAP.get(reparto_code, {})
1000
+ kpis_to_show = ['EFF_REP', 'EFF_PRO', 'EFF_SC', 'EFF_E', 'OEE']
1001
+
1002
+ # Prepare data using the helper function (now returns deltas too)
1003
+ df_comparison, deltas = prepare_comparison_data(df, selected_month, selected_year, reparto_code)
1004
+
1005
+ # --- Slide Creation ---
1006
+ # Try different layouts to find one with a title, starting with index 0
1007
+ slide_layout = None
1008
+ for layout_idx in range(len(prs.slide_layouts)):
1009
+ test_slide = prs.slides.add_slide(prs.slide_layouts[layout_idx])
1010
+ if test_slide.shapes.title is not None:
1011
+ # Found a layout with title, remove the test slide and use this layout
1012
+ prs.slides._sldIdLst.remove(prs.slides._sldIdLst[-1])
1013
+ slide_layout = prs.slide_layouts[layout_idx]
1014
+ break
1015
+ else:
1016
+ # Remove the test slide
1017
+ prs.slides._sldIdLst.remove(prs.slides._sldIdLst[-1])
1018
+
1019
+ # If no layout with title found, use layout 1 and create title manually
1020
+ if slide_layout is None:
1021
+ slide_layout = prs.slide_layouts[1]
1022
+
1023
+ slide = prs.slides.add_slide(slide_layout)
1024
+
1025
+ # Set Title - LEFT ALIGNED and positioned ABOVE the table
1026
+ title_text = f"CONFRONTO EFFICIENZE {previous_year}/{selected_year} – {reparto_descr.upper()}"
1027
+
1028
+ if slide.shapes.title is not None:
1029
+ # Title placeholder exists
1030
+ slide.shapes.title.text = title_text
1031
+ slide.shapes.title.text_frame.paragraphs[0].font.size = Pt(32)
1032
+ slide.shapes.title.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT # LEFT ALIGN
1033
+ else:
1034
+ # Create title manually - LEFT ALIGNED and positioned ABOVE table
1035
+ title_box = slide.shapes.add_textbox(Inches(0.3), Inches(0.3), Inches(12.3), Inches(1.0))
1036
+ title_frame = title_box.text_frame
1037
+ title_frame.text = title_text
1038
+ title_para = title_frame.paragraphs[0]
1039
+ title_para.font.size = Pt(32)
1040
+ title_para.font.bold = True
1041
+ title_para.alignment = PP_ALIGN.LEFT # LEFT ALIGN
1042
+
1043
+ # --- Table Creation and Positioning ---
1044
+ rows, cols = 14, 6 # 1 header row + 12 months + 1 delta row, 6 columns (month + 5 KPIs)
1045
+ table_shape = slide.shapes.add_table(rows, cols, Inches(0.3), Inches(1.6), Inches(12.4), Inches(7.0))
1046
+ table = table_shape.table
1047
+
1048
+ # --- Header Styling ---
1049
+ # Italian month names, uppercase
1050
+ month_names = [pd.Timestamp(f'2024-{m}-01').strftime('%B').upper() for m in range(1, 13)]
1051
+
1052
+ # Month column header - RED BACKGROUND with years styled differently
1053
+ cell_month_header = table.cell(0, 0)
1054
+ cell_month_header.fill.solid()
1055
+ cell_month_header.fill.fore_color.rgb = RGBColor(0xB4, 0x45, 0x57) # RED BACKGROUND like in image
1056
+
1057
+ tf = cell_month_header.text_frame
1058
+ tf.clear()
1059
+
1060
+ # Previous year paragraph - GRAY TEXT
1061
+ p1 = tf.paragraphs[0]
1062
+ p1.text = str(previous_year)
1063
+ p1.font.color.rgb = RGBColor(0x80, 0x80, 0x80) # GRAY
1064
+ p1.font.bold = True
1065
+ p1.font.size = Pt(20) # BIGGER FONT
1066
+ p1.alignment = PP_ALIGN.CENTER
1067
+
1068
+ # Current year paragraph - WHITE TEXT
1069
+ p2 = tf.add_paragraph()
1070
+ p2.text = str(selected_year)
1071
+ p2.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) # WHITE
1072
+ p2.font.bold = True
1073
+ p2.font.size = Pt(20) # BIGGER FONT
1074
+ p2.alignment = PP_ALIGN.CENTER
1075
+
1076
+ cell_month_header.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1077
+
1078
+ # KPI Headers (merged with targets)
1079
+ for i, kpi in enumerate(kpis_to_show, 1):
1080
+ cell_kpi = table.cell(0, i)
1081
+ target_val = kpi_targets.get(kpi, 0)
1082
+
1083
+ # Clear existing text and create two paragraphs
1084
+ tf = cell_kpi.text_frame
1085
+ tf.clear()
1086
+
1087
+ # KPI name paragraph
1088
+ p1 = tf.paragraphs[0]
1089
+ p1.text = kpi
1090
+ p1.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
1091
+ p1.font.bold = True
1092
+ p1.font.size = Pt(20) # BIGGER FONT
1093
+ p1.alignment = PP_ALIGN.CENTER
1094
+
1095
+ # Target paragraph
1096
+ p2 = tf.add_paragraph()
1097
+ p2.text = f">{target_val}%"
1098
+ p2.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
1099
+ p2.font.size = Pt(20) # BIGGER FONT
1100
+ p2.alignment = PP_ALIGN.CENTER
1101
+
1102
+ # Set background color based on column
1103
+ cell_kpi.fill.solid()
1104
+ if kpi == 'OEE': # OEE column should be GRAY
1105
+ cell_kpi.fill.fore_color.rgb = RGBColor(0x80, 0x80, 0x80) # GRAY for OEE column
1106
+ else:
1107
+ cell_kpi.fill.fore_color.rgb = RGBColor(0x3F, 0x3F, 0x3F) # Dark gray for other columns
1108
+
1109
+ cell_kpi.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1110
+
1111
+ # --- Body Styling ---
1112
+ delta_row_inserted = False
1113
+ current_row = 1
1114
+
1115
+ for month_idx, month_name in enumerate(month_names):
1116
+ # Month Name Column
1117
+ cell_month = table.cell(current_row, 0)
1118
+ cell_month.text = month_name
1119
+ cell_month.fill.solid()
1120
+ cell_month.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2) # Very light grey
1121
+ p = cell_month.text_frame.paragraphs[0]
1122
+ p.font.bold = True
1123
+ p.font.color.rgb = RGBColor(0x00, 0x00, 0x00) # Black text
1124
+ p.font.size = Pt(18) # BIGGER FONT
1125
+ p.alignment = PP_ALIGN.CENTER
1126
+ cell_month.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1127
+
1128
+ # KPI Value Cells
1129
+ for c, kpi in enumerate(kpis_to_show, 1):
1130
+ cell = table.cell(current_row, c)
1131
+ values = df_comparison.loc[month_name, kpi]
1132
+ prev_val = values.get('prev')
1133
+ curr_val = values.get('curr')
1134
+
1135
+ tf = cell.text_frame
1136
+ tf.clear()
1137
+
1138
+ # Previous year value
1139
+ p1 = tf.paragraphs[0]
1140
+ p1.text = f"{prev_val:.1f}%".replace('.',',') if pd.notna(prev_val) else ""
1141
+ p1.font.color.rgb = RGBColor(0x80, 0x80, 0x80) # Grey
1142
+ p1.font.size = Pt(18) # BIGGER FONT
1143
+ p1.alignment = PP_ALIGN.CENTER
1144
+
1145
+ # Current year value
1146
+ p2 = tf.add_paragraph()
1147
+ p2.text = f"{curr_val:.1f}%".replace('.',',') if pd.notna(curr_val) else ""
1148
+ p2.font.bold = True
1149
+ p2.font.size = Pt(18) # BIGGER FONT
1150
+ p2.alignment = PP_ALIGN.CENTER
1151
+
1152
+ # Color logic for current year value
1153
+ if pd.notna(curr_val):
1154
+ target = kpi_targets.get(kpi, 0)
1155
+ if curr_val >= target:
1156
+ p2.font.color.rgb = RGBColor(0x00, 0xB0, 0x50) # Green
1157
+ else:
1158
+ p2.font.color.rgb = RGBColor(0xC0, 0x00, 0x00) # Red
1159
+
1160
+ # Set background color for OEE column
1161
+ cell.fill.solid()
1162
+ if kpi == 'OEE': # OEE column cells should be GRAY
1163
+ cell.fill.fore_color.rgb = RGBColor(0xE5, 0xE5, 0xE5) # Light gray for OEE data cells
1164
+ else:
1165
+ cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF) # White for other columns
1166
+
1167
+ cell.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1168
+
1169
+ current_row += 1
1170
+
1171
+ # Insert delta row after selected month
1172
+ if month_idx + 1 == selected_month and not delta_row_inserted and selected_month > 1:
1173
+ # Delta month label
1174
+ cell_delta = table.cell(current_row, 0)
1175
+ cell_delta.text = "Ξ” vs prev"
1176
+ cell_delta.fill.solid()
1177
+ cell_delta.fill.fore_color.rgb = RGBColor(0xF2, 0xF2, 0xF2) # Very light grey
1178
+ p = cell_delta.text_frame.paragraphs[0]
1179
+ p.font.size = Pt(15) # BIGGER FONT
1180
+ p.font.italic = True
1181
+ p.alignment = PP_ALIGN.CENTER
1182
+ cell_delta.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1183
+
1184
+ # Delta values
1185
+ for c, kpi in enumerate(kpis_to_show, 1):
1186
+ cell = table.cell(current_row, c)
1187
+ delta_val = deltas.get(kpi)
1188
+
1189
+ tf = cell.text_frame
1190
+ tf.clear()
1191
+
1192
+ p = tf.paragraphs[0]
1193
+ if pd.notna(delta_val):
1194
+ sign = "+" if delta_val >= 0 else ""
1195
+ p.text = f"{sign}{delta_val:.1f}%".replace('.',',')
1196
+ # Color based on delta direction
1197
+ if delta_val >= 0:
1198
+ p.font.color.rgb = RGBColor(0x00, 0xB0, 0x50) # Green for positive
1199
+ else:
1200
+ p.font.color.rgb = RGBColor(0xC0, 0x00, 0x00) # Red for negative
1201
+ else:
1202
+ p.text = ""
1203
+
1204
+ p.font.size = Pt(12) # BIGGER FONT
1205
+ p.font.italic = True
1206
+ p.alignment = PP_ALIGN.CENTER
1207
+
1208
+ # Set background color for OEE column
1209
+ cell.fill.solid()
1210
+ if kpi == 'OEE': # OEE column cells should be GRAY
1211
+ cell.fill.fore_color.rgb = RGBColor(0xE5, 0xE5, 0xE5) # Light gray for OEE delta cells
1212
+ else:
1213
+ cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF) # White for other columns
1214
+
1215
+ cell.vertical_anchor = MSO_VERTICAL_ANCHOR.MIDDLE
1216
+
1217
+ current_row += 1
1218
+ delta_row_inserted = True
1219
+
1220
+ # --- Table Border Styling (BLACK borders) ---
1221
+ set_table_black_borders(table)
1222
+
1223
+ # --- Final Table Adjustments ---
1224
+ # Set column widths - distribute evenly across slide width
1225
+ total_width = 23
1226
+ col_width = total_width / cols
1227
+ for i in range(cols):
1228
+ table.columns[i].width = Inches(col_width)
1229
+
1230
+ # Set row heights - need to account for delta row position
1231
+ delta_row_position = selected_month + 1 if selected_month > 1 else None # Delta row position
1232
+
1233
+ for r in range(rows):
1234
+ if r == 0: # Header row
1235
+ table.rows[r].height = Inches(1)
1236
+ elif r == delta_row_position: # Delta row (positioned after selected_month)
1237
+ table.rows[r].height = Inches(0.45)
1238
+ else: # Month rows
1239
+ table.rows[r].height = Inches(0.8)
1240
+
1241
+ print(f"βœ… Slide 'CONFRONTO EFFICIENZE' for {reparto_descr} added successfully.")
1242
+
1243
+ # --- Unit Test Checks (as print statements) ---
1244
+ print("\n--- Running Checks ---")
1245
+ print(f"Table has {len(table.rows)} rows (expected 14)")
1246
+ print(f"Table has {len(table.columns)} columns (expected 6)")
1247
+
1248
+ # Check if delta row was inserted
1249
+ if delta_row_inserted:
1250
+ print(f"βœ… Delta row inserted after month {selected_month}")
1251
+ else:
1252
+ print(f"⚠️ Delta row not inserted (selected_month: {selected_month})")
1253
+
1254
+ # Check a value format
1255
+ try:
1256
+ val_check = table.cell(1,1).text_frame.paragraphs[1].text
1257
+ if '%' in val_check and ',' in val_check:
1258
+ print(f"βœ… Cell format check passed (e.g., '{val_check}')")
1259
+ else:
1260
+ print(f"⚠️ Cell format check failed (e.g., '{val_check}')")
1261
+ except IndexError:
1262
+ print("⚠️ Could not check cell format.")
1263
+
1264
+ return prs
1265
+
1266
+
1267
+ def add_eff_chart_slide(prs: Presentation, df: pd.DataFrame, reparto_code: str,
1268
+ selected_year: int, selected_month: int) -> Presentation:
1269
+ """
1270
+ Creates a new slide with efficiency chart for a specific department.
1271
+
1272
+ Parameters:
1273
+ -----------
1274
+ prs : pptx.presentation.Presentation
1275
+ PowerPoint presentation object
1276
+ df : pandas.DataFrame
1277
+ Raw data DataFrame (Dati_Grezzi)
1278
+ reparto_code : str
1279
+ Department code (e.g., 'ST', 'MS', 'DC')
1280
+ selected_year : int
1281
+ Year for comparison
1282
+ selected_month : int
1283
+ Month up to which to calculate YTD (1-12)
1284
+
1285
+ Returns:
1286
+ --------
1287
+ pptx.presentation.Presentation
1288
+ Updated presentation object with new chart slide
1289
+ """
1290
+ # Store initial slide count for unit testing
1291
+ initial_slide_count = len(prs.slides)
1292
+
1293
+ # Generate the chart using build_eff_chart
1294
+ fig = build_eff_chart(df, selected_month, selected_year, reparto_code)
1295
+
1296
+ # Extract title from the chart BEFORE removing it and convert to UPPERCASE
1297
+ chart_title = fig.axes[0].get_title().upper() # Convert to uppercase
1298
+
1299
+ # Remove the chart title and y-axis label to avoid duplication and clean up the chart
1300
+ fig.axes[0].set_title('')
1301
+ fig.axes[0].set_ylabel('') # Remove y-axis label
1302
+
1303
+ # Save chart as temporary image
1304
+ temp_img_path = None
1305
+ try:
1306
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:
1307
+ temp_img_path = tmp_file.name
1308
+ # Save with exact dimensions (build_eff_chart already sets figsize correctly)
1309
+ fig.savefig(temp_img_path, dpi=96, bbox_inches='tight',
1310
+ facecolor='white', edgecolor='none')
1311
+
1312
+ # Try to use layout 1 ("B"), otherwise find suitable layout
1313
+ slide_layout = None
1314
+ if len(prs.slide_layouts) > 1:
1315
+ # Try layout index 1 ("B")
1316
+ slide_layout = prs.slide_layouts[1]
1317
+ else:
1318
+ # Fallback: find first layout with title and content placeholders
1319
+ for layout in prs.slide_layouts:
1320
+ placeholders = layout.placeholders
1321
+ has_title = any(p.placeholder_format.type == 1 for p in placeholders) # Title placeholder
1322
+ has_content = any(p.placeholder_format.type == 7 for p in placeholders) # Content placeholder
1323
+ if has_title and has_content:
1324
+ slide_layout = layout
1325
+ break
1326
+
1327
+ # If still no suitable layout found, use the first available
1328
+ if slide_layout is None:
1329
+ slide_layout = prs.slide_layouts[0]
1330
+
1331
+ # Create new slide
1332
+ slide = prs.slides.add_slide(slide_layout)
1333
+
1334
+ # Set slide title - LEFT ALIGNED and UPPERCASE to match table slide
1335
+ title_placeholder = None
1336
+ for placeholder in slide.placeholders:
1337
+ if placeholder.placeholder_format.type == 1: # Title placeholder
1338
+ title_placeholder = placeholder
1339
+ break
1340
+
1341
+ if title_placeholder:
1342
+ title_placeholder.text = chart_title # Already uppercase
1343
+ # Format title: 32pt, bold, LEFT aligned, black (matching table slide)
1344
+ title_frame = title_placeholder.text_frame
1345
+ title_paragraph = title_frame.paragraphs[0]
1346
+ title_paragraph.alignment = PP_ALIGN.LEFT # Changed from CENTER to LEFT
1347
+ title_run = title_paragraph.runs[0]
1348
+ title_run.font.size = Pt(32)
1349
+ title_run.font.bold = True
1350
+ title_run.font.color.rgb = RGBColor(0, 0, 0) # Black
1351
+ else:
1352
+ # Create title manually if no placeholder - LEFT ALIGNED and positioned to match table
1353
+ title_box = slide.shapes.add_textbox(Inches(0.3), Inches(0.3), Inches(12.3), Inches(1.0))
1354
+ title_frame = title_box.text_frame
1355
+ title_frame.text = chart_title # Already uppercase
1356
+ title_para = title_frame.paragraphs[0]
1357
+ title_para.font.size = Pt(32)
1358
+ title_para.font.bold = True
1359
+ title_para.alignment = PP_ALIGN.LEFT # LEFT ALIGN to match table
1360
+ title_para.font.color.rgb = RGBColor(0, 0, 0) # Black
1361
+
1362
+ # Insert chart image with double dimensions (2x original: 10" Γ— 5.625" β†’ 20" Γ— 11.25")
1363
+ content_placeholder = None
1364
+ for placeholder in slide.placeholders:
1365
+ if placeholder.placeholder_format.type == 7: # Content placeholder
1366
+ content_placeholder = placeholder
1367
+ break
1368
+
1369
+ # Define target dimensions: Double the original chart size
1370
+ target_width = Inches(20.0) # Double of original 10"
1371
+ target_height = Inches(11.25) # Double of original 5.625"
1372
+
1373
+ if content_placeholder:
1374
+ # Remove the placeholder
1375
+ content_placeholder._element.getparent().remove(content_placeholder._element)
1376
+
1377
+ # Position image - moved right for better centering
1378
+ # Standard slide width is ~13.33", so we move it right to center better
1379
+ slide_width = Inches(13.33) # Standard slide width
1380
+
1381
+ # Calculate center position and then add offset to move right
1382
+ center_left = (slide_width - target_width) / 2
1383
+ # Move right by 1.5 inches for better visual centering
1384
+ left = center_left + Inches(1.5) if target_width < slide_width else Inches(1.8)
1385
+ top = Inches(1.6) # Match table top position
1386
+
1387
+ slide.shapes.add_picture(temp_img_path, left, top, target_width, target_height)
1388
+
1389
+ # Unit test checks
1390
+ final_slide_count = len(prs.slides)
1391
+ print(f"βœ… Chart slide added for {reparto_code}")
1392
+ print(f"πŸ“Š Slide count: {initial_slide_count} β†’ {final_slide_count}")
1393
+ print(f"πŸ“‹ Slide title (UPPERCASE): '{chart_title}'")
1394
+ print(f"🎯 Chart title and y-axis label removed from image")
1395
+
1396
+ # Verify image dimensions (double size: 20" x 11.25")
1397
+ added_shape = slide.shapes[-1] # Last added shape should be our image
1398
+ expected_width_emu = int(20.0 * 914400) # 20" in EMU
1399
+ expected_height_emu = int(11.25 * 914400) # 11.25" in EMU
1400
+ tolerance = 0.01 # 1% tolerance
1401
+
1402
+ width_ok = abs(added_shape.width - expected_width_emu) / expected_width_emu <= tolerance
1403
+ height_ok = abs(added_shape.height - expected_height_emu) / expected_height_emu <= tolerance
1404
+
1405
+ if width_ok and height_ok:
1406
+ print(f"βœ… Image dimensions verified: {added_shape.width} x {added_shape.height} EMU")
1407
+ else:
1408
+ print(f"⚠️ Image dimensions: {added_shape.width} x {added_shape.height} EMU (expected ~{expected_width_emu} x {expected_height_emu})")
1409
+
1410
+ print(f"πŸ“ Image positioned at: Left={added_shape.left/914400:.1f}\", Top={added_shape.top/914400:.1f}\"")
1411
+
1412
+ # Warning about size
1413
+ if target_width > slide_width:
1414
+ print(f"⚠️ Warning: Image width ({target_width/914400:.1f}\") exceeds standard slide width ({slide_width/914400:.1f}\")")
1415
+
1416
+ # Unit test assertions
1417
+ assert final_slide_count == initial_slide_count + 1, f"Expected {initial_slide_count + 1} slides, got {final_slide_count}"
1418
+
1419
+ finally:
1420
+ # Clean up temporary file
1421
+ if temp_img_path and os.path.exists(temp_img_path):
1422
+ os.unlink(temp_img_path)
1423
+
1424
+ # Close the matplotlib figure to free memory
1425
+ plt.close(fig)
1426
+
1427
+ return prs
1428
+
1429
+
1430
+ # =============================================================================
1431
+ # 🎯 MAIN ENTRY POINT
1432
+ # =============================================================================
1433
+
1434
+ def make_ppt(csv_path: str, selected_month: int, selected_year: int) -> str:
1435
+ """
1436
+ Main function to generate PowerPoint presentation from CSV data.
1437
+
1438
+ This follows the exact notebook pattern:
1439
+ 1. First department (ST) creates the presentation via add_eff_comparison_table_slide with prs=None
1440
+ 2. Subsequent departments (MS, DC) add their slides to the existing presentation
1441
+
1442
+ Args:
1443
+ csv_path: Path to the CSV file containing manufacturing data
1444
+ selected_month: Month for the report (1-12)
1445
+ selected_year: Year for the report
1446
+
1447
+ Returns:
1448
+ str: Path to the generated PowerPoint file
1449
+
1450
+ Raises:
1451
+ ValueError: If CSV is invalid or parameters are out of range
1452
+ FileNotFoundError: If CSV file or template is not found
1453
+ """
1454
+ # Validate inputs
1455
+ if not os.path.exists(csv_path):
1456
+ raise FileNotFoundError(f"CSV file not found: {csv_path}")
1457
+
1458
+ if not 1 <= selected_month <= 12:
1459
+ raise ValueError("selected_month must be between 1 and 12")
1460
+
1461
+ if not 2020 <= selected_year <= 2030:
1462
+ raise ValueError("selected_year must be between 2020 and 2030")
1463
+
1464
+ print(f"πŸš€ Starting PowerPoint generation for all departments")
1465
+ print(f"πŸ“‚ Input CSV: {csv_path}")
1466
+ print(f"πŸ“… Report period: Up to {calendar.month_name[selected_month]} {selected_year}")
1467
+
1468
+ # Load and validate data
1469
+ df = load_csv_with_encoding_fallback(csv_path)
1470
+ validate_csv_schema(df)
1471
+ df = prepare_dataframe(df)
1472
+
1473
+ # Template path (relative to the module)
1474
+ template_path = Path(__file__).parent.parent / "PPT Assets" / "Single Slide Template Operations monthly review 04-25.pptx"
1475
+ if not template_path.exists():
1476
+ raise FileNotFoundError(f"Template not found: {template_path}")
1477
+
1478
+ # Create output directory in temp
1479
+ output_dir = tempfile.mkdtemp(prefix="briva_ppt_")
1480
+
1481
+ # Departments to process - ST first (creates base), then MS and DC
1482
+ repartos = ['ST', 'MS', 'DC']
1483
+ prs = None
1484
+ final_path = None
1485
+
1486
+ try:
1487
+ # Process departments following exact notebook pattern
1488
+ for reparto_code in repartos:
1489
+ print(f"\n--- Generating slides for {REPARTO_DESCRIPTIONS[reparto_code]} ({reparto_code}) ---")
1490
+
1491
+ # Check if data exists for this reparto
1492
+ reparto_data = df[df['REPARTO'] == reparto_code]
1493
+ if reparto_data.empty:
1494
+ print(f"⚠️ No data found for {reparto_code}, skipping...")
1495
+ continue
1496
+
1497
+ # For the first department, create new presentation via add_eff_comparison_table_slide
1498
+ if reparto_code == repartos[0]:
1499
+ print(f"πŸ—οΈ Creating new presentation for first department {reparto_code}")
1500
+
1501
+ # Create new presentation for the first department (follows notebook pattern exactly)
1502
+ prs = add_eff_comparison_table_slide(
1503
+ prs=None, # This triggers creation of intro slides
1504
+ df=df,
1505
+ selected_year=selected_year,
1506
+ selected_month=selected_month,
1507
+ reparto_code=reparto_code,
1508
+ template_path=str(template_path),
1509
+ output_dir=output_dir
1510
+ )
1511
+
1512
+ # Set the final path based on the created presentation
1513
+ final_path = os.path.join(output_dir, f"Operations monthly review {selected_month:02d}-{selected_year}.pptx")
1514
+
1515
+ print(f"βœ… Base presentation created with {len(prs.slides)} slides")
1516
+
1517
+ else: # Subsequent departments - add to existing presentation
1518
+ print(f"πŸ“„ Adding {reparto_code} slides to existing presentation")
1519
+
1520
+ # Add table slide for subsequent departments
1521
+ prs = add_eff_comparison_table_slide(
1522
+ prs=prs,
1523
+ df=df,
1524
+ selected_year=selected_year,
1525
+ selected_month=selected_month,
1526
+ reparto_code=reparto_code
1527
+ )
1528
+
1529
+ # Add chart slide immediately after the table slide for this department
1530
+ print(f"πŸ“Š Adding chart slide for {reparto_code}...")
1531
+ prs = add_eff_chart_slide(
1532
+ prs=prs,
1533
+ df=df,
1534
+ reparto_code=reparto_code,
1535
+ selected_year=selected_year,
1536
+ selected_month=selected_month
1537
+ )
1538
+
1539
+ print(f"βœ… {reparto_code} slides added. Total slides: {len(prs.slides)}")
1540
+
1541
+ if prs is None:
1542
+ raise ValueError("No valid departments found in the data")
1543
+
1544
+ # Remove the first empty slide (template slide) at the very end to avoid XML corruption
1545
+ # This is done after all slides have been created to minimize disruption
1546
+ if len(prs.slides) > 8: # We expect 8 slides (2 intro + 6 department slides)
1547
+ print(f"\nπŸ—‘οΈ Removing original template slide (slide 1) at end of process...")
1548
+ try:
1549
+ # Get the first slide (index 0) - this should be the empty template slide
1550
+ first_slide = prs.slides[0]
1551
+
1552
+ # Check if it's actually empty/template slide by looking for minimal content
1553
+ is_empty_template = True
1554
+ for shape in first_slide.shapes:
1555
+ if hasattr(shape, 'text_frame') and shape.text_frame:
1556
+ try:
1557
+ text_content = shape.text_frame.text.strip()
1558
+ if text_content and len(text_content) > 10: # More than just placeholder text
1559
+ is_empty_template = False
1560
+ break
1561
+ except:
1562
+ pass
1563
+
1564
+ if is_empty_template:
1565
+ # Use the simplest possible approach - remove from slide ID list only
1566
+ # This avoids relationship manipulation issues
1567
+ sldIdLst = prs.slides._sldIdLst
1568
+ if len(sldIdLst) > 0:
1569
+ # Remove the first slide ID (index 0)
1570
+ removed_slide = sldIdLst[0]
1571
+ sldIdLst.remove(removed_slide)
1572
+
1573
+ print(f"βœ… Successfully removed empty template slide")
1574
+ print(f"πŸ“Š Final slide count: {len(prs.slides)} slides")
1575
+ else:
1576
+ print(f"⚠️ Slide ID list is empty")
1577
+ else:
1578
+ print(f"⚠️ First slide appears to have content, not removing")
1579
+
1580
+ except Exception as e:
1581
+ print(f"⚠️ Could not remove template slide: {e}")
1582
+ print(f"πŸ“Š Keeping all {len(prs.slides)} slides")
1583
+
1584
+ # Save final presentation
1585
+ if final_path:
1586
+ prs.save(final_path)
1587
+ print(f"\nβœ… Final PowerPoint saved: {final_path}")
1588
+ print(f"πŸ“Š Total slides: {len(prs.slides)}")
1589
+ print(f"🏭 Departments included: {', '.join(repartos)}")
1590
+
1591
+ # Verify "Production KPIs" slide is present
1592
+ production_kpis_found = False
1593
+ for i, slide in enumerate(prs.slides):
1594
+ for shape in slide.shapes:
1595
+ if hasattr(shape, 'text_frame') and shape.text_frame:
1596
+ try:
1597
+ text_content = shape.text_frame.text.strip()
1598
+ if 'Production KPIs' in text_content:
1599
+ production_kpis_found = True
1600
+ print(f"βœ… 'Production KPIs' slide verified in position {i+1}")
1601
+ break
1602
+ except:
1603
+ pass
1604
+ if production_kpis_found:
1605
+ break
1606
+
1607
+ if not production_kpis_found:
1608
+ print("⚠️ Warning: 'Production KPIs' slide not found in final presentation")
1609
+
1610
+ return final_path
1611
+ else:
1612
+ raise RuntimeError("Failed to create presentation")
1613
+
1614
+ except Exception as e:
1615
+ print(f"❌ Error creating PowerPoint: {str(e)}")
1616
+ raise
1617
+
1618
+
1619
+ # =============================================================================
1620
+ # πŸ§ͺ TESTING AND VALIDATION
1621
+ # =============================================================================
1622
+
1623
+ def validate_ppt_output(ppt_path: str) -> bool:
1624
+ """
1625
+ Validate that the generated PowerPoint file is valid.
1626
+
1627
+ Args:
1628
+ ppt_path: Path to the PowerPoint file
1629
+
1630
+ Returns:
1631
+ bool: True if valid, False otherwise
1632
+ """
1633
+ try:
1634
+ if not os.path.exists(ppt_path):
1635
+ print(f"❌ File does not exist: {ppt_path}")
1636
+ return False
1637
+
1638
+ # Try to load the file
1639
+ prs = Presentation(ppt_path)
1640
+
1641
+ # Check that it has at least 1 slide
1642
+ if len(prs.slides) < 1:
1643
+ print(f"❌ Presentation has no slides")
1644
+ return False
1645
+
1646
+ file_size = os.path.getsize(ppt_path)
1647
+ print(f"βœ… PowerPoint validation passed:")
1648
+ print(f" πŸ“ File size: {file_size:,} bytes")
1649
+ print(f" πŸ“Š Slides: {len(prs.slides)}")
1650
+ print(f" πŸ“ Dimensions: {prs.slide_width} x {prs.slide_height}")
1651
+
1652
+ return True
1653
+
1654
+ except Exception as e:
1655
+ print(f"❌ PowerPoint validation failed: {str(e)}")
1656
+ return False
1657
+
1658
+
1659
+ if __name__ == "__main__":
1660
+ # Example usage for testing
1661
+ import sys
1662
+
1663
+ if len(sys.argv) < 4:
1664
+ print("Usage: python generator.py <csv_path> <selected_month> <selected_year>")
1665
+ print("Example: python generator.py data.csv 5 2024")
1666
+ print("This will create a report for May 2024 covering all departments (ST, MS, DC)")
1667
+ sys.exit(1)
1668
+
1669
+ csv_file = sys.argv[1]
1670
+ try:
1671
+ selected_month = int(sys.argv[2])
1672
+ selected_year = int(sys.argv[3])
1673
+ except ValueError:
1674
+ print("❌ Error: selected_month and selected_year must be integers")
1675
+ print("Example: python generator.py data.csv 5 2024")
1676
+ sys.exit(1)
1677
+
1678
+ try:
1679
+ output_path = make_ppt(csv_file, selected_month, selected_year)
1680
+ if validate_ppt_output(output_path):
1681
+ print(f"πŸŽ‰ Success! PowerPoint created at: {output_path}")
1682
+ else:
1683
+ print("❌ PowerPoint validation failed")
1684
+ sys.exit(1)
1685
+ except Exception as e:
1686
+ print(f"❌ Error: {e}")
1687
+ sys.exit(1)
template.pptx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:794dd2583c70a3685de6a6f5c700921a6dfb425c52589f4844244287a30ca477
3
+ size 288052