# app.py # Inventory Management Assistant – HuggingFace / Gradio app import os from typing import Dict, List, Tuple import pandas as pd import gradio as gr # ----------------------------- Data Load ----------------------------- # DATA_FILES: Dict[str, List[str]] = { # --- exact files you have in /data/ --- "backlog": [ "data/Backlog_updated.xlsx", ], "inventory": [ "data/Inventory_updated.xlsx", ], "billing": [ "data/Billing_updated.xlsx", ], "lead_time": [ "data/Lead time_updated.xlsx", # note the space ], "purchase_orders": [ "data/Purchase Orders_updated.xlsx", # note the space ], "safety_stock": [ "data/safety_stock_updated.xlsx", ], } def _load_first_existing(path_candidates: List[str]) -> pd.DataFrame: """Try all candidate paths and return the first one that exists.""" for p in path_candidates: if os.path.exists(p): if p.lower().endswith(".csv"): return pd.read_csv(p) else: return pd.read_excel(p) raise FileNotFoundError(f"None of these files were found: {path_candidates}") def load_all_data() -> Dict[str, pd.DataFrame]: data = {} missing = [] for key, paths in DATA_FILES.items(): try: df = _load_first_existing(paths) data[key] = df except FileNotFoundError: missing.append(key) if missing: # Fail loudly so HF logs show which logical tables are missing raise RuntimeError(f"Data load error – missing logical tables: {missing}") return data DATA = load_all_data() INV = DATA["inventory"] BACKLOG = DATA["backlog"] # ----------------------------- Helper functions ----------------------------- # def _pick(colnames: List[str], candidates: List[str]): for c in candidates: if c in colnames: return c return None def _basic_inventory_view(df: pd.DataFrame, top_n: int = 20) -> pd.DataFrame: """Return a light, generic view that won't break if columns differ.""" cols = df.columns.tolist() mat_col = _pick(cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) desc_col = _pick(cols, ["MATERIAL_DESCRIPTION", "MAT_DESC", "DESCRIPTION"]) plant_col = _pick(cols, ["PLANT", "LOCATION", "SITE"]) qty_col = _pick(cols, ["QOH", "QTY", "UNRESTRICTED_STOCK", "TOTAL_STOCK"]) age_col = _pick(cols, ["AGE_DAYS", "DAYS_ON_HAND", "DAYS_COVER"]) selected = [c for c in [mat_col, desc_col, plant_col, qty_col, age_col] if c] if not selected: return df.head(top_n) return df[selected].head(top_n) # ----------------------------- Business Logic ----------------------------- # def get_fast_moving_materials(top_n: int = 25) -> pd.DataFrame: """Very simple heuristic: lowest days cover / age, then highest demand/qty.""" df = INV.copy() cols = df.columns.tolist() days_cover_col = _pick(cols, ["DAYS_COVER", "AGE_DAYS", "DAYS_ON_HAND"]) demand_col = _pick(cols, ["AVG_DAILY_DEMAND", "DEMAND_PER_DAY", "ISSUES_PER_DAY"]) qty_col = _pick(cols, ["QOH", "QTY", "UNRESTRICTED_STOCK", "TOTAL_STOCK"]) if days_cover_col: df = df.sort_values(by=days_cover_col, ascending=True) elif demand_col: df = df.sort_values(by=demand_col, ascending=False) elif qty_col: df = df.sort_values(by=qty_col, ascending=False) return _basic_inventory_view(df, top_n=top_n) def get_dead_stock(top_n: int = 25) -> pd.DataFrame: """Heuristic: highest age / lowest movement.""" df = INV.copy() cols = df.columns.tolist() age_col = _pick(cols, ["AGE_DAYS", "DAYS_ON_HAND", "DAYS_SINCE_MOVEMENT"]) if age_col: df = df.sort_values(by=age_col, ascending=False) else: # fallback: just low-qty materials qty_col = _pick(cols, ["QOH", "QTY", "UNRESTRICTED_STOCK", "TOTAL_STOCK"]) if qty_col: df = df.sort_values(by=qty_col, ascending=True) return _basic_inventory_view(df, top_n=top_n) def get_reallocation_opportunities(top_n: int = 25) -> pd.DataFrame: """ Simple cross-plant reallocation view: - Uses inventory + backlog. - Marks surplus/shortage per material/plant. """ inv = INV.copy() bl = BACKLOG.copy() inv_cols = inv.columns.tolist() bl_cols = bl.columns.tolist() mat_col_i = _pick(inv_cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) plant_col_i = _pick(inv_cols, ["PLANT", "LOCATION", "SITE"]) qty_col_i = _pick(inv_cols, ["QOH", "QTY", "UNRESTRICTED_STOCK", "TOTAL_STOCK"]) mat_col_b = _pick(bl_cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) plant_col_b = _pick(bl_cols, ["PLANT", "LOCATION", "SITE"]) demand_col_b = _pick(bl_cols, ["OPEN_QTY", "DEMAND_QTY", "BACKLOG_QTY"]) required = [mat_col_i, plant_col_i, qty_col_i, mat_col_b, plant_col_b, demand_col_b] if any(c is None for c in required): # If columns don't line up yet, just show generic message. return pd.DataFrame( { "Message": [ "Reallocation logic needs aligned columns in inventory & backlog.", f"Inventory columns: {inv_cols}", f"Backlog columns: {bl_cols}", ] } ) inv_agg = ( inv.groupby([mat_col_i, plant_col_i])[qty_col_i] .sum() .reset_index() .rename(columns={qty_col_i: "QOH"}) ) bl_agg = ( bl.groupby([mat_col_b, plant_col_b])[demand_col_b] .sum() .reset_index() .rename(columns={mat_col_b: mat_col_i, plant_col_b: plant_col_i, demand_col_b: "DEMAND"}) ) merged = inv_agg.merge(bl_agg, on=[mat_col_i, plant_col_i], how="outer").fillna(0) merged["NET"] = merged["QOH"] - merged["DEMAND"] # Mark surplus / shortage merged["STATUS"] = merged["NET"].apply( lambda x: "Surplus" if x > 0 else ("Shortage" if x < 0 else "Balanced") ) # Keep only materials which have at least one surplus and one shortage plant mat_status = ( merged.groupby(mat_col_i)["STATUS"] .agg(lambda s: set(s)) .reset_index() .rename(columns={"STATUS": "STATUS_SET"}) ) interesting_mats = mat_status[ mat_status["STATUS_SET"].apply(lambda s: {"Surplus", "Shortage"}.issubset(s)) ][mat_col_i] out = merged[merged[mat_col_i].isin(interesting_mats)] out = out.sort_values(by=[mat_col_i, "STATUS", "NET"]) return out.head(top_n * 4) # multiple rows per material def get_risk_recommendations(top_n: int = 25) -> pd.DataFrame: """ Very simple 'at-risk' view: - Net = demand – stock; positive = shortage. """ inv = INV.copy() bl = BACKLOG.copy() inv_cols = inv.columns.tolist() bl_cols = bl.columns.tolist() mat_col_i = _pick(inv_cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) plant_col_i = _pick(inv_cols, ["PLANT", "LOCATION", "SITE"]) qty_col_i = _pick(inv_cols, ["QOH", "QTY", "UNRESTRICTED_STOCK", "TOTAL_STOCK"]) mat_col_b = _pick(bl_cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) plant_col_b = _pick(bl_cols, ["PLANT", "LOCATION", "SITE"]) demand_col_b = _pick(bl_cols, ["OPEN_QTY", "DEMAND_QTY", "BACKLOG_QTY"]) required = [mat_col_i, plant_col_i, qty_col_i, mat_col_b, plant_col_b, demand_col_b] if any(c is None for c in required): return pd.DataFrame( { "Message": [ "Risk recommendations need aligned inventory & backlog columns.", f"Inventory columns: {inv_cols}", f"Backlog columns: {bl_cols}", ] } ) inv_agg = ( inv.groupby([mat_col_i, plant_col_i])[qty_col_i] .sum() .reset_index() .rename(columns={qty_col_i: "QOH"}) ) bl_agg = ( bl.groupby([mat_col_b, plant_col_b])[demand_col_b] .sum() .reset_index() .rename(columns={mat_col_b: mat_col_i, plant_col_b: plant_col_i, demand_col_b: "DEMAND"}) ) merged = inv_agg.merge(bl_agg, on=[mat_col_i, plant_col_i], how="outer").fillna(0) merged["SHORTAGE"] = merged["DEMAND"] - merged["QOH"] merged = merged[merged["SHORTAGE"] > 0] cols_out = [mat_col_i, plant_col_i, "QOH", "DEMAND", "SHORTAGE"] return merged[cols_out].sort_values("SHORTAGE", ascending=False).head(top_n) def search_inventory(query: str) -> pd.DataFrame: """Very light search by material number / description / plant.""" if not query: return _basic_inventory_view(INV, top_n=25) df = INV.copy() cols = df.columns.tolist() mat_col = _pick(cols, ["SAP_MATERIAL_NO", "MATERIAL", "MATERIAL_NO"]) desc_col = _pick(cols, ["MATERIAL_DESCRIPTION", "MAT_DESC", "DESCRIPTION"]) plant_col = _pick(cols, ["PLANT", "LOCATION", "SITE"]) mask = pd.Series([False] * len(df)) if mat_col: mask |= df[mat_col].astype(str).str.contains(query, case=False, na=False) if desc_col: mask |= df[desc_col].astype(str).str.contains(query, case=False, na=False) if plant_col: mask |= df[plant_col].astype(str).str.contains(query, case=False, na=False) results = df[mask] if results.empty: return pd.DataFrame({"Message": [f"No inventory rows found for '{query}'"]}) return _basic_inventory_view(results, top_n=50) # ----------------------------- Gradio Callbacks ----------------------------- # def handle_tile(tile: str, history: List[Tuple[str, str]]): if history is None: history = [] if tile == "fast": user_msg = "Show me fast moving materials." df = get_fast_moving_materials() assistant_msg = "Here are the current fast-moving materials based on days cover / age." elif tile == "reallocate": user_msg = "Show stock reallocation possibilities." df = get_reallocation_opportunities() assistant_msg = "These materials have surplus at some plants and shortages at others." elif tile == "risk": user_msg = "Show inventory risk recommendations." df = get_risk_recommendations() assistant_msg = "These materials have net shortages based on backlog vs available stock." elif tile == "dead": user_msg = "Show dead / slow-moving stock." df = get_dead_stock() assistant_msg = "These materials appear to be slow-moving or dead stock." else: user_msg = "Unknown action." df = pd.DataFrame({"Message": ["Unknown tile clicked."]}) assistant_msg = "I couldn't identify that tile." history = history + [(("user"), user_msg), (("assistant"), assistant_msg)] return history, df def handle_search(message: str, history: List[Tuple[str, str]]): if history is None: history = [] history = history + [("user", message)] df = search_inventory(message) assistant_msg = "Here is what I found in inventory for your search." history = history + [("assistant", assistant_msg)] return "", history, df # ----------------------------- UI Layout ----------------------------- # CUSTOM_CSS = """ .gradio-container {font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;} #header-bar {background-color: #002b5c; color: white; padding: 10px 16px; font-size: 20px; font-weight: 600;} .tile-row button {height: 60px; font-size: 16px; font-weight: 600;} #faq-bar {background-color: #003f87; color: white; padding: 8px 16px; margin-top: 8px; border-radius: 8px; font-size: 15px; font-weight: 500;} """ with gr.Blocks(css=CUSTOM_CSS, title="Inventory Management Assistant") as demo: gr.HTML('
Inventory Assistant
') with gr.Row(elem_id="tile-row"): btn_fast = gr.Button("Fast Moving Materials") btn_reallocate = gr.Button("Stock Reallocation") btn_risk = gr.Button("Risk Recommendations") btn_dead = gr.Button("Dead Stock Materials") gr.HTML( '
💡 FAQ: Where are we at risk on inventory, and where can we reallocate stock?
' ) chatbot = gr.Chatbot(label="Inventory Assistant", height=260) results_table = gr.Dataframe( headers=[], datatype="auto", label="Results", interactive=False, visible=True, wrap=True, height=260, ) with gr.Row(): txt = gr.Textbox( placeholder="Ask about materials, plants or inventory…", show_label=False, scale=5, ) btn_search = gr.Button("Search", scale=1) # Wire the tiles btn_fast.click( fn=lambda h: handle_tile("fast", h), inputs=chatbot, outputs=[chatbot, results_table], ) btn_reallocate.click( fn=lambda h: handle_tile("reallocate", h), inputs=chatbot, outputs=[chatbot, results_table], ) btn_risk.click( fn=lambda h: handle_tile("risk", h), inputs=chatbot, outputs=[chatbot, results_table], ) btn_dead.click( fn=lambda h: handle_tile("dead", h), inputs=chatbot, outputs=[chatbot, results_table], ) # Wire the search bar btn_search.click( fn=handle_search, inputs=[txt, chatbot], outputs=[txt, chatbot, results_table], ) txt.submit( fn=handle_search, inputs=[txt, chatbot], outputs=[txt, chatbot, results_table], ) if __name__ == "__main__": demo.launch()