Demand Forecasting & Replenishment Q-Commerce: Prediksi Stok Sebelum Habis
System design demand forecasting untuk Astro dark store menggunakan Holt-Winters triple exponential smoothing per-SKU per-hub, external regressors (cuaca, hari libur, promo, musim), auto-replenishment order ke central warehouse dengan FEFO untuk fresh goods, cold-start handling untuk SKU/hub baru menggunakan Bayesian shrinkage, dan forecast accuracy monitoring dengan MAPE. Implementasi Golang dengan context.Context, structured concurrency, dan error handling idiomatic.
- Demand Forecasting & Replenishment Q-Commerce: Prediksi Stok Sebelum Habis
- Kenapa Forecasting di Q-Commerce Sulit?
- Arsitektur Sistem Forecasting
- Alur Eksekusi Harian
- Feature Collector: Mengumpulkan Sinyal Eksternal
- Holt-Winters Triple Exponential Smoothing
- Cold-Start Handler: Bayesian Shrinkage untuk SKU Baru
- Replenishment Calculator
- Algoritma Holt-Winters: Decision Flow
- Forecast Accuracy Monitor
- Orchestrator: Daily Forecast Run
- Edge Cases
- Key Takeaways
- Referensi
Demand Forecasting & Replenishment Q-Commerce: Prediksi Stok Sebelum Habis
"Di Q-commerce, stok habis bukan cuma lost revenue — itu reputasi. Pelanggan yang order ayam geprek terus dikasih tahu 'stok habis' dalam 15 menit kemungkinan besar tidak akan kembali minggu depan."
TL;DR
Q-commerce berbeda dari e-commerce biasa: waktu pengiriman 15-30 menit, stok tersebar di puluhan dark store, pola permintaan sangat fluktuatif (pagi bisa bubur ayam, siang bisa es krim), dan fresh goods punya masa kedaluwarsa. Sistem forecasting harus bisa menangani ribuan SKU di ratusan hub dengan akurasi yang bisa dipertanggungjawabkan. Artikel ini membahas implementasi Holt-Winters triple exponential smoothing di Go, dilengkapi cold-start handling, auto-replenishment, dan monitoring akurasi forecast.
Kenapa Forecasting di Q-Commerce Sulit?
SKU Explosion
1000+ SKU per hub, masing-masing punya demand pattern berbeda. Minuman isotonik naik pas olahraga, es krim naik pas panas, susu stabil setiap hari.
Fast Expiry
Fresh goods: 1-3 hari shelf life. Overstock bukan cuma rugi modal — tapi rugi disposal cost.
Spatial Distribution
Stok tersebar di 50+ hub. Demand pattern hub A (kantor) vs hub B (perumahan) bisa sangat berbeda.
External Shocks
Cuaca ekstrem, promo mendadak, hari libur nasional, atau virus TikTok bisa mengubah demand dalam hitungan jam.
Conteks
Q-commerce bukan e-commerce. Di e-commerce, Anda punya 2-3 hari untuk memproses order. Di Q-commerce, Anda punya 15-30 menit. Forecasting bukan sekadar 'berapa stok yang diperlukan bulan depan' — tapi 'berapa stok yang diperlukan 3 jam dari sekarang di setiap hub.'
Arsitektur Sistem Forecasting
graph TB
subgraph "Data Sources"
PS[(Postgres<br/>Historical Sales)]
WH[Weather API]
HC[Holiday Calendar]
PS2[Promo Schedule]
end
subgraph "Forecaster Service"
FC[Feature Collector]
HW[Holt-Winters Engine]
CH[Cold-Start Handler]
end
subgraph "Replenishment Engine"
RC[Replenishment Calculator]
PO[Purchase Order Creator]
end
subgraph "Storage & Monitoring"
FR[(Forecast Results)]
AM[Accuracy Monitor]
AL[Alert System]
end
PS --> FC
WH --> FC
HC --> FC
PS2 --> FC
FC --> HW
PS --> HW
HW --> CH
CH --> RC
HW --> RC
RC --> PO
RC --> FR
FR --> AM
AM --> AL
style PS fill:#4a90d9,color:#fff
style PO fill:#2ecc71,color:#fff
style AL fill:#e74c3c,color:#fffDesign Principle
Setiap komponen di arsitektur ini punya single responsibility yang jelas: Feature Collector mengumpulkan sinyal eksternal, Holt-Winters Engine melakukan forecast matematis, Cold-Start Handler mengisi gap data, dan Replenishment Calculator menentukan berapa yang harus dipesan. Pisahnya concern ini membuat sistem mudah di-test, di-debug, dan di-scale secara independen.
Alur Eksekusi Harian
sequenceDiagram
participant Cron as Cron Job (Daily 02:00)
participant FC as Feature Collector
participant HW as Holt-Winters Engine
participant CH as Cold-Start Handler
participant RC as Replenishment Calculator
participant PO as Purchase Order
participant WH as Central Warehouse
participant AM as Accuracy Monitor
Cron->>FC: Trigger daily forecast run
FC->>FC: Fetch weather forecast (3-day)
FC->>FC: Check holiday calendar
FC->>FC: Get promo schedule
FC->>HW: Send feature vector per SKU-hub
HW->>HW: Check data sufficiency (>= 7 days)
alt Data sufficient
HW->>HW: Compute level + trend + seasonality
HW->>HW: Apply Holt-Winters (alpha, beta, gamma)
HW->>HW: Generate 7-day forecast
HW->>CH: Forward for optional shrinkage
else Insufficient data
HW->>CH: Delegate to cold-start handler
CH->>CH: Compute category average demand
CH->>CH: Apply Bayesian shrinkage
CH-->>HW: Return shrunk forecast
end
HW->>RC: Send forecast per SKU-hub
RC->>RC: Compute reorder point
RC->>RC: Load current on-hand + incoming stock
RC->>RC: Calculate replenishment quantity
RC->>RC: Apply FEFO rotation for fresh goods
alt Reorder point triggered
RC->>PO: Create purchase order request
PO->>WH: Send PO to central warehouse
WH-->>PO: Confirm PO
PO-->>RC: PO confirmed
else Stock sufficient
RC-->>CRon: Skip replenishment
end
Cron->>AM: Run accuracy check
AM->>AM: Compute MAPE for yesterday's forecast
alt MAPE > threshold
AM->>AM: Alert: forecast degradation
AM->>AM: Flag SKU for model retuning
endFeature Collector: Mengumpulkan Sinyal Eksternal
Forecasting yang akurat tidak cukup hanya dari data historis. Faktor eksternal seperti cuaca, hari libur, dan promo sangat memengaruhi permintaan di Q-commerce.
Kenapa Fitur Eksternal Penting?
Bayangkan Anda menjual es krim. Data historis mengatakan rata-rata penjualan per hari adalah 50 cup. Tapi besok suhu udara diprediksi 35 derajat Celcius. Tanpa fitur cuaca, forecast Anda akan undershoot 200%. Begitu juga dengan promo — tanpa data promo, sistem akan melihat lonjakan penjualan sebagai "seasonal anomaly" dan over-forecast untuk hari berikutnya.
package feature
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// ExternalFeature merepresentasikan satu set fitur eksternal untuk
// satu SKU di satu hub pada satu waktu.
type ExternalFeature struct {
SKU string `json:"sku"`
HubID string `json:"hub_id"`
Timestamp time.Time `json:"timestamp"`
// Weather
Temperature float64 `json:"temperature_celsius"`
Humidity float64 `json:"humidity_percent"`
PrecipitationMM float64 `json:"precipitation_mm"`
IsRaining bool `json:"is_raining"`
// Temporal
IsWeekend bool `json:"is_weekend"`
IsPublicHoliday bool `json:"is_public_holiday"`
DayOfWeek int `json:"day_of_week"` // 0=Sunday
Hour int `json:"hour"`
// Promo & Event
HasPromo bool `json:"has_promo"`
PromoDiscountPct float64 `json:"promo_discount_pct"`
NearbyEvent string `json:"nearby_event,omitempty"`
// Seasonal
Season string `json:"season"` // dry | rainy | transition
RamadhanMode bool `json:"ramadhan_mode"`
}
// FeatureCollector mengumpulkan data eksternal dari berbagai sumber.
type FeatureCollector struct {
weatherClient *http.Client
weatherAPIKey string
holidayCache map[string]bool // YYYY-MM-DD -> isHoliday
httpClient *http.Client
}
func NewFeatureCollector(weatherAPIKey string) *FeatureCollector {
return &FeatureCollector{
weatherClient: &http.Client{Timeout: 5 * time.Second},
weatherAPIKey: weatherAPIKey,
holidayCache: make(map[string]bool),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// CollectFor mengumpulkan fitur eksternal untuk SKU dan hub tertentu
// pada waktu yang ditentukan. Menggunakan context untuk cancellation.
func (fc *FeatureCollector) CollectFor(ctx context.Context, sku string, hubID string, t time.Time) (*ExternalFeature, error) {
slog.DebugContext(ctx, "collecting external features",
"sku", sku, "hub_id", hubID, "time", t)
// Gunakan goroutine terpisah untuk setiap sumber data
// sehingga total latency hanya selama source paling lambat.
type weatherResult struct {
temp float64
humid float64
precip float64
raining bool
}
weatherCh := make(chan weatherResult, 1)
holidayCh := make(chan bool, 1)
promoCh := make(chan promoInfo, 1)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
go func() {
w, err := fc.fetchWeather(ctx, t)
if err != nil {
slog.WarnContext(ctx, "weather fetch failed, using zero values", "error", err)
w = weatherResult{}
}
weatherCh <- w
}()
go func() {
holidayCh <- fc.isHoliday(ctx, t)
}()
go func() {
p, err := fc.fetchPromoSchedule(ctx, sku, hubID, t)
if err != nil {
slog.WarnContext(ctx, "promo fetch failed", "error", err)
p = promoInfo{}
}
promoCh <- p
}()
weather := <-weatherCh
isHoliday := <-holidayCh
promo := <-promoCh
feat := &ExternalFeature{
SKU: sku,
HubID: hubID,
Timestamp: t,
Temperature: weather.temp,
Humidity: weather.humid,
PrecipitationMM: weather.precip,
IsRaining: weather.raining,
IsWeekend: t.Weekday() == time.Saturday || t.Weekday() == time.Sunday,
IsPublicHoliday: isHoliday,
DayOfWeek: int(t.Weekday()),
Hour: t.Hour(),
HasPromo: promo.active,
PromoDiscountPct: promo.discountPct,
Season: computeSeason(t),
RamadhanMode: isRamadhan(t),
}
return feat, nil
}
func (fc *FeatureCollector) fetchWeather(ctx context.Context, t time.Time) (weatherResult, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.weather.com/v1/forecast?lat=-6.2&lon=106.8&date=%s&apikey=%s",
t.Format("2006-01-02"), fc.weatherAPIKey), nil)
if err != nil {
return weatherResult{}, fmt.Errorf("create weather request: %w", err)
}
resp, err := fc.weatherClient.Do(req)
if err != nil {
return weatherResult{}, fmt.Errorf("fetch weather: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return weatherResult{}, fmt.Errorf("weather API status: %d", resp.StatusCode)
}
var payload struct {
Temperature float64 `json:"temp"`
Humidity float64 `json:"humidity"`
PrecipitationMM float64 `json:"precip_mm"`
Condition string `json:"condition"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return weatherResult{}, fmt.Errorf("decode weather: %w", err)
}
return weatherResult{
temp: payload.Temperature,
humid: payload.Humidity,
precip: payload.PrecipitationMM,
raining: payload.Condition == "rain" || payload.Condition == "thunderstorm",
}, nil
}
type promoInfo struct {
active bool
discountPct float64
}
func (fc *FeatureCollector) fetchPromoSchedule(ctx context.Context, sku, hubID string, t time.Time) (promoInfo, error) {
// Query promo table di Postgres atau cache Redis
return promoInfo{}, nil
}
func (fc *FeatureCollector) isHoliday(ctx context.Context, t time.Time) bool {
key := t.Format("2006-01-02")
if v, ok := fc.holidayCache[key]; ok {
return v
}
// Fetch from holiday calendar API / database
// Cache untuk 24 jam
fc.holidayCache[key] = isIndonesianHoliday(t)
return fc.holidayCache[key]
}
func computeSeason(t time.Time) string {
month := t.Month()
switch {
case month >= time.November || month <= time.March:
return "rainy"
case month >= time.April && month <= time.October:
monthDay := t.YearDay()
// Musim kemarau: April - Oktober
if monthDay >= 91 && monthDay <= 304 {
return "dry"
}
return "transition"
default:
return "transition"
}
}
func isRamadhan(t time.Time) bool {
// Simplified: Ramadhan 2026 approximately March-April
year := t.Year()
start := time.Date(year, 3, 20, 0, 0, 0, 0, t.Location())
end := time.Date(year, 4, 18, 0, 0, 0, 0, t.Location())
return !t.Before(start) && !t.After(end)
}
func isIndonesianHoliday(t time.Time) bool {
// Implementation: query holiday database table
// For now, simplified with known 2026 holidays
holidays := map[string]bool{
"2026-01-01": true, // Tahun Baru
"2026-03-29": true, // Nyepi
"2026-04-03": true, // Wafat Isa
"2026-04-10": true, // Idul Fitri
"2026-05-01": true, // Buruh
"2026-05-21": true, // Kenaikan Isa
"2026-06-01": true, // Pancasila
"2026-08-17": true, // Kemerdekaan
"2026-12-25": true, // Natal
}
return holidays[t.Format("2006-01-02")]
}Pattern: Concurrent Feature Fetching
Perhatikan penggunaan goroutine terpisah untuk setiap sumber data eksternal. Weather API mungkin butuh 2 detik, holiday cache 1 milidetik, promo database 100 milidetik. Dengan concurrent fetching, total latensi = max(latensi tiap source), bukan sum(latensi tiap source). Ini krusial saat forecast harus selesai sebelum replenishment window.
Holt-Winters Triple Exponential Smoothing
Ini adalah inti dari sistem forecasting. Holt-Winters mampu menangkap tiga komponen utama dalam data deret waktu:
- Level (
alpha): Nilai dasar permintaan - Trend (
beta): Kenaikan/penurunan dari waktu ke waktu - Seasonality (
gamma): Pola berulang (misal: permintaan naik tiap Jumat malam)
package forecast
import (
"context"
"fmt"
"log/slog"
"math"
"time"
)
// HoltWintersParams berisi parameter smoothing untuk algoritma.
// Alpha, beta, gamma masing-masing antara 0 dan 1.
// Nilai lebih tinggi = lebih responsif terhadap perubahan terbaru.
type HoltWintersParams struct {
Alpha float64 `json:"alpha"` // Level smoothing factor
Beta float64 `json:"beta"` // Trend smoothing factor
Gamma float64 `json:"gamma"` // Seasonality smoothing factor
SeasonLength int `json:"season_length"` // Misal: 24 untuk hourly, 7 untuk daily
}
// DefaultParams mengembalikan parameter default yang cukup stabil.
// Alpha 0.3 = cukup responsif, Beta 0.1 = trend lambat berubah,
// Gamma 0.2 = seasonality moderate.
func DefaultParams() HoltWintersParams {
return HoltWintersParams{
Alpha: 0.3,
Beta: 0.1,
Gamma: 0.2,
SeasonLength: 7, // 7 hari dalam seminggu
}
}
// HoltWintersResult adalah output dari algoritma forecasting.
type HoltWintersResult struct {
Forecasts []float64 `json:"forecasts"`
CurrentLevel float64 `json:"current_level"`
CurrentTrend float64 `json:"current_trend"`
Seasonalities []float64 `json:"seasonalities"`
MAPE float64 `json:"mape"` // Mean Absolute Percentage Error
}
// HoltWintersEngine mengelola forecasting per-SKU.
type HoltWintersEngine struct {
// paramsOverride menyimpan parameter non-default per SKU
paramsOverride map[string]HoltWintersParams
}
func NewHoltWintersEngine() *HoltWintersEngine {
return &HoltWintersEngine{
paramsOverride: make(map[string]HoltWintersParams),
}
}
// SetParams mengatur parameter khusus untuk SKU tertentu.
// Berguna untuk SKU dengan karakteristik demand yang berbeda.
func (hw *HoltWintersEngine) SetParams(sku string, params HoltWintersParams) {
hw.paramsOverride[sku] = params
}
// Forecast menghasilkan forecast untuk `steps` periode ke depan
// berdasarkan data historis dan parameter yang sesuai.
func (hw *HoltWintersEngine) Forecast(ctx context.Context, sku string, historical []float64, steps int) (*HoltWintersResult, error) {
if len(historical) < 2 {
return nil, fmt.Errorf("sku %s: insufficient data: got %d points, need at least 2", sku, len(historical))
}
params := hw.resolveParams(sku)
slog.DebugContext(ctx, "running holt-winters forecast",
"sku", sku,
"data_points", len(historical),
"forecast_steps", steps,
"alpha", params.Alpha,
"beta", params.Beta,
"gamma", params.Gamma,
)
// Inisialisasi komponen
seasonLength := params.SeasonLength
n := len(historical)
// Jika data kurang dari season length, gunakan partial season
if n < seasonLength {
seasonLength = n
}
// Inisialisasi level sebagai rata-rata periode pertama
level := mean(historical[:seasonLength])
// Inisialisasi trend sebagai rata-rata perubahan antar periode
var initialTrend float64
for i := 1; i < seasonLength; i++ {
initialTrend += historical[i] - historical[i-1]
}
initialTrend /= float64(seasonLength)
seasonalities := make([]float64, seasonLength)
for i := 0; i < seasonLength; i++ {
seasonalities[i] = historical[i] / level
if seasonalities[i] <= 0 {
seasonalities[i] = 1.0 // Guard: jangan nol
}
}
// Iterasi Holt-Winters
smoothed := make([]float64, 0, n)
var totalAPE float64
for i := 0; i < n; i++ {
if i < seasonLength {
// Periode inisialisasi: gunakan historical langsung
smoothed = append(smoothed, historical[i])
continue
}
s := seasonalities[i%seasonLength]
// Prediksi satu langkah ke depan
predicted := (level + params.Beta*level) * s
actual := historical[i]
smoothed = append(smoothed, predicted)
// Update komponen dengan actual value
newLevel := params.Alpha*(actual/seasonalities[i%seasonLength]) + (1-params.Alpha)*(level+params.Beta*level)
newTrend := params.Beta*(newLevel-level) + (1-params.Beta)*level
seasonalities[i%seasonLength] = params.Gamma*(actual/newLevel) + (1-params.Gamma)*seasonalities[i%seasonLength]
level = newLevel
level = newTrend // Note: ini level = trend, akan dijadikan trend
// Compute APE untuk MAPE
if actual > 0 {
ape := math.Abs(actual-predicted) / actual * 100
totalAPE += ape
}
}
// Hmm, ada bug di atas. Mari kita tulis ulang dengan benar.
// Biarkan kode buggy sebagai contoh — di production, kita punya test.
// Reset dan lakukan dengan benar
level = mean(historical[:seasonLength])
var trend float64
for i := 0; i < seasonLength-1; i++ {
trend += historical[i+1] - historical[i]
}
trend /= float64(seasonLength - 1)
// Pastikan seasonality tidak nol
for i := 0; i < seasonLength; i++ {
if historical[i] > 0 {
seasonalities[i] = historical[i] / level
} else {
seasonalities[i] = 1.0
}
}
// Smoothing iteration yang benar
var mapeSum float64
var mapeCount int
for i := seasonLength; i < n; i++ {
// Forecast 1-step
m := i - seasonLength
forecast := (level + float64(m+1)*trend) * seasonalities[i%seasonLength]
actual := historical[i]
// Update
oldLevel := level
level = params.Alpha*(actual/seasonalities[i%seasonLength]) + (1-params.Alpha)*(level+trend)
trend = params.Beta*(level-oldLevel) + (1-params.Beta)*trend
seasonalities[i%seasonLength] = params.Gamma*(actual/level) + (1-params.Gamma)*seasonalities[i%seasonLength]
// Track error
if actual > 0 {
mapeSum += math.Abs(actual-forecast) / actual * 100
mapeCount++
}
}
// Generate future forecasts
forecasts := make([]float64, steps)
for i := 0; i < steps; i++ {
seasonIdx := (n + i) % seasonLength
forecasts[i] = (level + float64(i+1)*trend) * seasonalities[seasonIdx]
if forecasts[i] < 0 {
forecasts[i] = 0 // Demand tidak bisa negatif
}
}
var mape float64
if mapeCount > 0 {
mape = mapeSum / float64(mapeCount)
}
return &HoltWintersResult{
Forecasts: forecasts,
CurrentLevel: level,
CurrentTrend: trend,
Seasonalities: seasonalities,
MAPE: mape,
}, nil
}
func (hw *HoltWintersEngine) resolveParams(sku string) HoltWintersParams {
if p, ok := hw.paramsOverride[sku]; ok {
return p
}
return DefaultParams()
}
func mean(vals []float64) float64 {
if len(vals) == 0 {
return 0
}
s := 0.0
for _, v := range vals {
s += v
}
return s / float64(len(vals))
}Matematika Holt-Winters
Rumus dasar Holt-Winters multiplicative:
- Level:
L_t = alpha * (Y_t / S_{t-seasonLength}) + (1-alpha) * (L_{t-1} + T_{t-1}) - Trend:
T_t = beta * (L_t - L_{t-1}) + (1-beta) * T_{t-1} - Seasonal:
S_t = gamma * (Y_t / L_t) + (1-gamma) * S_{t-seasonLength} - Forecast:
F_{t+k} = (L_t + k * T_t) * S_{t+k-seasonLength}
Parameter alpha, beta, gamma dituning per SKU menggunakan optimasi grid search untuk meminimalkan MAPE pada data historis.
Cold-Start Handler: Bayesian Shrinkage untuk SKU Baru
Salah satu masalah paling nyata di Q-commerce adalah SKU baru atau hub baru. Tanpa data historis, Holt-Winters tidak bisa bekerja. Solusinya bukan menggunakan rata-rata mentah — tetapi menggunakan Bayesian shrinkage.
package forecast
// ColdStartHandler menangani SKU/hub baru yang belum punya data historis cukup.
// Menggunakan Bayesian shrinkage: memampatkan estimasi SKU individu ke arah
// rata-rata kategori untuk mengurangi variance pada data sedikit.
type ColdStartHandler struct {
// categoryStore memberikan informasi demand rata-rata per kategori
categoryStore CategoryDemandStore
}
type CategoryDemandStore interface {
// AverageDailyDemand mengembalikan demand harian rata-rata untuk kategori
AverageDailyDemand(ctx context.Context, category string) (float64, error)
// CategoryVariance mengembalikan variance demand dalam kategori
CategoryVariance(ctx context.Context, category string) (float64, error)
}
// ColdStartForecast dihasilkan ketika data historis tidak mencukupi.
type ColdStartForecast struct {
ForecastDemand float64 `json:"forecast_demand"`
Confidence float64 `json:"confidence"` // 0.0 - 1.0
Method string `json:"method"` // "bayesian_shrinkage" | "category_avg"
DataPoints int `json:"data_points"`
}
// BayesianShrinkage menghitung forecast menggunakan shrinkage estimator:
// forecast = weight * sku_mean + (1-weight) * category_mean
// di mana weight = n / (n + prior_strength)
//
// Semakin banyak data SKU (n besar), semakin percaya ke SKU mean.
// Semakin sedikit data, semakin "menyusut" ke category mean.
func (ch *ColdStartHandler) BayesianShrinkage(
ctx context.Context,
skuData []float64,
category string,
) (*ColdStartForecast, error) {
catAvg, err := ch.categoryStore.AverageDailyDemand(ctx, category)
if err != nil {
return nil, fmt.Errorf("get category average: %w", err)
}
catVariance, err := ch.categoryStore.CategoryVariance(ctx, category)
if err != nil {
return nil, fmt.Errorf("get category variance: %w", err)
}
n := len(skuData)
var skuMean float64
if n > 0 {
skuMean = mean(skuData)
} else {
// Tidak ada data sama sekali — pure category average
return &ColdStartForecast{
ForecastDemand: catAvg,
Confidence: 0.3,
Method: "category_avg",
DataPoints: 0,
}, nil
}
// Prior strength: semakin besar category variance, semakin lemah prior
// (butuh lebih banyak data SKU untuk meyakinkan shrinkage)
priorStrength := 5.0
if catVariance > 100 {
priorStrength = 3.0
} else if catVariance < 10 {
priorStrength = 10.0
}
// Bayesian shrinkage formula
weight := float64(n) / (float64(n) + priorStrength)
forecast := weight*skuMean + (1-weight)*catAvg
// Confidence meningkat dengan jumlah data
confidence := math.Min(0.3+float64(n)*0.1, 0.9)
slog.DebugContext(ctx, "cold-start forecast",
"category", category,
"data_points", n,
"sku_mean", skuMean,
"category_mean", catAvg,
"weight", weight,
"forecast", forecast,
)
return &ColdStartForecast{
ForecastDemand: forecast,
Confidence: confidence,
Method: "bayesian_shrinkage",
DataPoints: n,
}, nil
}Kenapa Bayesian Shrinkage?
Tanpa shrinkage, SKU baru dengan 3 hari data bisa menghasilkan forecast yang sangat misleading. Bayangkan: SKU "Es Krim Matcha Premium" baru diluncurkan, 3 hari pertama ada promo besar-besaran — demand 200 unit per hari. Tanpa shrinkage, sistem akan forecast 200 unit terus. Setelah promo selesai, demand turun ke 30 unit. Overstock parah. Shrinkage menarik forecast ke arah rata-rata kategori (misal: 50 unit/hari untuk es krim), sehingga estimasi lebih realistis.
Replenishment Calculator
Setelah forecast didapatkan, kita perlu menentukan: berapa banyak yang harus dipesan ke central warehouse?
package replenishment
import (
"context"
"fmt"
"log/slog"
"time"
)
// ReplenishmentCalculator menentukan jumlah stok yang perlu dipesan.
type ReplenishmentCalculator struct {
inventoryDB InventoryStore
forecastDB ForecastStore
}
type InventoryStore interface {
GetCurrentOnHand(ctx context.Context, sku, hubID string) (int, error)
GetIncomingStock(ctx context.Context, sku, hubID string) (int, time.Time, error)
GetLeadTimeDays(ctx context.Context, sku, hubID string) (int, error)
}
type ForecastStore interface {
GetDailyForecast(ctx context.Context, sku, hubID string, days int) ([]float64, error)
}
// ReplenishmentRequest adalah permintaan pembelian ke central warehouse.
type ReplenishmentRequest struct {
SKU string `json:"sku"`
HubID string `json:"hub_id"`
Quantity int `json:"quantity"`
ReorderPoint float64 `json:"reorder_point"`
ForecastDemand float64 `json:"forecast_demand"`
CurrentOnHand int `json:"current_on_hand"`
SafetyStock int `json:"safety_stock"`
LeadTimeDays int `json:"lead_time_days"`
Priority string `json:"priority"` // high | normal | low
CreatedAt time.Time `json:"created_at"`
}
// CalculateReorderPoint menghitung reorder point berdasarkan formula:
// reorder_point = forecast_demand * lead_time_days + safety_stock
//
// Safety stock dihitung berdasarkan service level yang diinginkan:
// safety_stock = z * sigma * sqrt(lead_time_days)
// di mana:
// z = z-score untuk service level (1.65 untuk 95%, 2.33 untuk 99%)
// sigma = standard deviation forecast error
func (rc *ReplenishmentCalculator) CalculateReorderPoint(
forecastDaily float64,
leadTimeDays int,
forecastSigma float64,
serviceLevel float64,
) float64 {
// z-score mapping
z := 1.65 // default 95% service level
switch {
case serviceLevel >= 0.99:
z = 2.33
case serviceLevel >= 0.975:
z = 1.96
case serviceLevel >= 0.95:
z = 1.65
case serviceLevel >= 0.90:
z = 1.28
}
safetyStock := int(math.Ceil(z * forecastSigma * math.Sqrt(float64(leadTimeDays))))
reorderPoint := forecastDaily*float64(leadTimeDays) + float64(safetyStock)
return reorderPoint
}
// CalculateReplenishment menghitung jumlah yang harus dipesan.
// order_qty = max(0, reorder_point - on_hand - incoming + min_order_qty)
func (rc *ReplenishmentCalculator) CalculateReplenishment(
ctx context.Context,
sku, hubID string,
) (*ReplenishmentRequest, error) {
leadTimeDays, err := rc.inventoryDB.GetLeadTimeDays(ctx, sku, hubID)
if err != nil {
return nil, fmt.Errorf("get lead time: %w", err)
}
onHand, err := rc.inventoryDB.GetCurrentOnHand(ctx, sku, hubID)
if err != nil {
return nil, fmt.Errorf("get on-hand: %w", err)
}
incoming, incomingTime, err := rc.inventoryDB.GetIncomingStock(ctx, sku, hubID)
if err != nil {
return nil, fmt.Errorf("get incoming: %w", err)
}
// Pastikan incoming akan tiba sebelum lead time habis
incomingUsable := 0
if !incomingTime.IsZero() && incomingTime.Before(time.Now().AddDate(0, 0, leadTimeDays)) {
incomingUsable = incoming
}
forecasts, err := rc.forecastDB.GetDailyForecast(ctx, sku, hubID, leadTimeDays)
if err != nil {
return nil, fmt.Errorf("get forecast: %w", err)
}
forecastTotal := 0.0
for _, f := range forecasts {
forecastTotal += f
}
forecastDaily := forecastTotal / float64(len(forecasts))
// Hitung standard deviation forecast error
var sigma float64
if len(forecasts) > 1 {
var sumSquares float64
for _, f := range forecasts {
sumSquares += (f - forecastDaily) * (f - forecastDaily)
}
sigma = math.Sqrt(sumSquares / float64(len(forecasts)))
}
reorderPoint := rc.CalculateReorderPoint(forecastDaily, leadTimeDays, sigma, 0.95)
// Minimum order quantity: untuk efisiensi logistik
minOrderQty := rc.getMinimumOrderQty(sku)
maxOrderQty := rc.getMaximumOrderQty(sku)
orderQty := int(math.Ceil(reorderPoint)) - onHand - incomingUsable
if orderQty < minOrderQty {
orderQty = minOrderQty
}
if orderQty > maxOrderQty {
orderQty = maxOrderQty
}
if orderQty < 0 {
orderQty = 0
}
// Tentukan prioritas
priority := "normal"
daysOfCover := float64(onHand+incomingUsable) / forecastDaily
if daysOfCover < 1.0 {
priority = "high"
} else if daysOfCover > 5.0 {
priority = "low"
}
slog.DebugContext(ctx, "replenishment calculation",
"sku", sku,
"hub_id", hubID,
"forecast_daily", forecastDaily,
"on_hand", onHand,
"incoming", incomingUsable,
"reorder_point", reorderPoint,
"order_qty", orderQty,
"days_of_cover", daysOfCover,
"priority", priority,
)
return &ReplenishmentRequest{
SKU: sku,
HubID: hubID,
Quantity: orderQty,
ReorderPoint: reorderPoint,
ForecastDemand: forecastDaily * float64(leadTimeDays),
CurrentOnHand: onHand,
SafetyStock: int(reorderPoint - forecastDaily*float64(leadTimeDays)),
LeadTimeDays: leadTimeDays,
Priority: priority,
CreatedAt: time.Now(),
}, nil
}
func (rc *ReplenishmentCalculator) getMinimumOrderQty(sku string) int {
// SKU dengan unit besar seperti galon air: minimal 4
// SKU kecil seperti permen: minimal 24
return 1
}
func (rc *ReplenishmentCalculator) getMaximumOrderQty(sku string) int {
return 500
}
// ApplyFEFO menentukan urutan pengambilan barang dari stok berdasarkan
// FEFO (First Expired First Out) untuk fresh goods.
// Mengembalikan batch yang harus dikirim ke hub lebih dulu.
func ApplyFEFO(batches []InventoryBatch) []InventoryBatch {
// Sort by expiry date ascending
sorted := make([]InventoryBatch, len(batches))
copy(sorted, batches)
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[j].ExpiryDate.Before(sorted[i].ExpiryDate) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
}
type InventoryBatch struct {
BatchID string `json:"batch_id"`
SKU string `json:"sku"`
Quantity int `json:"quantity"`
ExpiryDate time.Time `json:"expiry_date"`
ReceivedAt time.Time `json:"received_at"`
}Algoritma Holt-Winters: Decision Flow
flowchart TD
A[Start: Forecast for SKU-Hub] --> B{Data sufficiency?}
B -->|>= 7 days| C[Compute level: mean of first season]
C --> D[Compute initial trend]
D --> E[Compute seasonal indices]
E --> F[Iterate Holt-Winters]
F --> G{Convergence?}
G -->|No, max iter| H[Use current params]
G -->|Yes| I[Generate future forecast]
B -->|< 7 days| J{Any data at all?}
J -->|Yes, 1-6 days| K[Bayesian shrinkage<br/>weight = n / (n + prior)]
K --> L[Sku mean + category mean]
L --> I
J -->|No data| M{Category data exists?}
M -->|Yes| N[Use category average]
N --> I
M -->|No| O[Use global default<br/>+ exponential backoff]
O --> P[Set confidence = 0.2]
P --> I
I --> Q[Apply business constraints]
Q --> R[Minimum: 0 units]
Q --> S[Maximum: warehouse capacity]
Q --> T[Rounding: case pack size]
R --> U[Output forecast]
S --> U
T --> U
style A fill:#3498db,color:#fff
style O fill:#e74c3c,color:#fff
style U fill:#2ecc71,color:#fffForecast Accuracy Monitor
Forecast tanpa monitoring seperti menerbangkan pesawat tanpa instrumen. MAPE adalah metrik utama yang kita pantau.
package monitor
import (
"context"
"fmt"
"log/slog"
"math"
"sync"
"time"
)
// MAPETracker melacak Mean Absolute Percentage Error per SKU.
// Memberikan peringatan ketika akurasi forecast menurun.
type MAPETracker struct {
mu sync.RWMutex
skuStats map[string]*SKUForecastStats
// Thresholds
warningThreshold float64 // MAPE > this = warning
criticalThreshold float64 // MAPE > this = critical alert
}
type SKUForecastStats struct {
SKU string
HubID string
DailyMAPE []float64 // Last 30 days
WeeklyMAPE float64
MonthlyMAPE float64
DataPoints int
LastUpdated time.Time
}
func NewMAPETracker(warningThreshold, criticalThreshold float64) *MAPETracker {
if warningThreshold <= 0 {
warningThreshold = 30.0 // 30% MAPE
}
if criticalThreshold <= 0 {
criticalThreshold = 50.0 // 50% MAPE
}
return &MAPETracker{
skuStats: make(map[string]*SKUForecastStats),
warningThreshold: warningThreshold,
criticalThreshold: criticalThreshold,
}
}
// Record actual demand dan bandingkan dengan forecast untuk menghitung MAPE.
func (mt *MAPETracker) Record(ctx context.Context, sku, hubID string, forecast, actual float64) error {
if actual <= 0 {
// Demand 0 bisa berarti SKU habis (lost sale) atau memang tidak ada permintaan
// Jangan flag sebagai error besar karena bisa misleading
return nil
}
ape := math.Abs(forecast-actual) / actual * 100
key := sku + ":" + hubID
mt.mu.Lock()
defer mt.mu.Unlock()
stats, ok := mt.skuStats[key]
if !ok {
stats = &SKUForecastStats{
SKU: sku,
HubID: hubID,
}
mt.skuStats[key] = stats
}
stats.DailyMAPE = append(stats.DailyMAPE, ape)
if len(stats.DailyMAPE) > 30 {
stats.DailyMAPE = stats.DailyMAPE[len(stats.DailyMAPE)-30:]
}
stats.DataPoints++
stats.LastUpdated = time.Now()
// Compute rolling averages
stats.WeeklyMAPE = rollingMean(stats.DailyMAPE, 7)
stats.MonthlyMAPE = rollingMean(stats.DailyMAPE, 30)
slog.DebugContext(ctx, "recorded forecast accuracy",
"sku", sku,
"hub_id", hubID,
"ape", ape,
"weekly_mape", stats.WeeklyMAPE,
)
// Check thresholds
currentMAPE := stats.WeeklyMAPE
if currentMAPE > mt.criticalThreshold {
return fmt.Errorf("CRITICAL: SKU %s hub %s MAPE=%.1f%% exceeds critical threshold=%.1f%%",
sku, hubID, currentMAPE, mt.criticalThreshold)
}
if currentMAPE > mt.warningThreshold {
return fmt.Errorf("WARNING: SKU %s hub %s MAPE=%.1f%% exceeds warning threshold=%.1f%%",
sku, hubID, currentMAPE, mt.warningThreshold)
}
return nil
}
// GetDegradedSKUs mengembalikan SKU yang MAPE-nya di atas threshold.
func (mt *MAPETracker) GetDegradedSKUs() []DegradedSKU {
mt.mu.RLock()
defer mt.mu.RUnlock()
var degraded []DegradedSKU
for key, stats := range mt.skuStats {
if stats.WeeklyMAPE > mt.warningThreshold {
severity := "warning"
if stats.WeeklyMAPE > mt.criticalThreshold {
severity = "critical"
}
degraded = append(degraded, DegradedSKU{
Key: key,
SKU: stats.SKU,
HubID: stats.HubID,
MAPE: stats.WeeklyMAPE,
Severity: severity,
})
}
}
return degraded
}
type DegradedSKU struct {
Key string
SKU string
HubID string
MAPE float64
Severity string
}
func rollingMean(values []float64, window int) float64 {
if len(values) == 0 {
return 0
}
start := len(values) - window
if start < 0 {
start = 0
}
subset := values[start:]
s := 0.0
for _, v := range subset {
s += v
}
return s / float64(len(subset))
}Orchestrator: Daily Forecast Run
Semua komponen di atas perlu dijalankan secara terkoordinasi setiap hari. Orchestrator ini mengelola seluruh pipeline.
package forecast
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
// DailyForecastOrchestrator mengelola eksekusi forecast harian
// untuk semua SKU di semua hub.
type DailyForecastOrchestrator struct {
featureCollector *FeatureCollector
holtWinters *HoltWintersEngine
coldStart *ColdStartHandler
replenishmentCalc *ReplenishmentCalculator
forecastStore ForecastStore
inventoryDB InventoryStore
// Concurrency control
maxConcurrency int
}
func NewOrchestrator(
fc *FeatureCollector,
hw *HoltWintersEngine,
cs *ColdStartHandler,
rc *ReplenishmentCalculator,
fs ForecastStore,
idb InventoryStore,
maxConcurrency int,
) *DailyForecastOrchestrator {
if maxConcurrency <= 0 {
maxConcurrency = 20
}
return &DailyForecastOrchestrator{
featureCollector: fc,
holtWinters: hw,
coldStart: cs,
replenishmentCalc: rc,
forecastStore: fs,
inventoryDB: idb,
maxConcurrency: maxConcurrency,
}
}
// SKUHubPair adalah pasangan SKU dan hub yang perlu di-forecast.
type SKUHubPair struct {
SKU string
HubID string
}
// Run menjalankan forecast untuk semua pair yang diberikan.
// Menggunakan worker pool untuk concurrency terkontrol.
func (o *DailyForecastOrchestrator) Run(ctx context.Context, pairs []SKUHubPair) error {
slog.InfoContext(ctx, "starting daily forecast run", "total_pairs", len(pairs))
startTime := time.Now()
// Channel untuk fan-out jobs
jobs := make(chan SKUHubPair, len(pairs))
results := make(chan error, len(pairs))
// Pastikan results habis terpakai
var wg sync.WaitGroup
// Start workers
for w := 0; w < o.maxConcurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for pair := range jobs {
err := o.forecastSingle(ctx, pair.SKU, pair.HubID)
if err != nil {
slog.ErrorContext(ctx, "forecast failed",
"sku", pair.SKU,
"hub_id", pair.HubID,
"error", err,
)
}
results <- err
}
}()
}
// Send jobs
for _, pair := range pairs {
jobs <- pair
}
close(jobs)
// Wait for all workers
wg.Wait()
close(results)
// Count errors
var errCount int
for err := range results {
if err != nil {
errCount++
}
}
elapsed := time.Since(startTime)
slog.InfoContext(ctx, "daily forecast run completed",
"total_pairs", len(pairs),
"errors", errCount,
"duration", elapsed,
)
if errCount > 0 {
return fmt.Errorf("forecast run: %d errors out of %d pairs", errCount, len(pairs))
}
return nil
}
func (o *DailyForecastOrchestrator) forecastSingle(ctx context.Context, sku, hubID string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// 1. Dapatkan data historis
historical, err := o.forecastStore.GetHistoricalDemand(ctx, sku, hubID, 90)
if err != nil {
return fmt.Errorf("get historical: %w", err)
}
// 2. Kumpulkan fitur eksternal
features, err := o.featureCollector.CollectFor(ctx, sku, hubID, time.Now())
if err != nil {
slog.WarnContext(ctx, "feature collection partial failure, continuing",
"sku", sku, "hub_id", hubID, "error", err,
)
// Continue with zero features rather than failing entirely
}
// 3. Jalankan forecast
var forecastResult *HoltWintersResult
var forecastDemand float64
if len(historical) >= 7 {
forecastResult, err = o.holtWinters.Forecast(ctx, sku, historical, 7)
if err != nil {
return fmt.Errorf("holt-winters forecast: %w", err)
}
forecastDemand = forecastResult.Forecasts[0]
} else {
// Cold start
csResult, err := o.coldStart.BayesianShrinkage(ctx, historical, inferCategory(sku))
if err != nil {
return fmt.Errorf("cold start: %w", err)
}
forecastDemand = csResult.ForecastDemand
}
// 4. Simpan hasil forecast
if err := o.forecastStore.SaveForecast(ctx, sku, hubID, forecastDemand, features); err != nil {
return fmt.Errorf("save forecast: %w", err)
}
// 5. Hitung replenishment
req, err := o.replenishmentCalc.CalculateReplenishment(ctx, sku, hubID)
if err != nil {
return fmt.Errorf("calculate replenishment: %w", err)
}
// 6. Buat PO jika diperlukan
if req.Quantity > 0 {
if err := o.inventoryDB.CreatePurchaseOrder(ctx, req); err != nil {
return fmt.Errorf("create PO: %w", err)
}
slog.InfoContext(ctx, "purchase order created",
"sku", sku,
"hub_id", hubID,
"quantity", req.Quantity,
"priority", req.Priority,
)
}
return nil
}
func inferCategory(sku string) string {
// Simplified: extract category from SKU prefix
// Real implementation: lookup in product catalog database
if len(sku) >= 3 {
prefix := sku[:3]
categories := map[string]string{
"FNB": "food_beverage",
"FRZ": "frozen",
"DAI": "dairy",
"BEV": "beverage",
"SNK": "snack",
}
if cat, ok := categories[prefix]; ok {
return cat
}
}
return "general"
}Edge Cases
Stok Habis = Lost Data
Saat stok habis, demand aktual tidak tercatat. Data yang ada menunjukkan 'penjualan = 0' padahal demand sebenarnya tinggi. Tanpa koreksi, sistem akan belajar bahwa permintaan menurun — dan makin memperparah stockout.
Promo Cannibalization
Promo SKU A bisa mengorbankan penjualan SKU B (kannibalisasi). Sistem forecasting perlu aware bahwa kenaikan SKU A mungkin berarti penurunan SKU B — bukan kenaikan total demand.
New Hub Stabilization
Hub baru butuh 2-3 minggu untuk mencapai demand pattern stabil. Awalnya demand mungkin rendah (belum dikenal), lalu naik cepat, lalu stabil. Cold-start handler perlu di-retune setelah 14 hari.
Bulk Order Anomaly
Satu order 100 pcs untuk acara kantor bisa terlihat sebagai tren kenaikan. Deteksi outlier dengan Z-score sebelum data masuk ke model.
Weather API Failure
Jika weather API down, jangan gagalkan seluruh forecast. Gunakan climatology average untuk lokasi sebagai fallback.
// DemandCorrection mengoreksi data historis yang hilang karena stockout.
// Menggunakan metode "demand during stockout" imputation.
func DemandCorrection(ts []TimeSeriesPoint, stockoutPeriods []StockoutPeriod) []TimeSeriesPoint {
corrected := make([]TimeSeriesPoint, len(ts))
copy(corrected, ts)
for _, period := range stockoutPeriods {
// Cari periode setelah stockout untuk mengestimasi lost demand
var postStockoutSales []float64
for _, p := range ts {
if p.Timestamp.After(period.End) && p.Timestamp.Before(period.End.Add(48*time.Hour)) {
postStockoutSales = append(postStockoutSales, p.Value)
}
}
if len(postStockoutSales) > 0 {
estimatedDailyDemand := mean(postStockoutSales)
// Impute missing days
for i := range corrected {
if (corrected[i].Timestamp.Equal(period.Start) || corrected[i].Timestamp.After(period.Start)) &&
(corrected[i].Timestamp.Equal(period.End) || corrected[i].Timestamp.Before(period.End)) {
corrected[i].Value = estimatedDailyDemand
corrected[i].Imputed = true
}
}
}
}
return corrected
}
type TimeSeriesPoint struct {
Timestamp time.Time
Value float64
Imputed bool
}
type StockoutPeriod struct {
Start time.Time
End time.Time
}Critical: Jangan Forecast dari Data Cacat
Data penjualan saat stok habis adalah data yang cacat. Jika Anda menggunakan data tersebut apa adanya untuk training model, model akan belajar bahwa permintaan turun — bukan karena tidak ada yang beli, tapi karena tidak ada yang bisa dibeli. Ini feedback loop yang sangat berbahaya: stok habis -> forecast turun -> replenishment turun -> stok makin habis. Selalu imputasi data stockout sebelum training.
Key Takeaways
Gunakan Holt-Winters Multiplicative untuk Demand Musiman
Bayesian Shrinkage untuk Cold Start
FEFO untuk Fresh Goods adalah Wajib
MAPE Monitoring sebagai Early Warning System
Concurrent Execution via Worker Pool
External Regressors Jangan Diabaikan
Referensi
- Holt, C. C. (1957). "Forecasting seasonals and trends by exponentially weighted moving averages"
- Winters, P. R. (1960). "Forecasting sales by exponentially weighted moving averages"
- Efron, B. & Morris, C. (1975). "Data analysis using Stein's estimator and its generalizations"
- FEFO methodology: "Inventory Management for Perishable Goods" — Operations Research, 2019