501 lines
18 KiB
Python
501 lines
18 KiB
Python
import streamlit as st
|
|
import requests
|
|
import pandas as pd
|
|
import re
|
|
|
|
API_BASE = "http://localhost:8000/api/v1"
|
|
|
|
# CAS validation: 2-7 digits, dash, 2 digits, dash, 1 check digit
|
|
CAS_PATTERN = re.compile(r"^\d{2,7}-\d{2}-\d$")
|
|
|
|
# Percentuale target e tolleranza
|
|
PERCENTAGE_TARGET = 100.0
|
|
PERCENTAGE_TOLERANCE = 0.01
|
|
|
|
# INCI che indicano acqua (case-insensitive)
|
|
WATER_INCI = {"aqua", "water", "eau", "aqua/water", "water/aqua", "aqua/eau"}
|
|
|
|
|
|
def is_water_inci(inci_value: str) -> bool:
|
|
"""Controlla se l'INCI corrisponde ad acqua o varianti."""
|
|
if not inci_value or not isinstance(inci_value, str):
|
|
return False
|
|
return inci_value.strip().lower() in WATER_INCI
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fetch_presets() -> list[str]:
|
|
"""Recupera i nomi dei preset di esposizione dall'API."""
|
|
try:
|
|
resp = requests.get(f"{API_BASE}/esposition/presets", timeout=10)
|
|
data = resp.json()
|
|
if data.get("success") and data.get("data"):
|
|
return [p["preset_name"] for p in data["data"]]
|
|
return []
|
|
except requests.ConnectionError:
|
|
st.error("Impossibile connettersi all'API per caricare i preset. Verifica che il server sia attivo.")
|
|
return []
|
|
except Exception as e:
|
|
st.error(f"Errore nel caricamento dei preset: {e}")
|
|
return []
|
|
|
|
|
|
def fetch_clients() -> list[dict]:
|
|
"""Recupera la lista clienti dall'API. Ritorna lista di {id_cliente, nome_cliente}."""
|
|
try:
|
|
resp = requests.get(f"{API_BASE}/ingredients/clients", timeout=10)
|
|
data = resp.json()
|
|
if data.get("success") and data.get("data"):
|
|
return data["data"]
|
|
return []
|
|
except requests.ConnectionError:
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def create_client(nome_cliente: str) -> bool:
|
|
"""Crea un nuovo cliente via API. Ritorna True se riuscito."""
|
|
try:
|
|
resp = requests.post(
|
|
f"{API_BASE}/ingredients/clients",
|
|
json={"nome_cliente": nome_cliente},
|
|
timeout=10,
|
|
)
|
|
data = resp.json()
|
|
return data.get("success", False)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def validate_order(
|
|
client_name: str,
|
|
product_name: str,
|
|
selected_preset: str | None,
|
|
df: pd.DataFrame,
|
|
) -> list[str]:
|
|
"""Valida tutti i campi dell'ordine. Ritorna lista di errori (vuota = valido)."""
|
|
errors = []
|
|
|
|
if not client_name or not client_name.strip():
|
|
errors.append("Il campo 'Nome del cliente' e obbligatorio.")
|
|
if not product_name or not product_name.strip():
|
|
errors.append("Il campo 'Nome del prodotto' e obbligatorio.")
|
|
if not selected_preset:
|
|
errors.append("Selezionare un preset di esposizione.")
|
|
|
|
# Normalizza colonne stringa (data_editor puo generare NaN)
|
|
str_df = df.copy()
|
|
str_df["inci"] = str_df["inci"].fillna("")
|
|
str_df["cas"] = str_df["cas"].fillna("")
|
|
|
|
# Righe attive: CAS compilato oppure percentuale > 0
|
|
active = str_df[(str_df["cas"].str.strip() != "") | (str_df["percentage"] > 0)]
|
|
|
|
if active.empty:
|
|
errors.append("Inserire almeno un ingrediente.")
|
|
return errors
|
|
|
|
# CAS format (solo righe non-colorante e non-acqua con CAS compilato)
|
|
for idx, row in active.iterrows():
|
|
cas_val = row["cas"].strip()
|
|
inci_val = row["inci"].strip()
|
|
is_col = bool(row["is_colorante"])
|
|
is_aqua = is_water_inci(inci_val)
|
|
|
|
# Se acqua, CAS puo essere vuoto
|
|
if is_aqua and cas_val == "":
|
|
continue
|
|
|
|
# Se colorante, skip validazione formato
|
|
if is_col:
|
|
continue
|
|
|
|
# CAS vuoto su riga non-acqua e non-colorante
|
|
if cas_val == "":
|
|
errors.append(f"Riga {idx + 1}: inserire un CAS number oppure selezionare 'Colorante'.")
|
|
continue
|
|
|
|
# Formato CAS
|
|
if not CAS_PATTERN.match(cas_val):
|
|
hint = f" ({inci_val})" if inci_val else ""
|
|
errors.append(
|
|
f"Formato CAS non valido alla riga {idx + 1}{hint}: '{cas_val}'. "
|
|
"Formato atteso: XX-XX-X (es. 56-81-5)."
|
|
)
|
|
|
|
# Somma percentuali
|
|
total_pct = active["percentage"].sum()
|
|
if abs(total_pct - PERCENTAGE_TARGET) > PERCENTAGE_TOLERANCE:
|
|
errors.append(
|
|
f"La somma delle percentuali e {total_pct:.6f}%, "
|
|
f"ma deve essere 100% (tolleranza +/- {PERCENTAGE_TOLERANCE}%)."
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build payload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_order_payload(
|
|
client_name: str,
|
|
product_name: str,
|
|
preset_name: str,
|
|
df: pd.DataFrame,
|
|
) -> dict:
|
|
"""Costruisce il JSON dell'ordine a partire dai dati del form."""
|
|
str_df = df.copy()
|
|
str_df["inci"] = str_df["inci"].fillna("")
|
|
str_df["cas"] = str_df["cas"].fillna("")
|
|
|
|
active = str_df[(str_df["cas"].str.strip() != "") | (str_df["percentage"] > 0)]
|
|
|
|
ingredients = []
|
|
for _, row in active.iterrows():
|
|
inci_val = row["inci"].strip()
|
|
skip = bool(row["skip_tox"]) or is_water_inci(inci_val)
|
|
ingredients.append({
|
|
"inci": inci_val if inci_val else None,
|
|
"cas": row["cas"].strip(),
|
|
"percentage": round(float(row["percentage"]), 6),
|
|
"is_colorante": bool(row["is_colorante"]),
|
|
"skip_tox": skip,
|
|
})
|
|
|
|
return {
|
|
"client_name": client_name.strip(),
|
|
"product_name": product_name.strip(),
|
|
"preset_esposizione": preset_name,
|
|
"ingredients": ingredients,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Display summary (reusable)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def display_orderData(order_data: dict):
|
|
"""Mostra un riepilogo leggibile dell'ordine (non JSON)."""
|
|
st.markdown("### Riepilogo Ordine")
|
|
|
|
col1, col2, col3 = st.columns(3)
|
|
with col1:
|
|
st.metric("Cliente", order_data["client_name"])
|
|
with col2:
|
|
st.metric("Prodotto", order_data["product_name"])
|
|
with col3:
|
|
st.metric("Preset Esposizione", order_data["preset_esposizione"])
|
|
|
|
ingredients = order_data.get("ingredients", [])
|
|
total_pct = sum(i["percentage"] for i in ingredients)
|
|
|
|
col4, col5, col6 = st.columns(3)
|
|
with col4:
|
|
st.metric("Numero ingredienti", len(ingredients))
|
|
with col5:
|
|
st.metric("Percentuale totale", f"{total_pct:.6f}%")
|
|
with col6:
|
|
n_col = sum(1 for i in ingredients if i["is_colorante"])
|
|
n_skip = sum(1 for i in ingredients if i["skip_tox"])
|
|
parts = []
|
|
if n_col > 0:
|
|
parts.append(f"{n_col} colorante/i")
|
|
if n_skip > 0:
|
|
parts.append(f"{n_skip} senza tox")
|
|
st.metric("Flag attivi", ", ".join(parts) if parts else "Nessuno")
|
|
|
|
st.markdown("**Ingredienti:**")
|
|
display_rows = []
|
|
for i in ingredients:
|
|
display_rows.append({
|
|
"INCI": i["inci"] if i["inci"] else "-",
|
|
"CAS": i["cas"] if i["cas"] else "-",
|
|
"Percentuale (%)": f"{i['percentage']:.6f}",
|
|
"Colorante": "Si" if i["is_colorante"] else "-",
|
|
"Salta Tox": "Si" if i["skip_tox"] else "-",
|
|
})
|
|
st.dataframe(pd.DataFrame(display_rows), use_container_width=True, hide_index=True)
|
|
|
|
|
|
# ===========================================================================
|
|
# PAGE
|
|
# ===========================================================================
|
|
|
|
st.set_page_config(page_title="Creazione Ordine", layout="wide")
|
|
st.title("Creazione Ordine")
|
|
|
|
# --- Session state init ---
|
|
if "order_presets" not in st.session_state:
|
|
st.session_state.order_presets = None
|
|
if "order_clients" not in st.session_state:
|
|
st.session_state.order_clients = None
|
|
if "order_submitted" not in st.session_state:
|
|
st.session_state.order_submitted = False
|
|
if "order_result" not in st.session_state:
|
|
st.session_state.order_result = None
|
|
if "ingredient_df" not in st.session_state:
|
|
st.session_state.ingredient_df = pd.DataFrame({
|
|
"inci": [""] * 5,
|
|
"cas": [""] * 5,
|
|
"percentage": [0.0] * 5,
|
|
"is_colorante": [False] * 5,
|
|
"skip_tox": [False] * 5,
|
|
})
|
|
if "new_client_mode" not in st.session_state:
|
|
st.session_state.new_client_mode = False
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Istruzioni
|
|
# ---------------------------------------------------------------------------
|
|
with st.expander("Istruzioni per la compilazione", expanded=False):
|
|
st.markdown("""
|
|
### Come compilare l'ordine
|
|
|
|
**Campi obbligatori:**
|
|
- **Nome del cliente**: Selezionare un cliente esistente dal menu a tendina, oppure
|
|
scegliere *"+ Nuovo cliente..."* per inserirne uno nuovo.
|
|
- **Nome del prodotto**: Il nome commerciale del prodotto cosmetico.
|
|
- **Preset di esposizione**: Selezionare il preset che descrive le condizioni di utilizzo
|
|
del prodotto (es. crema viso leave-on, shampoo rinse-off, ecc.).
|
|
I preset si creano dalla pagina *Esposizione*.
|
|
|
|
**Tabella ingredienti:**
|
|
- **INCI**: Nome INCI dell'ingrediente (facoltativo, ma consigliato).
|
|
Se si inserisce *AQUA*, *Water*, *Eau* o varianti, la valutazione tossicologica viene
|
|
automaticamente saltata e il CAS puo essere lasciato vuoto.
|
|
- **CAS**: Numero CAS nel formato standard (es. `56-81-5`). Ogni riga deve contenere
|
|
**un solo** CAS number.
|
|
- **Percentuale (%)**: Percentuale in peso dell'ingrediente nella formulazione.
|
|
Sono ammessi fino a 6 decimali (es. `0.000200`).
|
|
- **Colorante / CAS speciale**: Selezionare per coloranti (CI xxxxx) o sostanze con
|
|
codici non-standard. Il formato CAS non verra validato.
|
|
- **Salta tossicologia**: Selezionare per ingredienti che non richiedono valutazione
|
|
tossicologica. Viene impostato automaticamente per AQUA/Water.
|
|
|
|
**Regole:**
|
|
- La somma delle percentuali deve essere esattamente **100%** (tolleranza: +/- 0.01%).
|
|
- E possibile aggiungere o rimuovere righe dalla tabella cliccando i pulsanti in basso.
|
|
- Compilare tutti i campi obbligatori prima di inviare l'ordine.
|
|
""")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Carica ordine esistente
|
|
# ---------------------------------------------------------------------------
|
|
st.markdown("---")
|
|
st.subheader("Carica ordine esistente")
|
|
|
|
col_lookup, col_lookup_btn = st.columns([3, 1])
|
|
with col_lookup:
|
|
order_id_input = st.number_input(
|
|
"ID Ordine",
|
|
min_value=1,
|
|
step=1,
|
|
value=None,
|
|
placeholder="es. 42",
|
|
help="Inserire l'ID numerico di un ordine esistente per precompilare il modulo.",
|
|
)
|
|
with col_lookup_btn:
|
|
st.write("")
|
|
st.write("")
|
|
lookup_clicked = st.button("Carica", disabled=order_id_input is None)
|
|
|
|
if lookup_clicked:
|
|
st.info(
|
|
f"Funzionalita in sviluppo. L'ordine con ID {order_id_input} "
|
|
"verra caricato automaticamente quando l'endpoint API sara disponibile."
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Caricamento dati da API (preset + clienti)
|
|
# ---------------------------------------------------------------------------
|
|
if st.session_state.order_presets is None:
|
|
st.session_state.order_presets = fetch_presets()
|
|
if st.session_state.order_clients is None:
|
|
st.session_state.order_clients = fetch_clients()
|
|
|
|
preset_names = st.session_state.order_presets
|
|
client_list = st.session_state.order_clients
|
|
client_names = [c["nome_cliente"] for c in client_list]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Form fields
|
|
# ---------------------------------------------------------------------------
|
|
st.markdown("---")
|
|
st.subheader("Dati ordine")
|
|
|
|
col_left, col_right = st.columns(2)
|
|
|
|
with col_left:
|
|
# Client dropdown con opzione "Nuovo cliente"
|
|
dropdown_options = client_names + ["+ Nuovo cliente..."]
|
|
selected_client_option = st.selectbox("Nome del cliente *", options=dropdown_options)
|
|
|
|
client_name = ""
|
|
if selected_client_option == "+ Nuovo cliente...":
|
|
new_client_name = st.text_input(
|
|
"Nome nuovo cliente",
|
|
placeholder="es. Cosmetica Italia S.r.l.",
|
|
)
|
|
if new_client_name.strip():
|
|
if st.button("Aggiungi cliente"):
|
|
success = create_client(new_client_name.strip())
|
|
if success:
|
|
st.success(f"Cliente '{new_client_name.strip()}' aggiunto.")
|
|
st.session_state.order_clients = fetch_clients()
|
|
st.rerun()
|
|
else:
|
|
st.error("Errore nella creazione del cliente.")
|
|
client_name = new_client_name.strip()
|
|
else:
|
|
client_name = selected_client_option
|
|
|
|
product_name = st.text_input("Nome del prodotto *", placeholder="es. Crema idratante viso")
|
|
|
|
with col_right:
|
|
if preset_names:
|
|
selected_preset = st.selectbox("Preset di esposizione *", options=preset_names)
|
|
else:
|
|
st.warning("Nessun preset disponibile. Creare un preset nella pagina Esposizione.")
|
|
selected_preset = None
|
|
|
|
col_reload_p, col_reload_c = st.columns(2)
|
|
with col_reload_p:
|
|
if st.button("Ricarica preset", key="reload_presets"):
|
|
st.session_state.order_presets = fetch_presets()
|
|
st.rerun()
|
|
with col_reload_c:
|
|
if st.button("Ricarica clienti", key="reload_clients"):
|
|
st.session_state.order_clients = fetch_clients()
|
|
st.rerun()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Tabella ingredienti
|
|
# ---------------------------------------------------------------------------
|
|
st.markdown("---")
|
|
st.subheader("Ingredienti")
|
|
|
|
column_config = {
|
|
"inci": st.column_config.TextColumn(
|
|
"INCI",
|
|
help="Nome INCI (facoltativo). Inserire AQUA/Water per saltare automaticamente la tossicologia.",
|
|
width="medium",
|
|
),
|
|
"cas": st.column_config.TextColumn(
|
|
"CAS",
|
|
help="Numero CAS (es. 56-81-5). Puo essere vuoto per AQUA/Water.",
|
|
width="medium",
|
|
),
|
|
"percentage": st.column_config.NumberColumn(
|
|
"Percentuale (%)",
|
|
help="Percentuale in peso (fino a 6 decimali)",
|
|
min_value=0.0,
|
|
max_value=100.0,
|
|
format="%.6f",
|
|
width="small",
|
|
),
|
|
"is_colorante": st.column_config.CheckboxColumn(
|
|
"Colorante",
|
|
help="Coloranti o CAS speciali: bypassa la validazione del formato CAS",
|
|
default=False,
|
|
width="small",
|
|
),
|
|
"skip_tox": st.column_config.CheckboxColumn(
|
|
"Salta Tox",
|
|
help="Salta la valutazione tossicologica (automatico per AQUA/Water)",
|
|
default=False,
|
|
width="small",
|
|
),
|
|
}
|
|
|
|
edited_df = st.data_editor(
|
|
st.session_state.ingredient_df,
|
|
column_config=column_config,
|
|
num_rows="dynamic",
|
|
use_container_width=True,
|
|
hide_index=True,
|
|
key="ingredients_editor",
|
|
)
|
|
|
|
# Info per l'utente sulla rilevazione automatica AQUA/Water
|
|
_inci_col = edited_df["inci"].fillna("")
|
|
_aqua_count = sum(is_water_inci(v) for v in _inci_col)
|
|
if _aqua_count > 0:
|
|
st.info(f"{_aqua_count} ingrediente/i rilevato/i come AQUA/Water: tossicologia saltata automaticamente.")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Validazione
|
|
# ---------------------------------------------------------------------------
|
|
errors = validate_order(client_name, product_name, selected_preset, edited_df)
|
|
|
|
if errors:
|
|
st.markdown("---")
|
|
for err in errors:
|
|
st.error(err)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Submit
|
|
# ---------------------------------------------------------------------------
|
|
st.markdown("---")
|
|
|
|
can_submit = (len(errors) == 0) and (not st.session_state.order_submitted)
|
|
|
|
if st.button("Invia Ordine", type="primary", disabled=not can_submit):
|
|
payload = build_order_payload(client_name, product_name, selected_preset, edited_df)
|
|
st.session_state.order_result = payload
|
|
|
|
with st.spinner("Invio ordine in corso..."):
|
|
try:
|
|
resp = requests.post(
|
|
f"{API_BASE}/orders/create",
|
|
json=payload,
|
|
timeout=30,
|
|
)
|
|
result = resp.json()
|
|
|
|
if result.get("success"):
|
|
st.session_state.order_submitted = True
|
|
id_ordine = result.get("id_ordine")
|
|
st.success(f"Ordine #{id_ordine} creato. Elaborazione avviata in background.")
|
|
display_orderData(payload)
|
|
else:
|
|
st.error(result.get("error") or result.get("detail", "Errore nella creazione dell'ordine"))
|
|
|
|
except requests.ConnectionError:
|
|
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo su localhost:8000.")
|
|
except Exception as e:
|
|
st.error(f"Errore: {e}")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Debug JSON
|
|
# ---------------------------------------------------------------------------
|
|
if st.session_state.order_result is not None:
|
|
with st.expander("Debug - Raw JSON"):
|
|
st.json(st.session_state.order_result)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Reset
|
|
# ---------------------------------------------------------------------------
|
|
st.markdown("---")
|
|
if st.button("Nuovo Ordine", key="reset_order"):
|
|
st.session_state.order_submitted = False
|
|
st.session_state.order_result = None
|
|
st.session_state.ingredient_df = pd.DataFrame({
|
|
"inci": [""] * 5,
|
|
"cas": [""] * 5,
|
|
"percentage": [0.0] * 5,
|
|
"is_colorante": [False] * 5,
|
|
"skip_tox": [False] * 5,
|
|
})
|
|
# Pulisci lo stato interno del widget data_editor
|
|
if "ingredients_editor" in st.session_state:
|
|
del st.session_state["ingredients_editor"]
|
|
st.rerun()
|