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()