lmb-fe/pages/order_page.py
adish-rmr 584de757bb fix
2026-02-22 19:44:27 +01:00

501 lines
18 KiB
Python

import streamlit as st
import requests
import pandas as pd
import re
API_BASE = "https://api.cosmoguard.it/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), width='stretch', 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",
width="stretch",
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()