Spaces:
Running
Running
Update Forecast bug
Browse files- streamlit_app.py +119 -63
streamlit_app.py
CHANGED
|
@@ -96,9 +96,11 @@ LANG = {
|
|
| 96 |
"fc_month_customers": "Forecast Customers (this month)",
|
| 97 |
"fc_month_revenue": "Forecast Revenue (this month, est.)",
|
| 98 |
"fc_month_basis": "Revenue is estimated as forecast customers × trailing 3-month Rev/Head per branch.",
|
| 99 |
-
"fc_month_basis_full":"
|
| 100 |
-
"
|
| 101 |
-
"
|
|
|
|
|
|
|
| 102 |
"fc_header": "Copper Buffet — Forecast & Bookings",
|
| 103 |
"fc_caption": "Forecasted customer counts and confirmed bookings for upcoming "
|
| 104 |
"service dates. Data is captured only for Copper Buffet.",
|
|
@@ -222,9 +224,11 @@ LANG = {
|
|
| 222 |
"fc_month_customers": "พยากรณ์จำนวนลูกค้า (เดือนนี้)",
|
| 223 |
"fc_month_revenue": "พยากรณ์รายได้ (เดือนนี้, ประมาณการ)",
|
| 224 |
"fc_month_basis": "ประมาณการรายได้จาก: พยากรณ์จำนวนลูกค้า × รายได้ต่อหัวเฉลี่ย 3 เดือนล่าสุดของแต่ละสาขา",
|
| 225 |
-
"fc_month_basis_full":"
|
| 226 |
-
"
|
| 227 |
-
"
|
|
|
|
|
|
|
| 228 |
"fc_header": "คอปเปอร์บุฟเฟ่ต์ — พยากรณ์และการจอง",
|
| 229 |
"fc_caption": "พยากรณ์จำนวนลูกค้าและการจองที่ยืนยันแล้วสำหรับวันที่บริการในอนาคต "
|
| 230 |
"ข้อมูลมีเฉพาะของคอปเปอร์บุฟเฟ่ต์เท่านั้น",
|
|
@@ -2149,75 +2153,127 @@ with tab_forecast:
|
|
| 2149 |
st.subheader(t("fc_header"))
|
| 2150 |
st.caption(t("fc_caption"))
|
| 2151 |
|
| 2152 |
-
# ── This Month forecast —
|
| 2153 |
-
#
|
| 2154 |
-
#
|
| 2155 |
-
#
|
| 2156 |
-
# trailing 3-month
|
| 2157 |
-
#
|
|
|
|
| 2158 |
st.markdown(f"**{t('fc_month_title')}**")
|
| 2159 |
_now = pd.Timestamp(_dt.now().date())
|
| 2160 |
_month_start = _now.replace(day=1)
|
| 2161 |
_month_end = (_month_start + pd.offsets.MonthEnd(0)).normalize()
|
| 2162 |
|
| 2163 |
-
def
|
| 2164 |
-
"""
|
| 2165 |
-
|
| 2166 |
-
if
|
| 2167 |
-
return 0, 0.0
|
| 2168 |
-
_mp["Date"] = pd.to_datetime(_mp["Date"], errors="coerce")
|
| 2169 |
-
_mp = _mp[(_mp["Date"] >= _month_start) & (_mp["Date"] <= _month_end)]
|
| 2170 |
-
if "Restaurant" in _mp.columns:
|
| 2171 |
-
_mp = _mp[_mp["Restaurant"] == "Copper Buffet"]
|
| 2172 |
-
if sel_branches and "Branch" in _mp.columns:
|
| 2173 |
-
_mp = _mp[_mp["Branch"].isin(sel_branches)]
|
| 2174 |
-
if _mp.empty:
|
| 2175 |
-
return 0, 0.0
|
| 2176 |
-
# Latest snapshot per (Date, Branch).
|
| 2177 |
-
if "Date_Diff" in _mp.columns:
|
| 2178 |
-
_mp = _mp.assign(_a=_mp["Date_Diff"].abs()) \
|
| 2179 |
-
.sort_values("_a") \
|
| 2180 |
-
.drop_duplicates(["Date", "Branch"], keep="first") \
|
| 2181 |
-
.drop(columns="_a")
|
| 2182 |
-
cust = int(_mp["Prediction"].sum())
|
| 2183 |
-
|
| 2184 |
-
# Per-branch trailing 3-month Rev/Head from kpi_monthly.
|
| 2185 |
-
rev = 0.0
|
| 2186 |
-
if not kpi_monthly.empty and {"Restaurant", "Branch", "Rev_Per_Head", "Year", "Month"}.issubset(kpi_monthly.columns):
|
| 2187 |
-
hist = kpi_monthly[kpi_monthly["Restaurant"] == "Copper Buffet"].sort_values(["Year", "Month"])
|
| 2188 |
-
rph_map = (
|
| 2189 |
-
hist.groupby("Branch").tail(3)
|
| 2190 |
-
.groupby("Branch")["Rev_Per_Head"].mean().to_dict()
|
| 2191 |
-
)
|
| 2192 |
-
fallback = sum(rph_map.values()) / len(rph_map) if rph_map else 0.0
|
| 2193 |
-
for _br, _cust in _mp.groupby("Branch")["Prediction"].sum().items():
|
| 2194 |
-
rev += float(_cust) * rph_map.get(_br, fallback)
|
| 2195 |
-
return cust, rev
|
| 2196 |
-
|
| 2197 |
-
def _avg_month_forecast(restaurant: str) -> tuple[float, float]:
|
| 2198 |
-
"""Trailing 3-month avg of monthly Customers and Revenue,
|
| 2199 |
-
summed across the branches the sidebar is filtered to."""
|
| 2200 |
-
if kpi_monthly.empty:
|
| 2201 |
return 0.0, 0.0
|
| 2202 |
-
|
| 2203 |
-
if
|
| 2204 |
-
|
| 2205 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2206 |
return 0.0, 0.0
|
| 2207 |
-
|
| 2208 |
-
|
| 2209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2210 |
.agg(Customers=("Customers", "sum"),
|
| 2211 |
Revenue=("Revenue", "sum"))
|
| 2212 |
-
.sort_values(["Year", "Month"])
|
| 2213 |
-
.tail(3)
|
| 2214 |
)
|
| 2215 |
-
|
| 2216 |
-
|
| 2217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2218 |
|
| 2219 |
cb_cust, cb_rev = _cb_month_forecast()
|
| 2220 |
-
tc_cust, tc_rev =
|
| 2221 |
|
| 2222 |
# Copper Buffet block
|
| 2223 |
st.markdown("**Copper Buffet**")
|
|
|
|
| 96 |
"fc_month_customers": "Forecast Customers (this month)",
|
| 97 |
"fc_month_revenue": "Forecast Revenue (this month, est.)",
|
| 98 |
"fc_month_basis": "Revenue is estimated as forecast customers × trailing 3-month Rev/Head per branch.",
|
| 99 |
+
"fc_month_basis_full":"This month total combines actual customers and revenue for days that have already "
|
| 100 |
+
"passed (from kpi_daily) with projections for remaining days. Copper Buffet's "
|
| 101 |
+
"remaining days use the model's per-day prediction × trailing 3-month Rev/Head per "
|
| 102 |
+
"branch. Tiew Copper's remaining days are projected at the trailing 90-day average "
|
| 103 |
+
"per day-of-week, so weekdays and weekends are weighted separately.",
|
| 104 |
"fc_header": "Copper Buffet — Forecast & Bookings",
|
| 105 |
"fc_caption": "Forecasted customer counts and confirmed bookings for upcoming "
|
| 106 |
"service dates. Data is captured only for Copper Buffet.",
|
|
|
|
| 224 |
"fc_month_customers": "พยากรณ์จำนวนลูกค้า (เดือนนี้)",
|
| 225 |
"fc_month_revenue": "พยากรณ์รายได้ (เดือนนี้, ประมาณการ)",
|
| 226 |
"fc_month_basis": "ประมาณการรายได้จาก: พยากรณ์จำนวนลูกค้า × รายได้ต่อหัวเฉลี่ย 3 เดือนล่าสุดของแต่ละสาขา",
|
| 227 |
+
"fc_month_basis_full":"ยอดรวมเดือนนี้รวมข้อมูลจริงของวันที่ผ่านมาแล้ว (จาก kpi_daily) "
|
| 228 |
+
"กับการประมาณการสำหรับวันที่เหลือ คอปเปอร์บุฟเฟ่ต์ใช้พยากรณ์รายวันจากโมเดล "
|
| 229 |
+
"× รายได้ต่อหัวเฉลี่ย 3 เดือนล่าสุดของแต่ละสาขา ส่วนเตี่ยวคอปเปอร์ "
|
| 230 |
+
"ใช้ค่าเฉลี่ย 90 วันล่าสุดตามวันในสัปดาห์สำหรับวันที่เหลือ "
|
| 231 |
+
"(วันธรรมดาและวันหยุดสุดสัปดาห์จะถูกถ่วงน้ำหนักแยกกัน)",
|
| 232 |
"fc_header": "คอปเปอร์บุฟเฟ่ต์ — พยากรณ์และการจอง",
|
| 233 |
"fc_caption": "พยากรณ์จำนวนลูกค้าและการจองที่ยืนยันแล้วสำหรับวันที่บริการในอนาคต "
|
| 234 |
"ข้อมูลมีเฉพาะของคอปเปอร์บุฟเฟ่ต์เท่านั้น",
|
|
|
|
| 2153 |
st.subheader(t("fc_header"))
|
| 2154 |
st.caption(t("fc_caption"))
|
| 2155 |
|
| 2156 |
+
# ── This Month forecast — actual MTD + projection for remaining ─
|
| 2157 |
+
# For days that have already passed, use real customers + revenue
|
| 2158 |
+
# from kpi_daily. For days that haven't happened yet:
|
| 2159 |
+
# • Copper Buffet — model-based per-day prediction from
|
| 2160 |
+
# fact_predictions × trailing 3-month Rev/Head per branch.
|
| 2161 |
+
# • Tiew Copper — trailing 3-month average daily rate ×
|
| 2162 |
+
# remaining days (no per-day model exists for Tiew).
|
| 2163 |
st.markdown(f"**{t('fc_month_title')}**")
|
| 2164 |
_now = pd.Timestamp(_dt.now().date())
|
| 2165 |
_month_start = _now.replace(day=1)
|
| 2166 |
_month_end = (_month_start + pd.offsets.MonthEnd(0)).normalize()
|
| 2167 |
|
| 2168 |
+
def _actual_mtd(restaurant: str) -> tuple[float, float]:
|
| 2169 |
+
"""Sum of actual Customers + Revenue from kpi_daily for days
|
| 2170 |
+
in the current month that are strictly before today."""
|
| 2171 |
+
if kpi_daily.empty:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2172 |
return 0.0, 0.0
|
| 2173 |
+
kd = kpi_daily.copy()
|
| 2174 |
+
if "Date" in kd.columns:
|
| 2175 |
+
kd["Date"] = pd.to_datetime(kd["Date"], errors="coerce")
|
| 2176 |
+
if "Restaurant" in kd.columns:
|
| 2177 |
+
kd = kd[kd["Restaurant"] == restaurant]
|
| 2178 |
+
if sel_branches and "Branch" in kd.columns:
|
| 2179 |
+
kd = kd[kd["Branch"].isin(sel_branches)]
|
| 2180 |
+
kd = kd[(kd["Date"] >= _month_start) & (kd["Date"] < _now)]
|
| 2181 |
+
if kd.empty:
|
| 2182 |
return 0.0, 0.0
|
| 2183 |
+
cust = float(kd["Customers"].sum()) if "Customers" in kd.columns else 0.0
|
| 2184 |
+
rev = float(kd["Revenue"].sum()) if "Revenue" in kd.columns else 0.0
|
| 2185 |
+
return cust, rev
|
| 2186 |
+
|
| 2187 |
+
def _cb_month_forecast() -> tuple[float, float]:
|
| 2188 |
+
"""Copper Buffet — actual MTD + per-day prediction for remaining."""
|
| 2189 |
+
actual_cust, actual_rev = _actual_mtd("Copper Buffet")
|
| 2190 |
+
pred_cust = 0.0
|
| 2191 |
+
pred_rev = 0.0
|
| 2192 |
+
if not fact_predictions.empty:
|
| 2193 |
+
_mp = fact_predictions.copy()
|
| 2194 |
+
_mp["Date"] = pd.to_datetime(_mp["Date"], errors="coerce")
|
| 2195 |
+
# Only days from today onwards within current month.
|
| 2196 |
+
_mp = _mp[(_mp["Date"] >= _now) & (_mp["Date"] <= _month_end)]
|
| 2197 |
+
if "Restaurant" in _mp.columns:
|
| 2198 |
+
_mp = _mp[_mp["Restaurant"] == "Copper Buffet"]
|
| 2199 |
+
if sel_branches and "Branch" in _mp.columns:
|
| 2200 |
+
_mp = _mp[_mp["Branch"].isin(sel_branches)]
|
| 2201 |
+
if not _mp.empty:
|
| 2202 |
+
# Latest snapshot per (Date, Branch).
|
| 2203 |
+
if "Date_Diff" in _mp.columns:
|
| 2204 |
+
_mp = _mp.assign(_a=_mp["Date_Diff"].abs()) \
|
| 2205 |
+
.sort_values("_a") \
|
| 2206 |
+
.drop_duplicates(["Date", "Branch"], keep="first") \
|
| 2207 |
+
.drop(columns="_a")
|
| 2208 |
+
pred_cust = float(_mp["Prediction"].sum())
|
| 2209 |
+
# Per-branch trailing 3-month Rev/Head from kpi_monthly.
|
| 2210 |
+
if not kpi_monthly.empty and {"Restaurant", "Branch", "Rev_Per_Head", "Year", "Month"}.issubset(kpi_monthly.columns):
|
| 2211 |
+
hist = kpi_monthly[kpi_monthly["Restaurant"] == "Copper Buffet"].sort_values(["Year", "Month"])
|
| 2212 |
+
rph_map = (
|
| 2213 |
+
hist.groupby("Branch").tail(3)
|
| 2214 |
+
.groupby("Branch")["Rev_Per_Head"].mean().to_dict()
|
| 2215 |
+
)
|
| 2216 |
+
fallback = sum(rph_map.values()) / len(rph_map) if rph_map else 0.0
|
| 2217 |
+
for _br, _cust in _mp.groupby("Branch")["Prediction"].sum().items():
|
| 2218 |
+
pred_rev += float(_cust) * rph_map.get(_br, fallback)
|
| 2219 |
+
return actual_cust + pred_cust, actual_rev + pred_rev
|
| 2220 |
+
|
| 2221 |
+
def _tc_month_forecast() -> tuple[float, float]:
|
| 2222 |
+
"""Tiew Copper — actual MTD + per-day projection using day-of-week
|
| 2223 |
+
weighted averages from the trailing 90 days.
|
| 2224 |
+
|
| 2225 |
+
Why day-of-week? Restaurant traffic varies sharply by DOW (weekends
|
| 2226 |
+
≫ weekdays in most cases). Averaging by DOW means a Sunday at the
|
| 2227 |
+
end of the month gets projected at a Sunday-typical rate instead
|
| 2228 |
+
of a "mean of every day this quarter" rate, which would massively
|
| 2229 |
+
under-count weekend nights and over-count weekday nights.
|
| 2230 |
+
"""
|
| 2231 |
+
actual_cust, actual_rev = _actual_mtd("Tiew Copper")
|
| 2232 |
+
remaining = pd.date_range(_now, _month_end, freq="D")
|
| 2233 |
+
if len(remaining) == 0 or kpi_daily.empty:
|
| 2234 |
+
return actual_cust, actual_rev
|
| 2235 |
+
|
| 2236 |
+
# Trailing 90 days before the start of the current month.
|
| 2237 |
+
trailing_start = _month_start - pd.Timedelta(days=90)
|
| 2238 |
+
kd = kpi_daily.copy()
|
| 2239 |
+
kd["Date"] = pd.to_datetime(kd["Date"], errors="coerce")
|
| 2240 |
+
kd = kd[kd.get("Restaurant", "") == "Tiew Copper"]
|
| 2241 |
+
if sel_branches and "Branch" in kd.columns:
|
| 2242 |
+
kd = kd[kd["Branch"].isin(sel_branches)]
|
| 2243 |
+
kd = kd[(kd["Date"] >= trailing_start) & (kd["Date"] < _month_start)]
|
| 2244 |
+
if kd.empty:
|
| 2245 |
+
return actual_cust, actual_rev
|
| 2246 |
+
|
| 2247 |
+
# Sum branches first to get a single per-day total, then average
|
| 2248 |
+
# across days within each day-of-week bucket. DOW: Mon=0 … Sun=6.
|
| 2249 |
+
daily_totals = (
|
| 2250 |
+
kd.groupby("Date", as_index=False)
|
| 2251 |
.agg(Customers=("Customers", "sum"),
|
| 2252 |
Revenue=("Revenue", "sum"))
|
|
|
|
|
|
|
| 2253 |
)
|
| 2254 |
+
daily_totals["DOW"] = daily_totals["Date"].dt.dayofweek
|
| 2255 |
+
dow_avg = (
|
| 2256 |
+
daily_totals.groupby("DOW", as_index=False)
|
| 2257 |
+
.agg(AvgCust=("Customers", "mean"),
|
| 2258 |
+
AvgRev=("Revenue", "mean"))
|
| 2259 |
+
)
|
| 2260 |
+
# Fallback rate if a DOW has no historical samples (e.g. closed
|
| 2261 |
+
# on Mondays during the trailing window).
|
| 2262 |
+
fb_cust = float(daily_totals["Customers"].mean())
|
| 2263 |
+
fb_rev = float(daily_totals["Revenue"].mean())
|
| 2264 |
+
dow_cust = dict(zip(dow_avg["DOW"], dow_avg["AvgCust"]))
|
| 2265 |
+
dow_rev = dict(zip(dow_avg["DOW"], dow_avg["AvgRev"]))
|
| 2266 |
+
|
| 2267 |
+
proj_cust = 0.0
|
| 2268 |
+
proj_rev = 0.0
|
| 2269 |
+
for d in remaining:
|
| 2270 |
+
dow = int(d.dayofweek)
|
| 2271 |
+
proj_cust += float(dow_cust.get(dow, fb_cust))
|
| 2272 |
+
proj_rev += float(dow_rev.get(dow, fb_rev))
|
| 2273 |
+
return actual_cust + proj_cust, actual_rev + proj_rev
|
| 2274 |
|
| 2275 |
cb_cust, cb_rev = _cb_month_forecast()
|
| 2276 |
+
tc_cust, tc_rev = _tc_month_forecast()
|
| 2277 |
|
| 2278 |
# Copper Buffet block
|
| 2279 |
st.markdown("**Copper Buffet**")
|