414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""
|
|
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
|