Spaces:
Running
Running
import gradio as gr | |
import pandas as pd | |
import numpy as np | |
import matplotlib.pyplot as plt | |
from io import BytesIO | |
from PIL import Image | |
############################################################################### | |
# GLOBAL STORAGE OF PARAMETERS | |
############################################################################### | |
PARAMS = {} | |
############################################################################### | |
# DERIVED RATES + PUSH/PULL HELPER FUNCTIONS | |
############################################################################### | |
def compute_derived_rates( | |
# Product A (replaces Smart phone) | |
product_value_A, salvage_rate_A, life_cycle_A, capital_return_A, | |
# Product B (replaces Feature phone) | |
product_value_B, salvage_rate_B, life_cycle_B, capital_return_B, | |
# Freight | |
overnight_rate, standard_ratio, volume_discount, | |
# Picking | |
pick_first, pick_follow, | |
# Product mapping - NEW | |
productA_keyword, productB_keyword | |
): | |
""" | |
Reads the user inputs, calculates weekly inventory cost, freight rates, etc. | |
Stores results in PARAMS + returns summary text. | |
""" | |
try: | |
# Convert everything to float | |
pv_A = float(product_value_A) | |
sr_A = float(salvage_rate_A) | |
lc_A = float(life_cycle_A) | |
cr_A = float(capital_return_A) | |
pv_B = float(product_value_B) | |
sr_B = float(salvage_rate_B) | |
lc_B = float(life_cycle_B) | |
cr_B = float(capital_return_B) | |
on_rate = float(overnight_rate) | |
std_ratio = float(standard_ratio) | |
vol_disc = float(volume_discount) | |
pk_first = float(pick_first) | |
pk_follow = float(pick_follow) | |
# Liquidation values | |
liq_A = pv_A * sr_A | |
liq_B = pv_B * sr_B | |
# Weekly inventory cost | |
inv_A = (pv_A * cr_A / 52.0) + ((pv_A - liq_A) / lc_A) | |
inv_B = (pv_B * cr_B / 52.0) + ((pv_B - liq_B) / lc_B) | |
# Standard freight | |
std_freight = (on_rate / std_ratio) * vol_disc | |
# Store in global dict | |
PARAMS["productA_inventory_cost"] = inv_A | |
PARAMS["productB_inventory_cost"] = inv_B | |
PARAMS["overnight_rate"] = on_rate | |
PARAMS["standard_freight"] = std_freight | |
PARAMS["pick_first"] = pk_first | |
PARAMS["pick_follow"] = pk_follow | |
# Store product mapping keywords | |
PARAMS["productA_keyword"] = productA_keyword if productA_keyword else "Smart" | |
PARAMS["productB_keyword"] = productB_keyword if productB_keyword else "Feature" | |
summary = [ | |
"## Computed Derived Rates:", | |
f"- **Product A weekly inventory**: ${inv_A:.2f}/unit", | |
f"- **Product B weekly inventory**: ${inv_B:.2f}/unit", | |
f"- **Overnight freight rate**: ${on_rate:.2f}", | |
f"- **Standard freight rate**: ${std_freight:.2f}", | |
f"- **Pick cost (first)**: ${pk_first:.2f}, (follow-up): ${pk_follow:.2f}", | |
"", | |
"## Product Mapping:", | |
f"- **Product A stores**: containing '{PARAMS['productA_keyword']}'", | |
f"- **Product B stores**: containing '{PARAMS['productB_keyword']}'" | |
] | |
return "\n".join(summary) | |
except ValueError as e: | |
return f"Error converting to floats: {str(e)}" | |
def compute_inventory_cost(row): | |
""" | |
Flexible method to determine product type based on store name or Product_Type column. | |
Uses the keyword mappings defined by the user when setting up parameters. | |
""" | |
# First check if there's a dedicated Product_Type column | |
if 'Product_Type' in row: | |
product_type = str(row['Product_Type']).strip().upper() | |
if product_type == 'A': | |
rate = PARAMS.get("productA_inventory_cost", 0.0) | |
else: | |
rate = PARAMS.get("productB_inventory_cost", 0.0) | |
else: | |
# Fall back to using the configured keywords for matching store names | |
store_name = str(row["Store"]) | |
productA_keyword = PARAMS.get("productA_keyword", "Smart") | |
productB_keyword = PARAMS.get("productB_keyword", "Feature") | |
# Check if store name contains the product A keyword | |
if productA_keyword in store_name: | |
rate = PARAMS.get("productA_inventory_cost", 0.0) | |
# Check if store name contains the product B keyword | |
elif productB_keyword in store_name: | |
rate = PARAMS.get("productB_inventory_cost", 0.0) | |
else: | |
# If no match, log warning and default to product B cost | |
print(f"Warning: Store '{store_name}' doesn't match any product keyword. Defaulting to Product B.") | |
rate = PARAMS.get("productB_inventory_cost", 0.0) | |
return row["Onhand_Inventory"] * rate | |
def compute_shipping_cost(row): | |
"""If strategy=Pull => overnight, else => standard freight.""" | |
if row["Strategy"] == "Pull": | |
return row["Weekly_Sales"] * PARAMS.get("overnight_rate", 0.0) | |
else: | |
return row["Weekly_Sales"] * PARAMS.get("standard_freight", 0.0) | |
def compute_picking_cost(row): | |
"""Pull => usage * pick_first; Push => pick_first + (usage-1)*pick_follow (if usage>1).""" | |
sales = row["Weekly_Sales"] | |
first = PARAMS.get("pick_first", 1.0) | |
follow = PARAMS.get("pick_follow", 0.1) | |
if row["Strategy"] == "Pull": | |
return sales * first | |
else: | |
if sales > 1: | |
return first + (sales - 1)*follow | |
elif sales == 1: | |
return first | |
else: | |
return 0.0 | |
############################################################################### | |
# PUSH/PULL ANALYSIS FUNCTION | |
############################################################################### | |
def run_push_pull_analysis(df): | |
""" | |
Expects columns: Store, Strategy, Onhand_Inventory, Weekly_Sales | |
Returns: | |
- text summary | |
- cost breakdown chart | |
- savings chart | |
- and a DataFrame with new cost columns | |
""" | |
# Convert relevant columns to numeric | |
for col in ["Onhand_Inventory","Weekly_Sales"]: | |
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0) | |
# Compute cost columns | |
df["Inventory_Cost"] = df.apply(compute_inventory_cost, axis=1) | |
df["Shipping_Cost"] = df.apply(compute_shipping_cost, axis=1) | |
df["PickPack_Cost"] = df.apply(compute_picking_cost, axis=1) | |
df["Total_Cost"] = df["Inventory_Cost"] + df["Shipping_Cost"] + df["PickPack_Cost"] | |
# Summation text | |
lines = [] | |
strategies = df["Strategy"].unique() | |
for s in strategies: | |
sub = df[df["Strategy"]==s] | |
inv_sum = sub["Inventory_Cost"].sum() | |
shp_sum = sub["Shipping_Cost"].sum() | |
pkp_sum = sub["PickPack_Cost"].sum() | |
ttl_sum = sub["Total_Cost"].sum() | |
lines.append(f"**{s}** Strategy:") | |
lines.append(f"- Inventory: ${inv_sum:,.2f}") | |
lines.append(f"- Shipping: ${shp_sum:,.2f}") | |
lines.append(f"- PickPack: ${pkp_sum:,.2f}") | |
lines.append(f"- **Total**: ${ttl_sum:,.2f}") | |
lines.append("") | |
# If we have both Pull and Push, compute overall % saving | |
if len(strategies)==2 and set(strategies)=={"Pull","Push"}: | |
push_total = df.loc[df["Strategy"]=="Push","Total_Cost"].sum() | |
pull_total = df.loc[df["Strategy"]=="Pull","Total_Cost"].sum() | |
if push_total>0: | |
saving = (push_total - pull_total)/push_total*100 | |
lines.append(f"**Overall % saving from Pull**: {saving:.2f}%") | |
text_summary = "\n".join(lines) | |
# Chart 1: Stacked cost breakdown | |
group_sums = df.groupby("Strategy")[["Inventory_Cost","Shipping_Cost","PickPack_Cost"]].sum() | |
fig1, ax1 = plt.subplots(figsize=(6,4)) | |
group_sums.plot.bar(stacked=True, ax=ax1) | |
ax1.set_title("Cost Breakdown by Strategy") | |
ax1.set_ylabel("Cost ($)") | |
# Move legend so it doesn't cover the bars | |
ax1.legend(loc='upper left', bbox_to_anchor=(1,1)) | |
plt.tight_layout() | |
buf1 = BytesIO() | |
plt.savefig(buf1, format="png", bbox_inches="tight") | |
buf1.seek(0) | |
cost_img = Image.open(buf1) | |
plt.close(fig1) | |
# Chart 2: Savings by store ( (Push - Pull)/Push ) | |
stores = df["Store"].unique() | |
savings_dict = {} | |
for store in stores: | |
sub = df[df["Store"]==store] | |
if sub["Strategy"].nunique()==2: | |
push_val = sub.loc[sub["Strategy"]=="Push","Total_Cost"].values[0] | |
pull_val = sub.loc[sub["Strategy"]=="Pull","Total_Cost"].values[0] | |
if push_val>0: | |
pct = (push_val - pull_val)/push_val*100 | |
else: | |
pct = 0 | |
savings_dict[store] = pct | |
fig2, ax2 = plt.subplots(figsize=(6,4)) | |
labels = list(savings_dict.keys()) | |
values = [savings_dict[x] for x in labels] | |
bars = ax2.bar(labels, values, color="skyblue") | |
ax2.axhline(y=0, color="black", linewidth=1) | |
ax2.set_ylabel("% Savings (Push vs Pull)") | |
ax2.set_title("Savings by Store (Pull vs. Push)") | |
plt.xticks(rotation=45) | |
for bar, val in zip(bars, values): | |
ax2.text(bar.get_x()+bar.get_width()/2, | |
(val*0.5 if val>0 else val-5), | |
f"{val:.2f}%", ha="center") | |
plt.tight_layout() | |
buf2 = BytesIO() | |
plt.savefig(buf2, format="png", bbox_inches="tight") | |
buf2.seek(0) | |
savings_img = Image.open(buf2) | |
plt.close(fig2) | |
return text_summary, cost_img, savings_img, df | |
############################################################################### | |
# CATEGORY ANALYSIS (STORES AS ITEMS, similar to "ABC") | |
############################################################################### | |
def run_category_analysis_on_stores(df, multiplier=26): | |
""" | |
Takes the push/pull result DataFrame (with columns Inventory_Cost, Shipping_Cost, | |
PickPack_Cost, Weekly_Sales, Store, Strategy). | |
1) We create "Category_Cost" = sum of Inventory + Shipping + PickPack. | |
2) "Usage" = Weekly_Sales | |
3) "Annual_Spend" = Usage * Category_Cost * multiplier | |
4) Sort desc, cumulative %, classify => A/B/C | |
Returns text summary + Pareto chart. | |
""" | |
# Build the cost | |
df["Category_Cost"] = df["Inventory_Cost"] + df["Shipping_Cost"] + df["PickPack_Cost"] | |
# MODIFIED: Create a combined Store-Strategy identifier | |
df["StoreStrategy"] = df["Store"] + "-" + df["Strategy"] | |
# Build a smaller Category df | |
# MODIFIED: Use StoreStrategy instead of just Store | |
cat_df = df[["StoreStrategy","Weekly_Sales","Category_Cost"]].copy() | |
cat_df.rename(columns={ | |
"StoreStrategy":"ItemName", # MODIFIED: Using the combined identifier | |
"Weekly_Sales":"Usage", | |
"Category_Cost":"UnitCost" | |
}, inplace=True) | |
# Annual spend | |
cat_df["Annual_Spend"] = cat_df["Usage"] * cat_df["UnitCost"] * multiplier | |
# Sort desc | |
cat_df = cat_df.sort_values("Annual_Spend", ascending=False).reset_index(drop=True) | |
total_spend = cat_df["Annual_Spend"].sum() | |
cat_df["Cumulative_Spend"] = cat_df["Annual_Spend"].cumsum() | |
if total_spend>0: | |
cat_df["Cumulative_Percent"] = cat_df["Cumulative_Spend"]/total_spend*100 | |
else: | |
cat_df["Cumulative_Percent"] = 0 | |
# Classify each item as A/B/C | |
def classify_abc(pct): | |
if pct <= 70: | |
return "A" | |
elif pct <= 90: | |
return "B" | |
else: | |
return "C" | |
cat_df["Category"] = cat_df["Cumulative_Percent"].apply(classify_abc) | |
# Text summary | |
lines = ["## Category Analysis (Store-Strategy as Item)"] # MODIFIED: Updated title | |
cat_grp = cat_df.groupby("Category")["Annual_Spend"].agg(["sum","count"]) | |
for cat in ["A","B","C"]: | |
if cat in cat_grp.index: | |
s = cat_grp.loc[cat,"sum"] | |
c = cat_grp.loc[cat,"count"] | |
pct_spend = (s/total_spend*100 if total_spend else 0) | |
lines.append(f"\n**Category {cat}**:") | |
lines.append(f" - Items: {c}") | |
lines.append(f" - Spend: ${s:,.2f} ({pct_spend:.1f}%)") | |
txt_summary = "\n".join(lines) | |
# Pareto chart | |
fig, ax1 = plt.subplots(figsize=(8,5)) # MODIFIED: Increased figure width for longer labels | |
ranks = cat_df.index + 1 | |
# MODIFIED: Store the actual labels for plotting | |
store_strategy_labels = cat_df["ItemName"].values | |
ax1.bar(ranks, cat_df["Annual_Spend"], color="lightblue", label="Annual Spend") | |
ax1.set_xlabel("Store-Strategy Combinations (Sorted by Descending Spend)") # MODIFIED: Updated label | |
ax1.set_ylabel("Annual Spend", color="blue") | |
ax1.tick_params(axis='y', labelcolor='blue') | |
# MODIFIED: Set x-tick labels to store-strategy combinations | |
ax1.set_xticks(ranks) | |
ax1.set_xticklabels(store_strategy_labels, rotation=45, ha='right') # Rotate labels for readability | |
ax2 = ax1.twinx() | |
ax2.plot(ranks, cat_df["Cumulative_Percent"], color="red", linewidth=2, label="Cumulative %") | |
ax2.set_ylabel("Cumulative %", color="red") | |
ax2.tick_params(axis='y', labelcolor='red') | |
ax2.axhline(y=70, color="gray", linestyle="--", alpha=0.7) | |
ax2.axhline(y=90, color="gray", linestyle="--", alpha=0.7) | |
plt.title("Category Pareto Chart - (Store-Strategy as Item)") # MODIFIED: Updated title | |
plt.tight_layout() | |
buf = BytesIO() | |
plt.savefig(buf, format="png", bbox_inches="tight") | |
buf.seek(0) | |
chart_img = Image.open(buf) | |
plt.close(fig) | |
return txt_summary, chart_img | |
############################################################################### | |
# SINGLE FUNCTION TO RUN BOTH ANALYSES ON ONE CSV | |
############################################################################### | |
def run_both_analyses(file): | |
""" | |
1) Read user CSV (with columns: Store, Strategy, Onhand_Inventory, Weekly_Sales). | |
2) Do push/pull => text + 2 images + resulting DF. | |
3) Do category analysis => text + 1 image (on that resulting DF). | |
4) Return everything to Gradio. | |
""" | |
if file is None: | |
return ( | |
"No file uploaded!", None, None, | |
"No file => Category Analysis not run!", None | |
) | |
# Read CSV | |
try: | |
df = pd.read_csv(file.name) | |
except Exception as e: | |
return ( | |
f"Error reading CSV: {e}", None, None, | |
"Category Analysis not run", None | |
) | |
# Run push/pull | |
try: | |
pp_text, cost_img, savings_img, df_res = run_push_pull_analysis(df) | |
except Exception as e: | |
return ( | |
f"Push/Pull error: {e}", None, None, | |
"Category Analysis not run", None | |
) | |
# Run category analysis | |
try: | |
cat_text, cat_chart = run_category_analysis_on_stores(df_res, multiplier=26) | |
except Exception as e: | |
cat_text = f"Category Analysis error: {e}" | |
cat_chart = None | |
return ( | |
pp_text, cost_img, savings_img, | |
cat_text, cat_chart | |
) | |
############################################################################### | |
# BUILD GRADIO APP | |
############################################################################### | |
def build_app(): | |
with gr.Blocks(theme="JohnSmith9982/small_and_pretty") as app: | |
gr.Markdown("# Push/Pull and Category Analysis") | |
gr.Markdown("**Instructions**:") | |
gr.Markdown( | |
"1. Fill in the parameter inputs and click **Compute Derived Rates**.\n" | |
"2. Upload **one CSV** with columns: `Store, Strategy, Onhand_Inventory, Weekly_Sales`.\n" | |
"3. Click **Run Both Analyses** to see Push/Pull results (2 charts) + Category Analysis results (Pareto chart)." | |
) | |
# Parameter inputs | |
with gr.Tab("Parameter Inputs"): | |
gr.Markdown("Enter your product/freight/pick parameters. Then click the button.") | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### Product A") | |
product_value_A = gr.Textbox(label="Product Value ($)", value="500") | |
salvage_rate_A = gr.Textbox(label="Salvage Rate (0-1)", value="0.0") | |
life_cycle_A = gr.Textbox(label="Life cycle (weeks)", value="26") | |
capital_return_A = gr.Textbox(label="Capital return rate (0-1)", value="0.08") | |
with gr.Column(): | |
gr.Markdown("### Product B") | |
product_value_B = gr.Textbox(label="Product Value ($)", value="200") | |
salvage_rate_B = gr.Textbox(label="Salvage Rate (0-1)", value="0.0") | |
life_cycle_B = gr.Textbox(label="Life cycle (weeks)", value="26") | |
capital_return_B = gr.Textbox(label="Capital return rate (0-1)", value="0.08") | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### Freight") | |
overnight_rate = gr.Textbox(label="Overnight Rate ($)", value="12") | |
standard_ratio = gr.Textbox(label="Overnight/Standard Ratio", value="2.5") | |
volume_discount = gr.Textbox(label="Volume Discount (0-1)", value="0.5") | |
with gr.Column(): | |
gr.Markdown("### Picking Costs") | |
pick_first = gr.Textbox(label="First Pick Cost ($)", value="1") | |
pick_follow = gr.Textbox(label="Follow-up Pick Cost ($)", value="0.1") | |
# Add Product mapping section | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### Product Mapping") | |
productA_keyword = gr.Textbox(label="Product A Keyword (in store name)", value="Smart") | |
productB_keyword = gr.Textbox(label="Product B Keyword (in store name)", value="Feature") | |
compute_btn = gr.Button("Compute Derived Rates") | |
param_output = gr.Markdown() | |
# When user clicks, compute derived rates | |
compute_btn.click( | |
fn=compute_derived_rates, | |
inputs=[ | |
product_value_A, | |
salvage_rate_A, | |
life_cycle_A, | |
capital_return_A, | |
product_value_B, | |
salvage_rate_B, | |
life_cycle_B, | |
capital_return_B, | |
overnight_rate, | |
standard_ratio, | |
volume_discount, | |
pick_first, | |
pick_follow, | |
productA_keyword, | |
productB_keyword | |
], | |
outputs=param_output | |
) | |
# Single CSV => run both analyses | |
with gr.Tab("Upload & Run"): | |
file_input = gr.File(label="Single CSV for both analyses") | |
run_btn = gr.Button("Run Both Analyses") | |
# 5 outputs from run_both_analyses | |
pushpull_text = gr.Markdown(label="Push/Pull Summary") | |
cost_chart_img = gr.Image(label="Cost Breakdown by Strategy") | |
savings_chart_img = gr.Image(label="Savings by Store") | |
cat_text = gr.Markdown(label="Category Analysis Summary") | |
cat_chart_img = gr.Image(label="Category Pareto Chart") | |
run_btn.click( | |
fn=run_both_analyses, | |
inputs=[file_input], | |
outputs=[ | |
pushpull_text, | |
cost_chart_img, | |
savings_chart_img, | |
cat_text, | |
cat_chart_img | |
] | |
) | |
return app | |
############################################################################### | |
# MAIN | |
############################################################################### | |
if __name__ == "__main__": | |
app = build_app() | |
app.launch(debug=True) |