""" Esportazione Excel per progetti PIF. Genera un file Excel con 4 fogli: 1. Anagrafica - Informazioni ordine e lista ingredienti 2. Esposizione - Parametri di esposizione 3. SED - Calcolo Systemic Exposure Dosage (senza DAP) 4. MoS - Calcolo Margin of Safety (con DAP, indicatore tossicologico, restrizioni) """ import os from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, numbers from openpyxl.utils import get_column_letter from pif_compiler.functions.common_log import get_logger logger = get_logger() # ==================== STYLES ==================== TITLE_FONT = Font(bold=True, size=14) LABEL_FONT = Font(bold=True, size=11) HEADER_FONT = Font(bold=True, color="FFFFFF", size=11) HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") LIGHT_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") WARNING_FILL = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") THIN_BORDER = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) def _style_header_row(ws, row, num_cols): """Applica stile alle celle header.""" for col in range(1, num_cols + 1): cell = ws.cell(row=row, column=col) cell.font = HEADER_FONT cell.fill = HEADER_FILL cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) cell.border = THIN_BORDER def _apply_borders(ws, row, num_cols): """Applica bordi a una riga.""" for col in range(1, num_cols + 1): ws.cell(row=row, column=col).border = THIN_BORDER WRAP_ALIGNMENT = Alignment(vertical='top', wrap_text=True) WRAP_CENTER = Alignment(horizontal='center', vertical='top', wrap_text=True) def _set_column_widths(ws, widths): """Imposta larghezze colonne fisse e applica wrap_text a tutte le celle con dati.""" for i, w in enumerate(widths, 1): ws.column_dimensions[get_column_letter(i)].width = w for row in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=len(widths)): for cell in row: if cell.alignment.horizontal == 'center': cell.alignment = Alignment(horizontal='center', vertical='top', wrap_text=True) else: cell.alignment = WRAP_ALIGNMENT def _get_ingredient_name(pi): """Restituisce il nome migliore per un ingrediente.""" if pi.inci: return pi.inci if pi.ingredient and pi.ingredient.inci: return pi.ingredient.inci[0] return pi.cas or "" def _get_cosing_restrictions(ingredient): """Estrae le restrizioni COSING da un ingrediente.""" annex = [] other = [] if ingredient and ingredient.cosing_info: for cosing in ingredient.cosing_info: annex.extend(cosing.annex) other.extend(cosing.otherRestrictions) return "; ".join(annex), "; ".join(other) def _get_dap_info_text(ingredient): """Formatta le informazioni DAP come testo leggibile.""" if not ingredient or not ingredient.dap_info: return "" d = ingredient.dap_info parts = [] if d.molecular_weight is not None: parts.append(f"Peso Molecolare: {d.molecular_weight} Da") if d.log_pow is not None: parts.append(f"LogP: {d.log_pow}") if d.tpsa is not None: parts.append(f"TPSA: {d.tpsa} A\u00b2") if d.melting_point is not None: parts.append(f"Punto di Fusione: {d.melting_point}\u00b0C") if d.high_ionization is not None: parts.append(f"pKa: {d.high_ionization}") parts.append(f"DAP: {d.dap_value * 100:.0f}%") return ", ".join(parts) # ==================== SHEET BUILDERS ==================== def _build_anagrafica(wb, project): """Sheet 1: Anagrafica e lista ingredienti.""" ws = wb.active ws.title = "Anagrafica" ws.merge_cells('A1:E1') ws['A1'] = "INFORMAZIONI ORDINE" ws['A1'].font = TITLE_FONT info_rows = [ ("Cliente", project.client_name), ("Nome Prodotto", project.product_name), ("Preset Esposizione", project.esposition.preset_name), ] for i, (label, value) in enumerate(info_rows): ws.cell(row=i + 3, column=1, value=label).font = LABEL_FONT ws.cell(row=i + 3, column=2, value=value) # Tabella ingredienti tbl_row = 7 headers = [ "INCI", "CAS", "Percentuale (%)", "Colorante", "Escluso da Valutazione Tossicologica" ] for col, h in enumerate(headers, 1): ws.cell(row=tbl_row, column=col, value=h) _style_header_row(ws, tbl_row, len(headers)) for i, pi in enumerate(project.ingredients): r = tbl_row + 1 + i ws.cell(row=r, column=1, value=pi.inci or "") ws.cell(row=r, column=2, value=pi.cas or "") ws.cell(row=r, column=3, value=pi.percentage) ws.cell(row=r, column=4, value="Si" if pi.is_colorante else "No") ws.cell(row=r, column=5, value="Si" if pi.skip_tox else "No") _apply_borders(ws, r, len(headers)) _set_column_widths(ws, [20, 14, 14, 12, 18]) def _build_esposizione(wb, project): """ Sheet 2: Parametri di esposizione. Layout delle celle di riferimento (usate nelle formule SED/MoS): B5 = Peso Corporeo Target (kg) B12 = Quantita Giornaliera (g/giorno) B13 = Fattore di Ritenzione """ ws = wb.create_sheet("Esposizione") esp = project.esposition ws.merge_cells('A1:B1') ws['A1'] = "PARAMETRI DI ESPOSIZIONE" ws['A1'].font = TITLE_FONT params = [ # label, value, row (starting from 3) ("Tipo Prodotto", esp.tipo_prodotto), ("Popolazione Target", esp.popolazione_target), ("Peso Corporeo Target (kg)", esp.peso_target_kg), # B5 ("Luogo di Applicazione", esp.luogo_applicazione), ("Vie di Esposizione Normali", ", ".join(esp.esp_normali)), ("Vie di Esposizione Secondarie", ", ".join(esp.esp_secondarie)), ("Vie di Esposizione Nano", ", ".join(esp.esp_nano)), ("Superficie Esposta (cm\u00b2)", esp.sup_esposta), ("Frequenza di Applicazione (applicazioni/giorno)", esp.freq_applicazione), ("Quantita Giornaliera (g/giorno)", esp.qta_giornaliera), # B12 ("Fattore di Ritenzione", esp.ritenzione), # B13 ] for i, (label, value) in enumerate(params): row = i + 3 ws.cell(row=row, column=1, value=label).font = LABEL_FONT ws.cell(row=row, column=2, value=value) # Campi calcolati con formule Excel ws.cell(row=15, column=1, value="Esposizione Calcolata (g/giorno)").font = LABEL_FONT ws['B15'] = "=B12*B13" ws['B15'].number_format = '0.0000' ws.cell(row=16, column=1, value="Esposizione Relativa (mg/kg bw/giorno)").font = LABEL_FONT ws['B16'] = "=B15*1000/B5" ws['B16'].number_format = '0.0000' ws.column_dimensions['A'].width = 50 ws.column_dimensions['B'].width = 25 def _build_sed(wb, project): """ Sheet 3: Calcolo SED senza DAP, con restrizioni COSING. Formula SED (mg/kg bw/giorno): = (percentuale / 100) * qta_giornaliera * ritenzione / peso_corporeo * 1000 In riferimenti Excel: = (C{row}/100) * Esposizione!$B$12 * Esposizione!$B$13 / Esposizione!$B$5 * 1000 """ ws = wb.create_sheet("SED") ws.merge_cells('A1:F1') ws['A1'] = "CALCOLO SYSTEMIC EXPOSURE DOSAGE (SED)" ws['A1'].font = TITLE_FONT hdr = 3 headers = [ "Ingrediente", "CAS", "Percentuale (%)", "Systemic Exposure Dosage - SED (mg/kg bw/giorno)", "Restrizioni COSING (Annex)", "Altre Restrizioni", ] for col, h in enumerate(headers, 1): ws.cell(row=hdr, column=col, value=h) _style_header_row(ws, hdr, len(headers)) for i, pi in enumerate(project.ingredients): r = hdr + 1 + i name = _get_ingredient_name(pi) ws.cell(row=r, column=1, value=name) ws.cell(row=r, column=2, value=pi.cas or "") ws.cell(row=r, column=3, value=pi.percentage) # SED formula solo per ingredienti non esclusi if not pi.skip_tox and pi.cas: ws.cell(row=r, column=4).value = ( f"=(C{r}/100)*Esposizione!$B$12*Esposizione!$B$13" f"/Esposizione!$B$5*1000" ) ws.cell(row=r, column=4).number_format = '0.000000' # Restrizioni COSING annex, other = _get_cosing_restrictions(pi.ingredient if not pi.skip_tox else None) ws.cell(row=r, column=5, value=annex) ws.cell(row=r, column=6, value=other) # Evidenzia riga se ha restrizioni if annex: for col in range(1, len(headers) + 1): ws.cell(row=r, column=col).fill = WARNING_FILL _apply_borders(ws, r, len(headers)) _set_column_widths(ws, [18, 14, 14, 20, 18, 18]) def _build_mos(wb, project): """ Sheet 4: Calcolo Margin of Safety. Formule Excel: SED (col C) = (B{r}/100) * Esposizione!$B$12 * Esposizione!$B$13 / Esposizione!$B$5 * 1000 SED con DAP (E) = C{r} * D{r} MoS (I) = G{r} / (E{r} * H{r}) [se E > 0 e H > 0] """ ws = wb.create_sheet("MoS") ws.merge_cells('A1:N1') ws['A1'] = "CALCOLO MARGIN OF SAFETY (MoS)" ws['A1'].font = TITLE_FONT hdr = 3 headers = [ "Nome Ingrediente", # A "Percentuale (%)", # B "Systemic Exposure Dosage - SED (mg/kg bw/giorno)", # C "Dermal Absorption Percentage (DAP)", # D "SED corretto con DAP (mg/kg bw/giorno)", # E "Indicatore Tossicologico (NOAEL / LOAEL / LD50)", # F "Valore Indicatore (mg/kg bw/giorno)", # G "Fattore di Sicurezza", # H "Margin of Safety (MoS)", # I "Fonte del Dato", # J "Informazioni DAP (Peso Molecolare, LogP, TPSA, Punto Fusione)",# K "Restrizioni COSING (Annex)", # L "Altre Restrizioni", # M "Note", # N ] for col, h in enumerate(headers, 1): ws.cell(row=hdr, column=col, value=h) _style_header_row(ws, hdr, len(headers)) num_cols = len(headers) r = hdr + 1 for pi in project.ingredients: # Salta ingredienti esclusi (skip_tox o senza CAS) if pi.skip_tox or not pi.cas: continue name = _get_ingredient_name(pi) ing = pi.ingredient best = ing.toxicity.best_case if ing and ing.toxicity else None # A: Nome ws.cell(row=r, column=1, value=name) # B: Percentuale ws.cell(row=r, column=2, value=pi.percentage) # C: SED (senza DAP) - formula Excel ws.cell(row=r, column=3).value = ( f"=(B{r}/100)*Esposizione!$B$12*Esposizione!$B$13" f"/Esposizione!$B$5*1000" ) ws.cell(row=r, column=3).number_format = '0.000000' # D: DAP dap_value = ing.dap_info.dap_value if ing and ing.dap_info else 0.5 ws.cell(row=r, column=4, value=dap_value) # E: SED con DAP - formula Excel ws.cell(row=r, column=5).value = f"=C{r}*D{r}" ws.cell(row=r, column=5).number_format = '0.000000' # F: Tipo indicatore ws.cell(row=r, column=6, value=best.indicator if best else "") # G: Valore indicatore ws.cell(row=r, column=7, value=best.value if best else "") # H: Fattore di sicurezza factor = best.factor if best else 1 ws.cell(row=r, column=8, value=factor) # I: MoS - formula Excel ws.cell(row=r, column=9).value = ( f'=IF(AND(E{r}>0,H{r}>0),G{r}/(E{r}*H{r}),"")' ) ws.cell(row=r, column=9).number_format = '0.00' # J: Fonte ws.cell(row=r, column=10, value=best.ref if best else "") # K: Informazioni DAP ws.cell(row=r, column=11, value=_get_dap_info_text(ing)) # L, M: Restrizioni annex, other = _get_cosing_restrictions(ing) ws.cell(row=r, column=12, value=annex) ws.cell(row=r, column=13, value=other) # N: Note (vuoto) ws.cell(row=r, column=14, value="") # Stile: bordi + evidenzia se MoS potrebbe essere basso _apply_borders(ws, r, num_cols) # Alterna colore righe per leggibilita if (r - hdr) % 2 == 0: for col in range(1, num_cols + 1): ws.cell(row=r, column=col).fill = LIGHT_FILL r += 1 # Legenda sotto la tabella legend_row = r + 2 ws.cell(row=legend_row, column=1, value="LEGENDA").font = LABEL_FONT ws.cell(row=legend_row + 1, column=1, value="Fattore di Sicurezza:") ws.cell(row=legend_row + 1, column=2, value="NOAEL = 1, LOAEL = 3, LD50 = 10") ws.cell(row=legend_row + 2, column=1, value="MoS accettabile:") ws.cell(row=legend_row + 2, column=2, value=">= 100 (secondo linee guida SCCS)") ws.cell(row=legend_row + 3, column=1, value="Formula MoS:") ws.cell(row=legend_row + 3, column=2, value="Valore Indicatore / (SED con DAP x Fattore di Sicurezza)") _set_column_widths(ws, [18, 10, 14, 8, 14, 12, 12, 10, 10, 18, 22, 18, 18, 14]) # ==================== MAIN EXPORT ==================== def export_project_excel(project, output_path: str = None) -> str: """ Esporta un Project completo in un file Excel (.xlsx). Args: project: oggetto Project da esportare output_path: percorso del file di output (default: exports/progetto_{order_id}.xlsx) Returns: Il percorso del file Excel generato. """ if output_path is None: os.makedirs("exports", exist_ok=True) output_path = f"exports/progetto_{project.order_id}.xlsx" else: dir_name = os.path.dirname(output_path) if dir_name: os.makedirs(dir_name, exist_ok=True) wb = Workbook() _build_anagrafica(wb, project) _build_esposizione(wb, project) _build_sed(wb, project) _build_mos(wb, project) wb.save(output_path) logger.info(f"Excel esportato: {output_path}") return output_path