Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +402 -38
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,404 @@
|
|
| 1 |
-
import altair as alt
|
| 2 |
-
import numpy as np
|
| 3 |
-
import pandas as pd
|
| 4 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
forums](https://discuss.streamlit.io).
|
| 12 |
-
|
| 13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import plotly.express as px
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from dateutil.parser import parse
|
| 7 |
+
|
| 8 |
+
# ---------------------------
|
| 9 |
+
# App Config and Theming
|
| 10 |
+
# ---------------------------
|
| 11 |
+
st.set_page_config(
|
| 12 |
+
page_title="Procurement Agent – S/4HANA Embedded Analytics (Demo)",
|
| 13 |
+
page_icon="🧭",
|
| 14 |
+
layout="wide",
|
| 15 |
+
initial_sidebar_state="expanded",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Subtle CSS polish for a premium feel
|
| 19 |
+
st.markdown(
|
| 20 |
+
"""
|
| 21 |
+
<style>
|
| 22 |
+
.kpi-card {
|
| 23 |
+
padding: 14px 16px; border-radius: 12px; background: #0a0a0a0d;
|
| 24 |
+
border: 1px solid #e6e6e6; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
| 25 |
+
}
|
| 26 |
+
.metric-label { font-size: 12px; color: #666; margin-bottom: 6px; }
|
| 27 |
+
.metric-value { font-size: 26px; font-weight: 700; }
|
| 28 |
+
.metric-sub { font-size: 12px; color: #999; }
|
| 29 |
+
.stChatFloatingInputContainer { border-top: 1px solid #eee; }
|
| 30 |
+
.st-emotion-cache-1avcm0n { padding-top: 0 !important; }
|
| 31 |
+
.rounded-img { border-radius: 50%; }
|
| 32 |
+
</style>
|
| 33 |
+
""",
|
| 34 |
+
unsafe_allow_html=True,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# ---------------------------
|
| 38 |
+
# Data Loading (Synthetic “CDS-like”)
|
| 39 |
+
# ---------------------------
|
| 40 |
+
@st.cache_data
|
| 41 |
+
def load_data():
|
| 42 |
+
df = pd.read_csv("data/synthetic_procurement.csv", parse_dates=["PO_Date","DeliveryDate","GR_Date","IR_Date"])
|
| 43 |
+
# Derived fields similar to embedded analytics
|
| 44 |
+
df["DaysToDeliver"] = (df["DeliveryDate"] - df["PO_Date"]).dt.days
|
| 45 |
+
df["IsOpen"] = df["Status"].eq("Open")
|
| 46 |
+
return df
|
| 47 |
+
|
| 48 |
+
df = load_data()
|
| 49 |
+
|
| 50 |
+
# ---------------------------
|
| 51 |
+
# Sidebar Filters
|
| 52 |
+
# ---------------------------
|
| 53 |
+
with st.sidebar:
|
| 54 |
+
st.image("https://huggingface.co/front/assets/huggingface_logo-noborder.svg", width=120)
|
| 55 |
+
st.title("Procurement Agent")
|
| 56 |
+
st.caption("S/4HANA Embedded Analytics – Learning Demo (Synthetic data)")
|
| 57 |
+
|
| 58 |
+
# Time filter
|
| 59 |
+
max_date = df["PO_Date"].max()
|
| 60 |
+
default_start = max_date - timedelta(days=45)
|
| 61 |
+
date_range = st.date_input("PO Date Range", (default_start, max_date))
|
| 62 |
+
|
| 63 |
+
# Codelists
|
| 64 |
+
company = st.multiselect("Company Code", sorted(df["CompanyCode"].unique().tolist()))
|
| 65 |
+
plants = st.multiselect("Plant", sorted(df["Plant"].unique().tolist()))
|
| 66 |
+
mat_groups = st.multiselect("Material Group", sorted(df["MaterialGroup"].unique().tolist()))
|
| 67 |
+
suppliers = st.multiselect("Supplier", sorted(df["Supplier"].unique().tolist()))
|
| 68 |
+
buyers = st.multiselect("Buyer", sorted(df["Buyer"].unique().tolist()))
|
| 69 |
+
status_sel = st.multiselect("Status", sorted(df["Status"].unique().tolist()))
|
| 70 |
+
|
| 71 |
+
st.markdown("---")
|
| 72 |
+
st.subheader("Demo actions")
|
| 73 |
+
if st.button("Reset Filters"):
|
| 74 |
+
st.session_state.clear()
|
| 75 |
+
st.rerun()
|
| 76 |
+
|
| 77 |
+
# Apply filters
|
| 78 |
+
def apply_filters(df):
|
| 79 |
+
dff = df.copy()
|
| 80 |
+
if isinstance(date_range, tuple) and len(date_range) == 2:
|
| 81 |
+
start_date, end_date = pd.to_datetime(date_range[0]), pd.to_datetime(date_range[1])
|
| 82 |
+
dff = dff[(dff["PO_Date"] >= start_date) & (dff["PO_Date"] <= end_date)]
|
| 83 |
+
if company:
|
| 84 |
+
dff = dff[dff["CompanyCode"].isin(company)]
|
| 85 |
+
if plants:
|
| 86 |
+
dff = dff[dff["Plant"].isin(plants)]
|
| 87 |
+
if mat_groups:
|
| 88 |
+
dff = dff[dff["MaterialGroup"].isin(mat_groups)]
|
| 89 |
+
if suppliers:
|
| 90 |
+
dff = dff[dff["Supplier"].isin(suppliers)]
|
| 91 |
+
if buyers:
|
| 92 |
+
dff = dff[dff["Buyer"].isin(buyers)]
|
| 93 |
+
if status_sel:
|
| 94 |
+
dff = dff[dff["Status"].isin(status_sel)]
|
| 95 |
+
return dff
|
| 96 |
+
|
| 97 |
+
fdf = apply_filters(df)
|
| 98 |
+
|
| 99 |
+
# ---------------------------
|
| 100 |
+
# KPI Header
|
| 101 |
+
# ---------------------------
|
| 102 |
+
def kpi_card(label, value, sub=""):
|
| 103 |
+
st.markdown(
|
| 104 |
+
f"""
|
| 105 |
+
<div class="kpi-card">
|
| 106 |
+
<div class="metric-label">{label}</div>
|
| 107 |
+
<div class="metric-value">{value}</div>
|
| 108 |
+
<div class="metric-sub">{sub}</div>
|
| 109 |
+
</div>
|
| 110 |
+
""",
|
| 111 |
+
unsafe_allow_html=True,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 115 |
+
with col1:
|
| 116 |
+
total_po = fdf["PO_ID"].nunique()
|
| 117 |
+
kpi_card("Purchase Orders", f"{total_po:,}", "Unique POs in selection")
|
| 118 |
+
with col2:
|
| 119 |
+
spend = fdf["NetValue"].sum()
|
| 120 |
+
kpi_card("Net Spend", f"${spend:,.0f}", "Sum of PO item values")
|
| 121 |
+
with col3:
|
| 122 |
+
avg_lt = fdf["LeadTimeDays"].mean() if len(fdf) else 0
|
| 123 |
+
kpi_card("Avg Lead Time", f"{avg_lt:.1f}d", "Supplier cycle time")
|
| 124 |
+
with col4:
|
| 125 |
+
otif = fdf["OTIF"].mean() * 100 if len(fdf) else 0
|
| 126 |
+
kpi_card("OTIF", f"{otif:.0f}%", "On-time in-full rate")
|
| 127 |
+
|
| 128 |
+
st.markdown("")
|
| 129 |
+
|
| 130 |
+
# ---------------------------
|
| 131 |
+
# Tabs: Overview | Supplier Insights | Explorer | Simulations
|
| 132 |
+
# ---------------------------
|
| 133 |
+
tab1, tab2, tab3, tab4 = st.tabs(["Overview", "Supplier Insights", "Explorer", "Simulations"])
|
| 134 |
+
|
| 135 |
+
with tab1:
|
| 136 |
+
c1, c2 = st.columns([1.3, 1])
|
| 137 |
+
with c1:
|
| 138 |
+
st.subheader("Spend by Supplier")
|
| 139 |
+
if len(fdf):
|
| 140 |
+
fig = px.bar(
|
| 141 |
+
fdf.groupby("Supplier", as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False),
|
| 142 |
+
x="Supplier", y="NetValue", color="Supplier", height=380, template="plotly_white",
|
| 143 |
+
hover_data={"NetValue":":,.0f"}
|
| 144 |
+
)
|
| 145 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 146 |
+
else:
|
| 147 |
+
st.info("No data for selected filters.")
|
| 148 |
+
|
| 149 |
+
st.subheader("Material Group Mix")
|
| 150 |
+
if len(fdf):
|
| 151 |
+
fig2 = px.pie(
|
| 152 |
+
fdf, names="MaterialGroup", values="NetValue", hole=0.45, template="plotly_white",
|
| 153 |
+
height=380
|
| 154 |
+
)
|
| 155 |
+
st.plotly_chart(fig2, use_container_width=True)
|
| 156 |
+
|
| 157 |
+
with c2:
|
| 158 |
+
st.subheader("Lead Time by Supplier")
|
| 159 |
+
if len(fdf):
|
| 160 |
+
g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean().sort_values("LeadTimeDays")
|
| 161 |
+
fig3 = px.bar(g, x="LeadTimeDays", y="Supplier", orientation="h", height=380, template="plotly_white")
|
| 162 |
+
st.plotly_chart(fig3, use_container_width=True)
|
| 163 |
+
|
| 164 |
+
st.subheader("OTIF by Supplier")
|
| 165 |
+
if len(fdf):
|
| 166 |
+
g2 = fdf.groupby("Supplier", as_index=False)["OTIF"].mean()
|
| 167 |
+
g2["OTIF%"] = (g2["OTIF"] * 100).round(1)
|
| 168 |
+
fig4 = px.scatter(g2, x="Supplier", y="OTIF%", size="OTIF%", color="Supplier", height=340, template="plotly_white")
|
| 169 |
+
st.plotly_chart(fig4, use_container_width=True)
|
| 170 |
+
|
| 171 |
+
with tab2:
|
| 172 |
+
st.subheader("Supplier Scorecard")
|
| 173 |
+
sup = st.selectbox("Choose supplier", sorted(df["Supplier"].unique().tolist()))
|
| 174 |
+
sdf = fdf[fdf["Supplier"] == sup]
|
| 175 |
+
if len(sdf):
|
| 176 |
+
c1, c2, c3 = st.columns(3)
|
| 177 |
+
with c1:
|
| 178 |
+
kpi_card("Spend", f"${sdf['NetValue'].sum():,.0f}")
|
| 179 |
+
with c2:
|
| 180 |
+
kpi_card("Avg Price", f"${sdf['NetPrice'].mean():.2f}/unit")
|
| 181 |
+
with c3:
|
| 182 |
+
kpi_card("OTIF", f"{(sdf['OTIF'].mean()*100):.0f}%")
|
| 183 |
+
|
| 184 |
+
st.markdown("")
|
| 185 |
+
|
| 186 |
+
c4, c5 = st.columns(2)
|
| 187 |
+
with c4:
|
| 188 |
+
st.caption("Lead time trend (by PO date)")
|
| 189 |
+
trend = sdf.sort_values("PO_Date").groupby("PO_Date", as_index=False)["LeadTimeDays"].mean()
|
| 190 |
+
fig5 = px.line(trend, x="PO_Date", y="LeadTimeDays", markers=True, template="plotly_white", height=340)
|
| 191 |
+
st.plotly_chart(fig5, use_container_width=True)
|
| 192 |
+
with c5:
|
| 193 |
+
st.caption("Price distribution")
|
| 194 |
+
fig6 = px.histogram(sdf, x="NetPrice", nbins=10, template="plotly_white", height=340)
|
| 195 |
+
st.plotly_chart(fig6, use_container_width=True)
|
| 196 |
+
|
| 197 |
+
st.subheader("Recent PO Lines")
|
| 198 |
+
st.dataframe(
|
| 199 |
+
sdf.sort_values("PO_Date", ascending=False)[
|
| 200 |
+
["PO_ID","PO_Item","PO_Date","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
|
| 201 |
+
].head(10),
|
| 202 |
+
use_container_width=True, height=300
|
| 203 |
+
)
|
| 204 |
+
else:
|
| 205 |
+
st.info("No lines for selected supplier within current filters.")
|
| 206 |
+
|
| 207 |
+
with tab3:
|
| 208 |
+
st.subheader("Interactive Explorer")
|
| 209 |
+
dims = ["CompanyCode","Plant","MaterialGroup","Supplier","Buyer","Status"]
|
| 210 |
+
sel_dim = st.selectbox("Dimension", dims, index=3)
|
| 211 |
+
sel_mea = st.selectbox("Measure", ["NetValue","Quantity","NetPrice","LeadTimeDays","OTIF"], index=0)
|
| 212 |
+
|
| 213 |
+
if len(fdf):
|
| 214 |
+
g = fdf.groupby(sel_dim, as_index=False)[sel_mea].mean() if sel_mea in ["NetPrice","LeadTimeDays","OTIF"] else \
|
| 215 |
+
fdf.groupby(sel_dim, as_index=False)[sel_mea].sum()
|
| 216 |
+
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)
|
| 217 |
+
st.plotly_chart(fig7, use_container_width=True)
|
| 218 |
+
st.dataframe(g.sort_values(sel_mea, ascending=False), use_container_width=True, height=260)
|
| 219 |
+
else:
|
| 220 |
+
st.info("Adjust filters to see data.")
|
| 221 |
+
|
| 222 |
+
with tab4:
|
| 223 |
+
st.subheader("What-if: Payment Terms and Delivery Delays")
|
| 224 |
+
|
| 225 |
+
# Simple simulation: change payment terms and hypothetical delay impact on OTIF
|
| 226 |
+
term_delta = st.slider("Payment term change (days)", -30, 30, 0, step=5)
|
| 227 |
+
delay_rate = st.slider("Simulate delivery delay rate (%)", 0, 50, 10, step=5)
|
| 228 |
+
|
| 229 |
+
def run_sim(df_in, term_delta, delay_rate):
|
| 230 |
+
sim = df_in.copy()
|
| 231 |
+
# Adjust payment days
|
| 232 |
+
sim["PaymentDaysSim"] = sim["PaymentDays"] + term_delta
|
| 233 |
+
# Apply simple OTIF penalty based on delay rate
|
| 234 |
+
penalty = delay_rate / 100.0
|
| 235 |
+
sim["OTIF_Sim"] = np.clip(sim["OTIF"] * (1 - penalty) + (1 - sim["OTIF"]) * (1 - penalty/2), 0, 1)
|
| 236 |
+
# Assume carrying cost impact: +0.02% per extra payment day on spend
|
| 237 |
+
delta_days = np.maximum(sim["PaymentDaysSim"] - sim["PaymentDays"], 0)
|
| 238 |
+
sim["CarryingCostAdj"] = sim["NetValue"] * (0.0002 * delta_days)
|
| 239 |
+
return sim
|
| 240 |
+
|
| 241 |
+
if len(fdf):
|
| 242 |
+
simdf = run_sim(fdf, term_delta, delay_rate)
|
| 243 |
+
c1, c2, c3 = st.columns(3)
|
| 244 |
+
with c1:
|
| 245 |
+
kpi_card("OTIF (Simulated)", f"{(simdf['OTIF_Sim'].mean()*100):.0f}%")
|
| 246 |
+
with c2:
|
| 247 |
+
kpi_card("PaymentDays Δ", f"{term_delta:+d}d")
|
| 248 |
+
with c3:
|
| 249 |
+
kpi_card("Carrying Cost Adj", f"${simdf['CarryingCostAdj'].sum():,.0f}")
|
| 250 |
+
|
| 251 |
+
st.caption("Supplier-level OTIF change")
|
| 252 |
+
g = simdf.groupby("Supplier", as_index=False)[["OTIF","OTIF_Sim"]].mean()
|
| 253 |
+
g["OTIF"] = (g["OTIF"]*100).round(1)
|
| 254 |
+
g["OTIF_Sim"] = (g["OTIF_Sim"]*100).round(1)
|
| 255 |
+
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)
|
| 256 |
+
st.plotly_chart(fig8, use_container_width=True)
|
| 257 |
+
st.dataframe(g.sort_values("OTIF_Sim", ascending=False), use_container_width=True, height=260)
|
| 258 |
+
else:
|
| 259 |
+
st.info("No data to simulate. Adjust filters.")
|
| 260 |
+
|
| 261 |
+
# ---------------------------
|
| 262 |
+
# Agentic Chat (Demo)
|
| 263 |
+
# ---------------------------
|
| 264 |
+
st.markdown("---")
|
| 265 |
+
st.subheader("Agent Assistant")
|
| 266 |
+
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.”")
|
| 267 |
+
|
| 268 |
+
if "messages" not in st.session_state:
|
| 269 |
+
st.session_state.messages = [
|
| 270 |
+
{"role": "assistant", "content": "Hello! I can analyze procurement data, compute KPIs, and run what‑if simulations. What would you like to see?"}
|
| 271 |
+
]
|
| 272 |
+
|
| 273 |
+
# Simple tool functions (CDS-like queries)
|
| 274 |
+
def tool_top_suppliers_by(metric="OTIF", topn=5):
|
| 275 |
+
if not len(fdf): return "No data in current selection."
|
| 276 |
+
g = fdf.groupby("Supplier", as_index=False)[metric].mean()
|
| 277 |
+
if metric != "OTIF":
|
| 278 |
+
# For value metrics that make sense as sum (e.g., NetValue)
|
| 279 |
+
if metric in ["NetValue","Quantity"]:
|
| 280 |
+
g = fdf.groupby("Supplier", as_index=False)[metric].sum()
|
| 281 |
+
g = g.sort_values(metric, ascending=False).head(topn)
|
| 282 |
+
return g
|
| 283 |
+
|
| 284 |
+
def tool_compare_suppliers(sup_a, sup_b):
|
| 285 |
+
sub = fdf[fdf["Supplier"].isin([sup_a, sup_b])]
|
| 286 |
+
if not len(sub): return "No data for those suppliers in current selection."
|
| 287 |
+
stats = sub.groupby("Supplier").agg(
|
| 288 |
+
Spend=("NetValue","sum"),
|
| 289 |
+
AvgPrice=("NetPrice","mean"),
|
| 290 |
+
AvgLead=("LeadTimeDays","mean"),
|
| 291 |
+
OTIF=("OTIF","mean")
|
| 292 |
+
).reset_index()
|
| 293 |
+
stats["OTIF%"] = (stats["OTIF"]*100).round(1)
|
| 294 |
+
return stats
|
| 295 |
+
|
| 296 |
+
def tool_spend_by_dim(dim="MaterialGroup"):
|
| 297 |
+
if not len(fdf): return None
|
| 298 |
+
g = fdf.groupby(dim, as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False)
|
| 299 |
+
return g
|
| 300 |
+
|
| 301 |
+
def tool_show_recent_po_lines(n=10):
|
| 302 |
+
if not len(fdf): return None
|
| 303 |
+
cols = ["PO_ID","PO_Item","PO_Date","Supplier","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
|
| 304 |
+
return fdf.sort_values("PO_Date", ascending=False)[cols].head(n)
|
| 305 |
+
|
| 306 |
+
# Heuristic “planner” to route user intents to tools
|
| 307 |
+
def agent_router(prompt: str):
|
| 308 |
+
p = prompt.lower().strip()
|
| 309 |
+
# pattern routes
|
| 310 |
+
if "top" in p and "supplier" in p and "otif" in p:
|
| 311 |
+
n = 5
|
| 312 |
+
for tok in p.split():
|
| 313 |
+
if tok.isdigit():
|
| 314 |
+
n = int(tok)
|
| 315 |
+
break
|
| 316 |
+
return ("top_suppliers_otif", {"topn": n})
|
| 317 |
+
if "compare" in p and "supplier" in p:
|
| 318 |
+
# naive extract A vs B
|
| 319 |
+
tokens = p.replace("compare","").replace("supplier","").replace("suppliers","").replace(" vs "," ").split()
|
| 320 |
+
# heuristic: choose two known supplier names intersected
|
| 321 |
+
known = set(df["Supplier"].unique().tolist())
|
| 322 |
+
picks = [t for t in tokens if t.upper() in known]
|
| 323 |
+
if len(picks) >= 2:
|
| 324 |
+
return ("compare_suppliers", {"a": picks[0].upper(), "b": picks[1].upper()})
|
| 325 |
+
return ("compare_suppliers", {"a": "ACME_SUPPLY", "b": "GLOBAL_MFG"})
|
| 326 |
+
if "spend" in p and ("material group" in p or "group" in p):
|
| 327 |
+
return ("spend_by_dim", {"dim": "MaterialGroup"})
|
| 328 |
+
if "recent" in p or ("last" in p and "po" in p):
|
| 329 |
+
return ("recent_pos", {"n": 10})
|
| 330 |
+
if "lead time" in p and "supplier" in p:
|
| 331 |
+
return ("lead_by_supplier", {})
|
| 332 |
+
if "price" in p and "supplier" in p:
|
| 333 |
+
return ("price_by_supplier", {})
|
| 334 |
+
# default: summary
|
| 335 |
+
return ("summary", {})
|
| 336 |
+
|
| 337 |
+
def agent_execute(route, args):
|
| 338 |
+
if route == "top_suppliers_otif":
|
| 339 |
+
g = tool_top_suppliers_by("OTIF", topn=args.get("topn",5))
|
| 340 |
+
if isinstance(g, str):
|
| 341 |
+
return g, None
|
| 342 |
+
g2 = g.copy()
|
| 343 |
+
g2["OTIF%"] = (g2["OTIF"]*100).round(1)
|
| 344 |
+
fig = px.bar(g2, x="Supplier", y="OTIF%", color="Supplier", template="plotly_white", height=360)
|
| 345 |
+
return "Top suppliers by OTIF:", fig
|
| 346 |
+
|
| 347 |
+
if route == "compare_suppliers":
|
| 348 |
+
stats = tool_compare_suppliers(args.get("a"), args.get("b"))
|
| 349 |
+
if isinstance(stats, str):
|
| 350 |
+
return stats, None
|
| 351 |
+
fig = px.bar(stats, x="Supplier", y=["Spend","AvgPrice","AvgLead","OTIF"], barmode="group", template="plotly_white", height=420)
|
| 352 |
+
return "Comparison across Spend, AvgPrice, AvgLead, and OTIF:", fig
|
| 353 |
+
|
| 354 |
+
if route == "spend_by_dim":
|
| 355 |
+
g = tool_spend_by_dim(args.get("dim","MaterialGroup"))
|
| 356 |
+
if g is None:
|
| 357 |
+
return "No data for spend by dimension.", None
|
| 358 |
+
fig = px.treemap(g, path=[args.get("dim","MaterialGroup")], values="NetValue", height=420)
|
| 359 |
+
return f"Spend by {args.get('dim','MaterialGroup')}:", fig
|
| 360 |
+
|
| 361 |
+
if route == "recent_pos":
|
| 362 |
+
lines = tool_show_recent_po_lines(args.get("n",10))
|
| 363 |
+
if lines is None:
|
| 364 |
+
return "No recent PO lines found.", None
|
| 365 |
+
st.dataframe(lines, use_container_width=True, height=260)
|
| 366 |
+
return f"Showing {len(lines)} most recent PO lines.", None
|
| 367 |
+
|
| 368 |
+
if route == "lead_by_supplier":
|
| 369 |
+
if not len(fdf): return "No data.", None
|
| 370 |
+
g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean()
|
| 371 |
+
fig = px.bar(g.sort_values("LeadTimeDays"), x="LeadTimeDays", y="Supplier", orientation="h", template="plotly_white", height=420)
|
| 372 |
+
return "Average lead time by supplier:", fig
|
| 373 |
+
|
| 374 |
+
if route == "price_by_supplier":
|
| 375 |
+
if not len(fdf): return "No data.", None
|
| 376 |
+
g = fdf.groupby("Supplier", as_index=False)["NetPrice"].mean()
|
| 377 |
+
fig = px.bar(g.sort_values("NetPrice", ascending=False), x="Supplier", y="NetPrice", template="plotly_white", height=420)
|
| 378 |
+
return "Average price by supplier:", fig
|
| 379 |
+
|
| 380 |
+
# summary
|
| 381 |
+
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}%."
|
| 382 |
+
return msg, None
|
| 383 |
+
|
| 384 |
+
# Render chat history
|
| 385 |
+
for m in st.session_state.messages:
|
| 386 |
+
with st.chat_message(m["role"], avatar="🧭" if m["role"]=="assistant" else "🧑🏻"):
|
| 387 |
+
st.write(m["content"])
|
| 388 |
+
|
| 389 |
+
prompt = st.chat_input("Ask about procurement performance, suppliers, KPIs, or simulations…")
|
| 390 |
+
if prompt:
|
| 391 |
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
| 392 |
+
with st.chat_message("user", avatar="🧑🏻"):
|
| 393 |
+
st.write(prompt)
|
| 394 |
+
|
| 395 |
+
with st.chat_message("assistant", avatar="🧭"):
|
| 396 |
+
with st.status("Thinking…", expanded=False):
|
| 397 |
+
route, args = agent_router(prompt)
|
| 398 |
+
text, fig = agent_execute(route, args)
|
| 399 |
|
| 400 |
+
if text:
|
| 401 |
+
st.write(text)
|
| 402 |
+
if fig is not None:
|
| 403 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 404 |
+
st.session_state.messages.append({"role": "assistant", "content": text or "(shown as chart/table)"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|