Spaces:
Running
Running
Update Sales tab to include delivery and party pack
Browse files- streamlit_app.py +170 -3
streamlit_app.py
CHANGED
|
@@ -86,6 +86,11 @@ LANG = {
|
|
| 86 |
"sm_chart_cap": "%Cap β Capacity utilised",
|
| 87 |
"sm_chart_premium": "%Premium β premium share of customers",
|
| 88 |
"sm_chart_rounds": "Customers by Round (monthly)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
# Forecast tab
|
| 90 |
"fc_month_title": "This Month Forecast",
|
| 91 |
"fc_month_customers": "Forecast Customers (this month)",
|
|
@@ -207,6 +212,11 @@ LANG = {
|
|
| 207 |
"sm_chart_cap": "%ΰΉΰΈΰΉΰΈΰΈ·ΰΉΰΈΰΈΰΈ΅ΰΉ",
|
| 208 |
"sm_chart_premium": "%ΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ²ΰΈΰΈ£ΰΈ΅ΰΉΰΈ‘ΰΈ΅ΰΈ’ΰΈ‘",
|
| 209 |
"sm_chart_rounds": "ΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ²ΰΈΰΈ²ΰΈ‘ΰΈ£ΰΈΰΈ (ΰΈ£ΰΈ²ΰΈ’ΰΉΰΈΰΈ·ΰΈΰΈ)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
# Forecast tab
|
| 211 |
"fc_month_title": "ΰΈΰΈ’ΰΈ²ΰΈΰΈ£ΰΈΰΉΰΈΰΈΰΈΰΉΰΈΰΈ·ΰΈΰΈΰΈΰΈ΅ΰΉ",
|
| 212 |
"fc_month_customers": "ΰΈΰΈ’ΰΈ²ΰΈΰΈ£ΰΈΰΉΰΈΰΈ³ΰΈΰΈ§ΰΈΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ² (ΰΉΰΈΰΈ·ΰΈΰΈΰΈΰΈ΅ΰΉ)",
|
|
@@ -1479,6 +1489,97 @@ with tab_summary:
|
|
| 1479 |
m = monthly.copy().sort_values(
|
| 1480 |
["Year", "Month", "Branch"], ascending=[True, True, True]
|
| 1481 |
) if not monthly.empty else monthly.copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1482 |
round_cols: list[str] = []
|
| 1483 |
if (restaurant_name == "Copper Buffet"
|
| 1484 |
and not fact_shift_items.empty and not m.empty):
|
|
@@ -1676,6 +1777,64 @@ with tab_summary:
|
|
| 1676 |
xaxis_title=None, yaxis_title=None)
|
| 1677 |
st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
|
| 1678 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1679 |
# ββ Monthly summary table ββββββββββββββββββββββββββββββββββββββββ
|
| 1680 |
st.markdown(f"**{t('sm_monthly_summary')}**")
|
| 1681 |
if not m.empty:
|
|
@@ -1683,9 +1842,9 @@ with tab_summary:
|
|
| 1683 |
["Year", "Month", "Branch", "Revenue", "Customers"]
|
| 1684 |
if c in m.columns]
|
| 1685 |
tail_cols = [c for c in ["Rev_Per_Head"] if c in m.columns]
|
| 1686 |
-
# Column order: base Β· %Cap Β· %Premium Β· Rev/Head Β· rounds.
|
| 1687 |
metric_cols = [c for c in ["%Cap", "%Premium"] if c in m.columns]
|
| 1688 |
-
cols = base_cols + metric_cols + tail_cols + round_cols
|
| 1689 |
|
| 1690 |
# Pre-format money / count / percent columns to strings (printf
|
| 1691 |
# "," flag is not supported on older Streamlit versions).
|
|
@@ -1695,11 +1854,19 @@ with tab_summary:
|
|
| 1695 |
disp[c] = disp[c].map(fmt_money)
|
| 1696 |
if "Customers" in disp.columns:
|
| 1697 |
disp["Customers"] = disp["Customers"].map(fmt_num)
|
|
|
|
|
|
|
| 1698 |
for c in round_cols:
|
| 1699 |
disp[c] = disp[c].map(fmt_num)
|
| 1700 |
for c in metric_cols:
|
| 1701 |
disp[c] = disp[c].map(fmt_pct)
|
| 1702 |
-
disp = disp.rename(columns={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1703 |
st.dataframe(
|
| 1704 |
disp,
|
| 1705 |
use_container_width=True, hide_index=True,
|
|
|
|
| 86 |
"sm_chart_cap": "%Cap β Capacity utilised",
|
| 87 |
"sm_chart_premium": "%Premium β premium share of customers",
|
| 88 |
"sm_chart_rounds": "Customers by Round (monthly)",
|
| 89 |
+
"sm_col_normal": "Normal",
|
| 90 |
+
"sm_col_premium": "Premium",
|
| 91 |
+
"sm_col_delivery": "Delivery",
|
| 92 |
+
"sm_col_partypack": "Party Pack",
|
| 93 |
+
"sm_chart_rev_split": "Monthly Revenue by Channel",
|
| 94 |
# Forecast tab
|
| 95 |
"fc_month_title": "This Month Forecast",
|
| 96 |
"fc_month_customers": "Forecast Customers (this month)",
|
|
|
|
| 212 |
"sm_chart_cap": "%ΰΉΰΈΰΉΰΈΰΈ·ΰΉΰΈΰΈΰΈ΅ΰΉ",
|
| 213 |
"sm_chart_premium": "%ΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ²ΰΈΰΈ£ΰΈ΅ΰΉΰΈ‘ΰΈ΅ΰΈ’ΰΈ‘",
|
| 214 |
"sm_chart_rounds": "ΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ²ΰΈΰΈ²ΰΈ‘ΰΈ£ΰΈΰΈ (ΰΈ£ΰΈ²ΰΈ’ΰΉΰΈΰΈ·ΰΈΰΈ)",
|
| 215 |
+
"sm_col_normal": "ΰΈΰΈΰΈΰΈ΄",
|
| 216 |
+
"sm_col_premium": "ΰΈΰΈ£ΰΈ΅ΰΉΰΈ‘ΰΈ΅ΰΈ’ΰΈ‘",
|
| 217 |
+
"sm_col_delivery": "ΰΉΰΈΰΈ₯ΰΈ΄ΰΉΰΈ§ΰΈΰΈ£ΰΈ΅ΰΉ",
|
| 218 |
+
"sm_col_partypack": "ΰΈΰΈ²ΰΈ£ΰΉΰΈΰΈ΅ΰΉΰΉΰΈΰΉΰΈ",
|
| 219 |
+
"sm_chart_rev_split": "ΰΈ£ΰΈ²ΰΈ’ΰΉΰΈΰΉΰΈ£ΰΈ²ΰΈ’ΰΉΰΈΰΈ·ΰΈΰΈΰΈΰΈ²ΰΈ‘ΰΈΰΉΰΈΰΈΰΈΰΈ²ΰΈ",
|
| 220 |
# Forecast tab
|
| 221 |
"fc_month_title": "ΰΈΰΈ’ΰΈ²ΰΈΰΈ£ΰΈΰΉΰΈΰΈΰΈΰΉΰΈΰΈ·ΰΈΰΈΰΈΰΈ΅ΰΉ",
|
| 222 |
"fc_month_customers": "ΰΈΰΈ’ΰΈ²ΰΈΰΈ£ΰΈΰΉΰΈΰΈ³ΰΈΰΈ§ΰΈΰΈ₯ΰΈΉΰΈΰΈΰΉΰΈ² (ΰΉΰΈΰΈ·ΰΈΰΈΰΈΰΈ΅ΰΉ)",
|
|
|
|
| 1489 |
m = monthly.copy().sort_values(
|
| 1490 |
["Year", "Month", "Branch"], ascending=[True, True, True]
|
| 1491 |
) if not monthly.empty else monthly.copy()
|
| 1492 |
+
|
| 1493 |
+
# ββ Split Revenue into Normal / Premium / Delivery / Party Pack ββ
|
| 1494 |
+
# Per-row revenue = GrossRev + SVC (net of discount, including
|
| 1495 |
+
# service charge but excluding tax β matches how the ops team
|
| 1496 |
+
# accounts for revenue).
|
| 1497 |
+
#
|
| 1498 |
+
# Tagging differs between the two restaurants:
|
| 1499 |
+
# β’ Copper Buffet uses Type='Package' + SubType β
|
| 1500 |
+
# ('Normal', 'Premium', 'Delivery', 'Party Pack').
|
| 1501 |
+
# β’ Tiew Copper is Γ la carte, so its delivery is tagged with
|
| 1502 |
+
# Type='Delivery' (no SubType split). It has no Premium /
|
| 1503 |
+
# Party Pack channels β those columns stay at 0.
|
| 1504 |
+
channel_cols: list[str] = []
|
| 1505 |
+
if not m.empty and not fact_items.empty:
|
| 1506 |
+
fi = fact_items.copy()
|
| 1507 |
+
if "Date" in fi.columns:
|
| 1508 |
+
fi["Date"] = pd.to_datetime(fi["Date"], errors="coerce")
|
| 1509 |
+
fi = fi.dropna(subset=["Date"])
|
| 1510 |
+
if "Restaurant" in fi.columns:
|
| 1511 |
+
fi = fi[fi["Restaurant"] == restaurant_name]
|
| 1512 |
+
if sel_branches and "Branch" in fi.columns:
|
| 1513 |
+
fi = fi[fi["Branch"].isin(sel_branches)]
|
| 1514 |
+
# Apply the same date-range filter the rest of the Summary tab uses.
|
| 1515 |
+
if date_from is not None and "Date" in fi.columns:
|
| 1516 |
+
fi = fi[fi["Date"] >= pd.to_datetime(date_from)]
|
| 1517 |
+
if date_to is not None and "Date" in fi.columns:
|
| 1518 |
+
fi = fi[fi["Date"] <= pd.to_datetime(date_to)]
|
| 1519 |
+
if not fi.empty and "Year" not in fi.columns:
|
| 1520 |
+
fi["Year"] = fi["Date"].dt.year
|
| 1521 |
+
fi["Month"] = fi["Date"].dt.month
|
| 1522 |
+
|
| 1523 |
+
# Revenue per row = GrossRev + SVC. Both coerced to numeric
|
| 1524 |
+
# (NaNβ0) so the sum is safe when columns are missing.
|
| 1525 |
+
if not fi.empty:
|
| 1526 |
+
_gross = pd.to_numeric(fi.get("GrossRev", 0), errors="coerce").fillna(0)
|
| 1527 |
+
_svc = pd.to_numeric(fi.get("SVC", 0), errors="coerce").fillna(0)
|
| 1528 |
+
fi["_rev"] = _gross + _svc
|
| 1529 |
+
|
| 1530 |
+
def _bucket_into(source: pd.DataFrame, dest_col: str) -> None:
|
| 1531 |
+
"""Sum `source['_rev']` per (Year, Month, Branch) into m[dest_col]."""
|
| 1532 |
+
nonlocal m
|
| 1533 |
+
if source.empty:
|
| 1534 |
+
m[dest_col] = 0.0
|
| 1535 |
+
return
|
| 1536 |
+
agg = (
|
| 1537 |
+
source.groupby(["Year", "Month", "Branch"], as_index=False)["_rev"]
|
| 1538 |
+
.sum()
|
| 1539 |
+
.rename(columns={"_rev": dest_col})
|
| 1540 |
+
)
|
| 1541 |
+
m = m.merge(agg, on=["Year", "Month", "Branch"], how="left")
|
| 1542 |
+
m[dest_col] = m[dest_col].fillna(0.0)
|
| 1543 |
+
|
| 1544 |
+
if restaurant_name == "Copper Buffet":
|
| 1545 |
+
# Filter to Package rows, then bucket by SubType.
|
| 1546 |
+
fi_pkg = (fi[fi["Type"] == "Package"]
|
| 1547 |
+
if "Type" in fi.columns else fi.iloc[0:0])
|
| 1548 |
+
def _by_subtype(sub_value: str) -> pd.DataFrame:
|
| 1549 |
+
if "SubType" not in fi_pkg.columns:
|
| 1550 |
+
return fi_pkg.iloc[0:0]
|
| 1551 |
+
return fi_pkg[fi_pkg["SubType"] == sub_value]
|
| 1552 |
+
_bucket_into(_by_subtype("Normal"), "Normal")
|
| 1553 |
+
_bucket_into(_by_subtype("Premium"), "Premium")
|
| 1554 |
+
_bucket_into(_by_subtype("Delivery"), "Delivery")
|
| 1555 |
+
_bucket_into(_by_subtype("Party Pack"), "PartyPack")
|
| 1556 |
+
|
| 1557 |
+
elif restaurant_name == "Tiew Copper":
|
| 1558 |
+
# Tiew Copper tags delivery via Type='Delivery'; the
|
| 1559 |
+
# rest of the revenue is "Normal" (Γ la carte food +
|
| 1560 |
+
# beverage). Derive Normal as Revenue β Delivery so the
|
| 1561 |
+
# column lines up with the kpi_monthly Revenue total
|
| 1562 |
+
# that drives the other tiles.
|
| 1563 |
+
delivery_rows = (fi[fi["Type"] == "Delivery"]
|
| 1564 |
+
if "Type" in fi.columns else fi.iloc[0:0])
|
| 1565 |
+
_bucket_into(delivery_rows, "Delivery")
|
| 1566 |
+
if "Revenue" in m.columns:
|
| 1567 |
+
m["Normal"] = (m["Revenue"] - m.get("Delivery", 0.0)).clip(lower=0)
|
| 1568 |
+
else:
|
| 1569 |
+
m["Normal"] = 0.0
|
| 1570 |
+
# Tiew Copper has no Premium / Party Pack channels.
|
| 1571 |
+
m["Premium"] = 0.0
|
| 1572 |
+
m["PartyPack"] = 0.0
|
| 1573 |
+
|
| 1574 |
+
else:
|
| 1575 |
+
# Group-level rows (Holding / CK / Conso) β no channel
|
| 1576 |
+
# split applies. Leave the columns at 0 for consistency.
|
| 1577 |
+
for c in ("Normal", "Premium", "Delivery", "PartyPack"):
|
| 1578 |
+
m[c] = 0.0
|
| 1579 |
+
|
| 1580 |
+
channel_cols = [c for c in ("Normal", "Premium", "Delivery", "PartyPack")
|
| 1581 |
+
if c in m.columns]
|
| 1582 |
+
|
| 1583 |
round_cols: list[str] = []
|
| 1584 |
if (restaurant_name == "Copper Buffet"
|
| 1585 |
and not fact_shift_items.empty and not m.empty):
|
|
|
|
| 1777 |
xaxis_title=None, yaxis_title=None)
|
| 1778 |
st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
|
| 1779 |
|
| 1780 |
+
# Stacked-bar revenue split β Normal / Premium / Delivery /
|
| 1781 |
+
# Party Pack per month, summed across the branches in scope.
|
| 1782 |
+
# Skipped only when every channel column is flat-zero in the
|
| 1783 |
+
# filter window.
|
| 1784 |
+
if channel_cols and any(
|
| 1785 |
+
(col in m.columns) and m[col].sum() > 0
|
| 1786 |
+
for col in channel_cols
|
| 1787 |
+
):
|
| 1788 |
+
_RC_COLOR = {
|
| 1789 |
+
t("sm_col_normal"): "#976A4D", # COPPER (primary baseline)
|
| 1790 |
+
t("sm_col_premium"): "#1E2B3A", # NAVY (premium = anchor)
|
| 1791 |
+
t("sm_col_delivery"): "#DC7D3D", # TIEW (delivery accent)
|
| 1792 |
+
t("sm_col_partypack"): "#D4A574", # GOLD (party pack accent)
|
| 1793 |
+
}
|
| 1794 |
+
rev_long = mf[["YearMonth"] + channel_cols].copy()
|
| 1795 |
+
# Rename the internal column names to their localized
|
| 1796 |
+
# labels before melting so the chart legend reads in the
|
| 1797 |
+
# user's language.
|
| 1798 |
+
rev_long = rev_long.rename(columns={
|
| 1799 |
+
"Normal": t("sm_col_normal"),
|
| 1800 |
+
"Premium": t("sm_col_premium"),
|
| 1801 |
+
"Delivery": t("sm_col_delivery"),
|
| 1802 |
+
"PartyPack": t("sm_col_partypack"),
|
| 1803 |
+
})
|
| 1804 |
+
value_vars = [
|
| 1805 |
+
t("sm_col_normal"),
|
| 1806 |
+
t("sm_col_premium"),
|
| 1807 |
+
t("sm_col_delivery"),
|
| 1808 |
+
t("sm_col_partypack"),
|
| 1809 |
+
]
|
| 1810 |
+
value_vars = [c for c in value_vars if c in rev_long.columns]
|
| 1811 |
+
long_df = rev_long.melt(
|
| 1812 |
+
id_vars=["YearMonth"], value_vars=value_vars,
|
| 1813 |
+
var_name="Channel", value_name="Revenue",
|
| 1814 |
+
)
|
| 1815 |
+
long_df = (
|
| 1816 |
+
long_df.groupby(["YearMonth", "Channel"], as_index=False)["Revenue"]
|
| 1817 |
+
.sum()
|
| 1818 |
+
.sort_values(["YearMonth"])
|
| 1819 |
+
)
|
| 1820 |
+
fig = px.bar(
|
| 1821 |
+
long_df, x="YearMonth", y="Revenue", color="Channel",
|
| 1822 |
+
barmode="stack",
|
| 1823 |
+
color_discrete_map=_RC_COLOR,
|
| 1824 |
+
category_orders={"Channel": value_vars},
|
| 1825 |
+
text="Revenue",
|
| 1826 |
+
)
|
| 1827 |
+
fig.update_traces(
|
| 1828 |
+
texttemplate="ΰΈΏ%{y:,.0f}", textposition="inside",
|
| 1829 |
+
textfont=dict(size=9, color="#FAF7F2"),
|
| 1830 |
+
insidetextanchor="middle",
|
| 1831 |
+
)
|
| 1832 |
+
fig.update_yaxes(tickformat=",.0f")
|
| 1833 |
+
fig.update_layout(title=t("sm_chart_rev_split"),
|
| 1834 |
+
xaxis_title=None, yaxis_title=None,
|
| 1835 |
+
legend_title=None)
|
| 1836 |
+
st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
|
| 1837 |
+
|
| 1838 |
# ββ Monthly summary table ββββββββββββββββββββββββββββββββββββββββ
|
| 1839 |
st.markdown(f"**{t('sm_monthly_summary')}**")
|
| 1840 |
if not m.empty:
|
|
|
|
| 1842 |
["Year", "Month", "Branch", "Revenue", "Customers"]
|
| 1843 |
if c in m.columns]
|
| 1844 |
tail_cols = [c for c in ["Rev_Per_Head"] if c in m.columns]
|
| 1845 |
+
# Column order: base Β· channels Β· %Cap Β· %Premium Β· Rev/Head Β· rounds.
|
| 1846 |
metric_cols = [c for c in ["%Cap", "%Premium"] if c in m.columns]
|
| 1847 |
+
cols = base_cols + channel_cols + metric_cols + tail_cols + round_cols
|
| 1848 |
|
| 1849 |
# Pre-format money / count / percent columns to strings (printf
|
| 1850 |
# "," flag is not supported on older Streamlit versions).
|
|
|
|
| 1854 |
disp[c] = disp[c].map(fmt_money)
|
| 1855 |
if "Customers" in disp.columns:
|
| 1856 |
disp["Customers"] = disp["Customers"].map(fmt_num)
|
| 1857 |
+
for c in channel_cols:
|
| 1858 |
+
disp[c] = disp[c].map(fmt_money)
|
| 1859 |
for c in round_cols:
|
| 1860 |
disp[c] = disp[c].map(fmt_num)
|
| 1861 |
for c in metric_cols:
|
| 1862 |
disp[c] = disp[c].map(fmt_pct)
|
| 1863 |
+
disp = disp.rename(columns={
|
| 1864 |
+
"Rev_Per_Head": "Rev / Head",
|
| 1865 |
+
"Normal": t("sm_col_normal"),
|
| 1866 |
+
"Premium": t("sm_col_premium"),
|
| 1867 |
+
"Delivery": t("sm_col_delivery"),
|
| 1868 |
+
"PartyPack": t("sm_col_partypack"),
|
| 1869 |
+
})
|
| 1870 |
st.dataframe(
|
| 1871 |
disp,
|
| 1872 |
use_container_width=True, hide_index=True,
|