import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
from datetime import datetime, timedelta
from dateutil.parser import parse
# ---------------------------
# App Config and Theming
# ---------------------------
st.set_page_config(
page_title="Procurement Agent – S/4HANA Embedded Analytics (Demo)",
page_icon="🧭",
layout="wide",
initial_sidebar_state="expanded",
)
# Subtle CSS polish for a premium feel
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# ---------------------------
# Data Loading (Synthetic “CDS-like”)
# ---------------------------
@st.cache_data
def load_data():
df = pd.read_csv("data/synthetic_procurement.csv", parse_dates=["PO_Date","DeliveryDate","GR_Date","IR_Date"])
# Derived fields similar to embedded analytics
df["DaysToDeliver"] = (df["DeliveryDate"] - df["PO_Date"]).dt.days
df["IsOpen"] = df["Status"].eq("Open")
return df
df = load_data()
# ---------------------------
# Sidebar Filters
# ---------------------------
with st.sidebar:
st.image("https://huggingface.co/front/assets/huggingface_logo-noborder.svg", width=120)
st.title("Procurement Agent")
st.caption("S/4HANA Embedded Analytics – Learning Demo (Synthetic data)")
# Time filter
max_date = df["PO_Date"].max()
default_start = max_date - timedelta(days=45)
date_range = st.date_input("PO Date Range", (default_start, max_date))
# Codelists
company = st.multiselect("Company Code", sorted(df["CompanyCode"].unique().tolist()))
plants = st.multiselect("Plant", sorted(df["Plant"].unique().tolist()))
mat_groups = st.multiselect("Material Group", sorted(df["MaterialGroup"].unique().tolist()))
suppliers = st.multiselect("Supplier", sorted(df["Supplier"].unique().tolist()))
buyers = st.multiselect("Buyer", sorted(df["Buyer"].unique().tolist()))
status_sel = st.multiselect("Status", sorted(df["Status"].unique().tolist()))
st.markdown("---")
st.subheader("Demo actions")
if st.button("Reset Filters"):
st.session_state.clear()
st.rerun()
# Apply filters
def apply_filters(df):
dff = df.copy()
if isinstance(date_range, tuple) and len(date_range) == 2:
start_date, end_date = pd.to_datetime(date_range[0]), pd.to_datetime(date_range[1])
dff = dff[(dff["PO_Date"] >= start_date) & (dff["PO_Date"] <= end_date)]
if company:
dff = dff[dff["CompanyCode"].isin(company)]
if plants:
dff = dff[dff["Plant"].isin(plants)]
if mat_groups:
dff = dff[dff["MaterialGroup"].isin(mat_groups)]
if suppliers:
dff = dff[dff["Supplier"].isin(suppliers)]
if buyers:
dff = dff[dff["Buyer"].isin(buyers)]
if status_sel:
dff = dff[dff["Status"].isin(status_sel)]
return dff
fdf = apply_filters(df)
# ---------------------------
# KPI Header
# ---------------------------
def kpi_card(label, value, sub=""):
st.markdown(
f"""
""",
unsafe_allow_html=True,
)
col1, col2, col3, col4 = st.columns(4)
with col1:
total_po = fdf["PO_ID"].nunique()
kpi_card("Purchase Orders", f"{total_po:,}", "Unique POs in selection")
with col2:
spend = fdf["NetValue"].sum()
kpi_card("Net Spend", f"${spend:,.0f}", "Sum of PO item values")
with col3:
avg_lt = fdf["LeadTimeDays"].mean() if len(fdf) else 0
kpi_card("Avg Lead Time", f"{avg_lt:.1f}d", "Supplier cycle time")
with col4:
otif = fdf["OTIF"].mean() * 100 if len(fdf) else 0
kpi_card("OTIF", f"{otif:.0f}%", "On-time in-full rate")
st.markdown("")
# ---------------------------
# Tabs: Overview | Supplier Insights | Explorer | Simulations
# ---------------------------
tab1, tab2, tab3, tab4 = st.tabs(["Overview", "Supplier Insights", "Explorer", "Simulations"])
with tab1:
c1, c2 = st.columns([1.3, 1])
with c1:
st.subheader("Spend by Supplier")
if len(fdf):
fig = px.bar(
fdf.groupby("Supplier", as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False),
x="Supplier", y="NetValue", color="Supplier", height=380, template="plotly_white",
hover_data={"NetValue":":,.0f"}
)
st.plotly_chart(fig, use_container_width=True)
else:
st.info("No data for selected filters.")
st.subheader("Material Group Mix")
if len(fdf):
fig2 = px.pie(
fdf, names="MaterialGroup", values="NetValue", hole=0.45, template="plotly_white",
height=380
)
st.plotly_chart(fig2, use_container_width=True)
with c2:
st.subheader("Lead Time by Supplier")
if len(fdf):
g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean().sort_values("LeadTimeDays")
fig3 = px.bar(g, x="LeadTimeDays", y="Supplier", orientation="h", height=380, template="plotly_white")
st.plotly_chart(fig3, use_container_width=True)
st.subheader("OTIF by Supplier")
if len(fdf):
g2 = fdf.groupby("Supplier", as_index=False)["OTIF"].mean()
g2["OTIF%"] = (g2["OTIF"] * 100).round(1)
fig4 = px.scatter(g2, x="Supplier", y="OTIF%", size="OTIF%", color="Supplier", height=340, template="plotly_white")
st.plotly_chart(fig4, use_container_width=True)
with tab2:
st.subheader("Supplier Scorecard")
sup = st.selectbox("Choose supplier", sorted(df["Supplier"].unique().tolist()))
sdf = fdf[fdf["Supplier"] == sup]
if len(sdf):
c1, c2, c3 = st.columns(3)
with c1:
kpi_card("Spend", f"${sdf['NetValue'].sum():,.0f}")
with c2:
kpi_card("Avg Price", f"${sdf['NetPrice'].mean():.2f}/unit")
with c3:
kpi_card("OTIF", f"{(sdf['OTIF'].mean()*100):.0f}%")
st.markdown("")
c4, c5 = st.columns(2)
with c4:
st.caption("Lead time trend (by PO date)")
trend = sdf.sort_values("PO_Date").groupby("PO_Date", as_index=False)["LeadTimeDays"].mean()
fig5 = px.line(trend, x="PO_Date", y="LeadTimeDays", markers=True, template="plotly_white", height=340)
st.plotly_chart(fig5, use_container_width=True)
with c5:
st.caption("Price distribution")
fig6 = px.histogram(sdf, x="NetPrice", nbins=10, template="plotly_white", height=340)
st.plotly_chart(fig6, use_container_width=True)
st.subheader("Recent PO Lines")
st.dataframe(
sdf.sort_values("PO_Date", ascending=False)[
["PO_ID","PO_Item","PO_Date","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
].head(10),
use_container_width=True, height=300
)
else:
st.info("No lines for selected supplier within current filters.")
with tab3:
st.subheader("Interactive Explorer")
dims = ["CompanyCode","Plant","MaterialGroup","Supplier","Buyer","Status"]
sel_dim = st.selectbox("Dimension", dims, index=3)
sel_mea = st.selectbox("Measure", ["NetValue","Quantity","NetPrice","LeadTimeDays","OTIF"], index=0)
if len(fdf):
g = fdf.groupby(sel_dim, as_index=False)[sel_mea].mean() if sel_mea in ["NetPrice","LeadTimeDays","OTIF"] else \
fdf.groupby(sel_dim, as_index=False)[sel_mea].sum()
fig7 = px.bar(g.sort_values(sel_mea, ascending=False).head(15), x=sel_dim, y=sel_mea, color=sel_dim, template="plotly_white", height=420)
st.plotly_chart(fig7, use_container_width=True)
st.dataframe(g.sort_values(sel_mea, ascending=False), use_container_width=True, height=260)
else:
st.info("Adjust filters to see data.")
with tab4:
st.subheader("What-if: Payment Terms and Delivery Delays")
# Simple simulation: change payment terms and hypothetical delay impact on OTIF
term_delta = st.slider("Payment term change (days)", -30, 30, 0, step=5)
delay_rate = st.slider("Simulate delivery delay rate (%)", 0, 50, 10, step=5)
def run_sim(df_in, term_delta, delay_rate):
sim = df_in.copy()
# Adjust payment days
sim["PaymentDaysSim"] = sim["PaymentDays"] + term_delta
# Apply simple OTIF penalty based on delay rate
penalty = delay_rate / 100.0
sim["OTIF_Sim"] = np.clip(sim["OTIF"] * (1 - penalty) + (1 - sim["OTIF"]) * (1 - penalty/2), 0, 1)
# Assume carrying cost impact: +0.02% per extra payment day on spend
delta_days = np.maximum(sim["PaymentDaysSim"] - sim["PaymentDays"], 0)
sim["CarryingCostAdj"] = sim["NetValue"] * (0.0002 * delta_days)
return sim
if len(fdf):
simdf = run_sim(fdf, term_delta, delay_rate)
c1, c2, c3 = st.columns(3)
with c1:
kpi_card("OTIF (Simulated)", f"{(simdf['OTIF_Sim'].mean()*100):.0f}%")
with c2:
kpi_card("PaymentDays Δ", f"{term_delta:+d}d")
with c3:
kpi_card("Carrying Cost Adj", f"${simdf['CarryingCostAdj'].sum():,.0f}")
st.caption("Supplier-level OTIF change")
g = simdf.groupby("Supplier", as_index=False)[["OTIF","OTIF_Sim"]].mean()
g["OTIF"] = (g["OTIF"]*100).round(1)
g["OTIF_Sim"] = (g["OTIF_Sim"]*100).round(1)
fig8 = px.bar(g.melt(id_vars="Supplier", value_vars=["OTIF","OTIF_Sim"], var_name="Metric", value_name="OTIF%"), x="Supplier", y="OTIF%", color="Metric", barmode="group", template="plotly_white", height=400)
st.plotly_chart(fig8, use_container_width=True)
st.dataframe(g.sort_values("OTIF_Sim", ascending=False), use_container_width=True, height=260)
else:
st.info("No data to simulate. Adjust filters.")
# ---------------------------
# Agentic Chat (Demo)
# ---------------------------
st.markdown("---")
st.subheader("Agent Assistant")
st.caption("Ask procurement questions, e.g., “Top suppliers by OTIF this month,” “Compare ACME vs GLOBAL_MFG on price and lead time,” “Show spend by RM group last 30 days.”")
if "messages" not in st.session_state:
st.session_state.messages = [
{"role": "assistant", "content": "Hello! I can analyze procurement data, compute KPIs, and run what‑if simulations. What would you like to see?"}
]
# Simple tool functions (CDS-like queries)
def tool_top_suppliers_by(metric="OTIF", topn=5):
if not len(fdf): return "No data in current selection."
g = fdf.groupby("Supplier", as_index=False)[metric].mean()
if metric != "OTIF":
# For value metrics that make sense as sum (e.g., NetValue)
if metric in ["NetValue","Quantity"]:
g = fdf.groupby("Supplier", as_index=False)[metric].sum()
g = g.sort_values(metric, ascending=False).head(topn)
return g
def tool_compare_suppliers(sup_a, sup_b):
sub = fdf[fdf["Supplier"].isin([sup_a, sup_b])]
if not len(sub): return "No data for those suppliers in current selection."
stats = sub.groupby("Supplier").agg(
Spend=("NetValue","sum"),
AvgPrice=("NetPrice","mean"),
AvgLead=("LeadTimeDays","mean"),
OTIF=("OTIF","mean")
).reset_index()
stats["OTIF%"] = (stats["OTIF"]*100).round(1)
return stats
def tool_spend_by_dim(dim="MaterialGroup"):
if not len(fdf): return None
g = fdf.groupby(dim, as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False)
return g
def tool_show_recent_po_lines(n=10):
if not len(fdf): return None
cols = ["PO_ID","PO_Item","PO_Date","Supplier","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
return fdf.sort_values("PO_Date", ascending=False)[cols].head(n)
# Heuristic “planner” to route user intents to tools
def agent_router(prompt: str):
p = prompt.lower().strip()
# pattern routes
if "top" in p and "supplier" in p and "otif" in p:
n = 5
for tok in p.split():
if tok.isdigit():
n = int(tok)
break
return ("top_suppliers_otif", {"topn": n})
if "compare" in p and "supplier" in p:
# naive extract A vs B
tokens = p.replace("compare","").replace("supplier","").replace("suppliers","").replace(" vs "," ").split()
# heuristic: choose two known supplier names intersected
known = set(df["Supplier"].unique().tolist())
picks = [t for t in tokens if t.upper() in known]
if len(picks) >= 2:
return ("compare_suppliers", {"a": picks[0].upper(), "b": picks[1].upper()})
return ("compare_suppliers", {"a": "ACME_SUPPLY", "b": "GLOBAL_MFG"})
if "spend" in p and ("material group" in p or "group" in p):
return ("spend_by_dim", {"dim": "MaterialGroup"})
if "recent" in p or ("last" in p and "po" in p):
return ("recent_pos", {"n": 10})
if "lead time" in p and "supplier" in p:
return ("lead_by_supplier", {})
if "price" in p and "supplier" in p:
return ("price_by_supplier", {})
# default: summary
return ("summary", {})
def agent_execute(route, args):
if route == "top_suppliers_otif":
g = tool_top_suppliers_by("OTIF", topn=args.get("topn",5))
if isinstance(g, str):
return g, None
g2 = g.copy()
g2["OTIF%"] = (g2["OTIF"]*100).round(1)
fig = px.bar(g2, x="Supplier", y="OTIF%", color="Supplier", template="plotly_white", height=360)
return "Top suppliers by OTIF:", fig
if route == "compare_suppliers":
stats = tool_compare_suppliers(args.get("a"), args.get("b"))
if isinstance(stats, str):
return stats, None
fig = px.bar(stats, x="Supplier", y=["Spend","AvgPrice","AvgLead","OTIF"], barmode="group", template="plotly_white", height=420)
return "Comparison across Spend, AvgPrice, AvgLead, and OTIF:", fig
if route == "spend_by_dim":
g = tool_spend_by_dim(args.get("dim","MaterialGroup"))
if g is None:
return "No data for spend by dimension.", None
fig = px.treemap(g, path=[args.get("dim","MaterialGroup")], values="NetValue", height=420)
return f"Spend by {args.get('dim','MaterialGroup')}:", fig
if route == "recent_pos":
lines = tool_show_recent_po_lines(args.get("n",10))
if lines is None:
return "No recent PO lines found.", None
st.dataframe(lines, use_container_width=True, height=260)
return f"Showing {len(lines)} most recent PO lines.", None
if route == "lead_by_supplier":
if not len(fdf): return "No data.", None
g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean()
fig = px.bar(g.sort_values("LeadTimeDays"), x="LeadTimeDays", y="Supplier", orientation="h", template="plotly_white", height=420)
return "Average lead time by supplier:", fig
if route == "price_by_supplier":
if not len(fdf): return "No data.", None
g = fdf.groupby("Supplier", as_index=False)["NetPrice"].mean()
fig = px.bar(g.sort_values("NetPrice", ascending=False), x="Supplier", y="NetPrice", template="plotly_white", height=420)
return "Average price by supplier:", fig
# summary
msg = f"In current selection: {fdf['PO_ID'].nunique()} POs, spend ${fdf['NetValue'].sum():,.0f}, avg lead time {fdf['LeadTimeDays'].mean():.1f}d, OTIF {(fdf['OTIF'].mean()*100):.0f}%."
return msg, None
# Render chat history
for m in st.session_state.messages:
with st.chat_message(m["role"], avatar="🧭" if m["role"]=="assistant" else "🧑🏻"):
st.write(m["content"])
prompt = st.chat_input("Ask about procurement performance, suppliers, KPIs, or simulations…")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user", avatar="🧑🏻"):
st.write(prompt)
with st.chat_message("assistant", avatar="🧭"):
with st.status("Thinking…", expanded=False):
route, args = agent_router(prompt)
text, fig = agent_execute(route, args)
if text:
st.write(text)
if fig is not None:
st.plotly_chart(fig, use_container_width=True)
st.session_state.messages.append({"role": "assistant", "content": text or "(shown as chart/table)"})