ArizalMuluk commited on
Commit
b0e8ab4
·
verified ·
1 Parent(s): f78d8cf

Upload 13 files

Browse files
Files changed (3) hide show
  1. app/forecaster.py +68 -94
  2. app/main.py +94 -91
  3. static/index.html +65 -13
app/forecaster.py CHANGED
@@ -1,9 +1,15 @@
1
  """
2
  Future Forecaster
3
- Prediksi 1 bulan ke depan dengan pola:
4
- Data terakhir = Bulan N
5
- Skip = Bulan N+1 (laporan belum masuk)
6
- Prediksi = Bulan N+2
 
 
 
 
 
 
7
  """
8
  import numpy as np
9
  import pandas as pd
@@ -19,15 +25,11 @@ FITUR_LGBM = [
19
 
20
 
21
  def _get_holiday(target_date: pd.Timestamp, country_code: str) -> int:
22
- """Cek apakah bulan target mengandung hari libur."""
23
  try:
24
  import holidays
25
  hl = getattr(holidays, country_code)(years=[target_date.year])
26
- # Cek seluruh hari dalam bulan target
27
  days_in_month = pd.date_range(
28
- start=target_date.replace(day=1),
29
- end=target_date,
30
- freq="D"
31
  )
32
  return int(any(d.date() in hl for d in days_in_month))
33
  except Exception:
@@ -40,112 +42,89 @@ def forecast_one_product(
40
  model,
41
  le_cat,
42
  le_types,
43
- country_code: str = "ID",
 
44
  ) -> dict:
45
  """
46
- Prediksi 1 bulan ke depan untuk 1 produk.
47
-
48
- Pola:
49
- N = bulan data terakhir yang tersedia
50
- N+1 = dilewati (laporan belum masuk)
51
- N+2 = bulan yang diprediksi
52
-
53
- Returns dict berisi info prediksi + data historis untuk grafik.
54
  """
55
- # Ambil data produk, urutkan by date
56
  df_prod = df_cont[df_cont["Product_ID"] == product_id].copy()
57
  df_prod = df_prod.sort_values("Date").reset_index(drop=True)
58
 
59
  if df_prod.empty:
60
  return {"error": f"Produk {product_id} tidak ditemukan."}
61
 
62
- # ── Tentukan bulan target (N+2) ──
63
- last_date = df_prod["Date"].max()
64
- skip_date = last_date + relativedelta(months=1) # N+1 (dilewati)
65
- target_date = last_date + relativedelta(months=2) # N+2 (diprediksi)
66
 
67
- # ── Bangun fitur untuk bulan target ──
68
- qty_history = df_prod["Quantity"].values # array historis
 
 
 
 
 
 
69
 
70
- # Lag features — ambil dari histori yang ada
71
- def safe_lag(arr, n):
72
- return float(arr[-n]) if len(arr) >= n else 0.0
73
 
74
- qty_lag1 = safe_lag(qty_history, 1)
75
- qty_lag2 = safe_lag(qty_history, 2)
76
- qty_lag3 = safe_lag(qty_history, 3)
77
- qty_lag12 = safe_lag(qty_history, 12)
78
 
79
- # Rolling mean
 
 
 
80
  qty_roll3 = float(np.mean(qty_history[-3:])) if len(qty_history) >= 3 else float(np.mean(qty_history))
81
  qty_roll6 = float(np.mean(qty_history[-6:])) if len(qty_history) >= 6 else float(np.mean(qty_history))
82
  qty_roll12 = float(np.mean(qty_history[-12:])) if len(qty_history) >= 12 else float(np.mean(qty_history))
83
 
84
- # Fitur statis produk
85
  price = float(df_prod["Price"].iloc[-1])
86
  category = str(df_prod["Category"].iloc[-1])
87
  types = str(df_prod["Types"].iloc[-1])
88
  name = str(df_prod["Product_Name"].iloc[-1])
89
 
90
- # Encode kategori — handle unseen label dengan fallback
91
- try:
92
- cat_enc = int(le_cat.transform([category])[0])
93
- except ValueError:
94
- cat_enc = 0
95
-
96
- try:
97
- types_enc = int(le_types.transform([types])[0])
98
- except ValueError:
99
- types_enc = 0
100
 
101
  is_holiday = _get_holiday(target_date, country_code)
102
 
103
- # ── Buat DataFrame fitur ──
104
  feat_row = pd.DataFrame([{
105
- "Price" : price,
106
- "Is_Holiday" : is_holiday,
107
- "Bulan" : target_date.month,
108
- "Kuartal" : (target_date.month - 1) // 3 + 1,
109
- "Tahun" : target_date.year,
110
- "Category_enc": cat_enc,
111
- "Types_enc" : types_enc,
112
- "Qty_lag1" : qty_lag1,
113
- "Qty_lag2" : qty_lag2,
114
- "Qty_lag3" : qty_lag3,
115
- "Qty_lag12" : qty_lag12,
116
- "Qty_roll3" : qty_roll3,
117
- "Qty_roll6" : qty_roll6,
118
- "Qty_roll12" : qty_roll12,
119
  }])[FITUR_LGBM]
120
 
121
- # ── Prediksi ──
122
- raw_pred = model.predict(feat_row)[0]
123
- prediksi = max(0, round(raw_pred))
124
 
125
- # ── Data historis untuk grafik (12 bulan terakhir) ──
126
- df_hist = df_prod.tail(12).copy()
127
- hist_labels = pd.to_datetime(df_hist["Date"]).dt.strftime("%b %Y").tolist()
128
- hist_qty = df_hist["Quantity"].tolist()
129
 
130
  return {
131
- "product_id" : product_id,
132
- "product_name" : name,
133
- "category" : category,
134
- "types" : types,
135
- "last_data" : last_date.strftime("%b %Y"),
136
- "skip_month" : skip_date.strftime("%b %Y"),
137
- "target_month" : target_date.strftime("%b %Y"),
138
- "prediksi" : int(prediksi),
139
- "fitur_input" : {
140
- "qty_lag1" : qty_lag1,
141
- "qty_lag2" : qty_lag2,
142
- "qty_lag3" : qty_lag3,
143
- "qty_lag12" : qty_lag12,
144
- "qty_roll3" : round(qty_roll3, 2),
145
- "is_holiday": is_holiday,
146
  },
147
- "hist_labels" : hist_labels,
148
- "hist_qty" : hist_qty,
149
  }
150
 
151
 
@@ -154,27 +133,22 @@ def forecast_all_products(
154
  model,
155
  le_cat,
156
  le_types,
157
- country_code: str = "ID",
158
- top_n : int = None,
 
159
  ) -> list:
160
- """
161
- Prediksi N+2 untuk semua produk (atau top N berdasarkan qty).
162
- """
163
  if top_n:
164
  products = (
165
  df_cont.groupby("Product_ID")["Quantity"]
166
- .sum()
167
- .sort_values(ascending=False)
168
- .head(top_n)
169
- .index.tolist()
170
  )
171
  else:
172
  products = df_cont["Product_ID"].unique().tolist()
173
 
174
  results = []
175
  for pid in products:
176
- r = forecast_one_product(df_cont, pid, model, le_cat, le_types, country_code)
177
  if "error" not in r:
178
  results.append(r)
179
-
180
  return results
 
1
  """
2
  Future Forecaster
3
+ Mendukung dua pola prediksi:
4
+
5
+ skip_n1=True (default laporan belum masuk):
6
+ N = bulan data terakhir
7
+ N+1 = dilewati
8
+ N+2 = bulan yang diprediksi
9
+
10
+ skip_n1=False (data sudah tersedia):
11
+ N = bulan data terakhir
12
+ N+1 = bulan yang diprediksi langsung
13
  """
14
  import numpy as np
15
  import pandas as pd
 
25
 
26
 
27
  def _get_holiday(target_date: pd.Timestamp, country_code: str) -> int:
 
28
  try:
29
  import holidays
30
  hl = getattr(holidays, country_code)(years=[target_date.year])
 
31
  days_in_month = pd.date_range(
32
+ start=target_date.replace(day=1), end=target_date, freq="D"
 
 
33
  )
34
  return int(any(d.date() in hl for d in days_in_month))
35
  except Exception:
 
42
  model,
43
  le_cat,
44
  le_types,
45
+ country_code: str = "ID",
46
+ skip_n1 : bool = True,
47
  ) -> dict:
48
  """
49
+ Prediksi 1 bulan ke depan.
50
+ skip_n1=True → prediksi N+2 (laporan N+1 belum masuk)
51
+ skip_n1=False → prediksi N+1 (data sudah tersedia)
 
 
 
 
 
52
  """
 
53
  df_prod = df_cont[df_cont["Product_ID"] == product_id].copy()
54
  df_prod = df_prod.sort_values("Date").reset_index(drop=True)
55
 
56
  if df_prod.empty:
57
  return {"error": f"Produk {product_id} tidak ditemukan."}
58
 
59
+ last_date = df_prod["Date"].max()
 
 
 
60
 
61
+ if skip_n1:
62
+ skip_date = last_date + relativedelta(months=1)
63
+ target_date = last_date + relativedelta(months=2)
64
+ pola_label = "N+2 (laporan N+1 belum masuk)"
65
+ else:
66
+ skip_date = None
67
+ target_date = last_date + relativedelta(months=1)
68
+ pola_label = "N+1 (data sudah tersedia)"
69
 
70
+ qty_history = df_prod["Quantity"].values
 
 
71
 
72
+ def safe_lag(n):
73
+ return float(qty_history[-n]) if len(qty_history) >= n else 0.0
 
 
74
 
75
+ qty_lag1 = safe_lag(1)
76
+ qty_lag2 = safe_lag(2)
77
+ qty_lag3 = safe_lag(3)
78
+ qty_lag12 = safe_lag(12)
79
  qty_roll3 = float(np.mean(qty_history[-3:])) if len(qty_history) >= 3 else float(np.mean(qty_history))
80
  qty_roll6 = float(np.mean(qty_history[-6:])) if len(qty_history) >= 6 else float(np.mean(qty_history))
81
  qty_roll12 = float(np.mean(qty_history[-12:])) if len(qty_history) >= 12 else float(np.mean(qty_history))
82
 
 
83
  price = float(df_prod["Price"].iloc[-1])
84
  category = str(df_prod["Category"].iloc[-1])
85
  types = str(df_prod["Types"].iloc[-1])
86
  name = str(df_prod["Product_Name"].iloc[-1])
87
 
88
+ try: cat_enc = int(le_cat.transform([category])[0])
89
+ except: cat_enc = 0
90
+ try: types_enc = int(le_types.transform([types])[0])
91
+ except: types_enc = 0
 
 
 
 
 
 
92
 
93
  is_holiday = _get_holiday(target_date, country_code)
94
 
 
95
  feat_row = pd.DataFrame([{
96
+ "Price": price, "Is_Holiday": is_holiday,
97
+ "Bulan": target_date.month, "Kuartal": (target_date.month - 1) // 3 + 1,
98
+ "Tahun": target_date.year, "Category_enc": cat_enc, "Types_enc": types_enc,
99
+ "Qty_lag1": qty_lag1, "Qty_lag2": qty_lag2, "Qty_lag3": qty_lag3,
100
+ "Qty_lag12": qty_lag12, "Qty_roll3": qty_roll3,
101
+ "Qty_roll6": qty_roll6, "Qty_roll12": qty_roll12,
 
 
 
 
 
 
 
 
102
  }])[FITUR_LGBM]
103
 
104
+ prediksi = max(0, round(model.predict(feat_row)[0]))
 
 
105
 
106
+ df_hist = df_prod.tail(12).copy()
107
+ hist_labels = pd.to_datetime(df_hist["Date"]).dt.strftime("%b %Y").tolist()
108
+ hist_qty = df_hist["Quantity"].tolist()
 
109
 
110
  return {
111
+ "product_id" : product_id,
112
+ "product_name": name,
113
+ "category" : category,
114
+ "types" : types,
115
+ "last_data" : last_date.strftime("%b %Y"),
116
+ "skip_month" : skip_date.strftime("%b %Y") if skip_date else None,
117
+ "target_month": target_date.strftime("%b %Y"),
118
+ "prediksi" : int(prediksi),
119
+ "skip_n1" : skip_n1,
120
+ "pola_label" : pola_label,
121
+ "fitur_input" : {
122
+ "qty_lag1": qty_lag1, "qty_lag2": qty_lag2,
123
+ "qty_lag3": qty_lag3, "qty_lag12": qty_lag12,
124
+ "qty_roll3": round(qty_roll3, 2), "is_holiday": is_holiday,
 
125
  },
126
+ "hist_labels" : hist_labels,
127
+ "hist_qty" : hist_qty,
128
  }
129
 
130
 
 
133
  model,
134
  le_cat,
135
  le_types,
136
+ country_code: str = "ID",
137
+ top_n : int = None,
138
+ skip_n1 : bool = True,
139
  ) -> list:
 
 
 
140
  if top_n:
141
  products = (
142
  df_cont.groupby("Product_ID")["Quantity"]
143
+ .sum().sort_values(ascending=False)
144
+ .head(top_n).index.tolist()
 
 
145
  )
146
  else:
147
  products = df_cont["Product_ID"].unique().tolist()
148
 
149
  results = []
150
  for pid in products:
151
+ r = forecast_one_product(df_cont, pid, model, le_cat, le_types, country_code, skip_n1)
152
  if "error" not in r:
153
  results.append(r)
 
154
  return results
app/main.py CHANGED
@@ -326,14 +326,16 @@ def list_products(session_id: str):
326
  # FORECAST MASA DEPAN (N+2)
327
  # ─────────────────────────────────────────
328
  @app.get("/forecast/{session_id}")
329
- def forecast(session_id: str, product_id: Optional[str] = None):
 
 
 
 
330
  """
331
- Prediksi stok masa depan bulan N+2.
332
 
333
- Pola:
334
- N = bulan data terakhir
335
- N+1 = dilewati (laporan belum masuk)
336
- N+2 = bulan yang diprediksi
337
 
338
  Jika product_id diisi → prediksi 1 produk.
339
  Jika kosong → prediksi top 10 produk terlaris.
@@ -354,56 +356,60 @@ def forecast(session_id: str, product_id: Optional[str] = None):
354
 
355
  # Prediksi
356
  if product_id:
357
- raw = forecast_one_product(df_cont, product_id, model, le_cat, le_types, country_code)
358
  if "error" in raw:
359
  raise HTTPException(status_code=404, detail=raw["error"])
360
  forecasts = [raw]
361
  else:
362
  forecasts = forecast_all_products(
363
- df_cont, model, le_cat, le_types, country_code, top_n=10
364
  )
365
 
366
  # Bangun response dengan chart gabungan
367
  output = []
368
  for f in forecasts:
369
- # Gabungkan label historis + skip + target
370
- all_labels = f["hist_labels"] + [f["skip_month"], f["target_month"]]
371
- all_qty = f["hist_qty"] + [None, None] # skip & target belum ada aktual
372
-
373
- # Chart gabungan: historis + prediksi future
374
  chart = _build_forecast_chart(
375
- hist_labels = f["hist_labels"],
376
- hist_qty = f["hist_qty"],
377
- skip_month = f["skip_month"],
378
- target_month = f["target_month"],
379
- prediksi = f["prediksi"],
380
- product_id = f["product_id"],
381
- product_name = f["product_name"],
 
382
  )
383
 
 
 
 
 
 
 
 
 
 
384
  output.append({
385
- "product_id" : f["product_id"],
386
- "product_name" : f["product_name"],
387
- "category" : f["category"],
388
- "types" : f["types"],
389
- "forecast" : {
390
  "last_data" : f["last_data"],
391
  "skip_month" : f["skip_month"],
392
  "target_month": f["target_month"],
393
  "prediksi_pcs": f["prediksi"],
394
- "keterangan" : (
395
- f"Prediksi stok untuk {f['target_month']} "
396
- f"(data terakhir: {f['last_data']}, "
397
- f"bulan {f['skip_month']} dilewati karena laporan belum masuk)"
398
- ),
399
  },
400
- "chart_json" : json.loads(chart),
401
- "fitur_input" : f["fitur_input"],
402
  })
403
 
 
404
  return {
405
  "status" : "success",
406
- "pattern" : "N → skip N+1 → prediksi N+2",
 
407
  "results" : output,
408
  }
409
 
@@ -416,80 +422,77 @@ def _build_forecast_chart(
416
  prediksi : int,
417
  product_id : str,
418
  product_name: str,
 
419
  ) -> str:
420
  """Buat grafik Plotly gabungan historis + prediksi future."""
421
  import plotly.graph_objects as go
422
 
423
  fig = go.Figure()
424
-
425
- # Garis historis
426
- fig.add_trace(go.Scatter(
427
- x = hist_labels,
428
- y = hist_qty,
429
- mode = "lines+markers",
430
- name = "Historis Aktual",
431
- line = dict(color="royalblue", width=2),
432
- marker = dict(symbol="circle", size=7),
433
- ))
434
-
435
- # Titik prediksi future (N+2) — dengan garis putus dari data terakhir
436
  last_label = hist_labels[-1]
437
  last_qty = hist_qty[-1]
438
 
439
- # Garis putus-putus dari data terakhir ke prediksi (lewati skip)
440
  fig.add_trace(go.Scatter(
441
- x = [last_label, skip_month, target_month],
442
- y = [last_qty, None, prediksi],
443
- mode = "lines+markers",
444
- name = f"Prediksi {target_month}",
445
- line = dict(color="darkorange", width=2.5, dash="dot"),
446
- marker = dict(
447
- symbol = ["circle", "x", "star"],
448
- size = [0, 10, 14],
449
- color = ["darkorange", "gray", "darkorange"],
450
- ),
451
- connectgaps = False,
452
  ))
453
 
454
- # Anotasi prediksi
455
- fig.add_annotation(
456
- x = target_month,
457
- y = prediksi,
458
- text = f"<b>{prediksi} pcs</b>",
459
- showarrow = True,
460
- arrowhead = 2,
461
- arrowcolor= "darkorange",
462
- font = dict(size=13, color="darkorange"),
463
- bgcolor = "#fff7ed",
464
- bordercolor = "darkorange",
465
- borderwidth = 1,
466
- ay = -40,
467
- )
468
-
469
- # Anotasi skip month
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  fig.add_annotation(
471
- x = skip_month,
472
- y = 0,
473
- text = "⏭ Laporan<br>belum masuk",
474
- showarrow = False,
475
- font = dict(size=10, color="#94a3b8"),
476
- bgcolor = "#f8fafc",
477
- bordercolor = "#e2e8f0",
478
  )
479
 
480
  fig.update_layout(
481
- title = dict(
482
- text = f"Prediksi Stok — {product_id} ({product_name})<br>"
483
- f"<sup>Historis 12 bulan + Prediksi {target_month}</sup>",
484
- font = dict(size=14),
485
  ),
486
- xaxis = dict(title="Bulan", tickangle=-45),
487
- yaxis = dict(title="Jumlah Stok Keluar (pcs)", rangemode="tozero"),
488
- legend = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
489
- plot_bgcolor = "white",
490
- paper_bgcolor = "white",
491
- hovermode = "x unified",
492
- margin = dict(l=50, r=30, t=100, b=80),
493
  )
494
  fig.update_xaxes(showgrid=True, gridcolor="#eee")
495
  fig.update_yaxes(showgrid=True, gridcolor="#eee")
 
326
  # FORECAST MASA DEPAN (N+2)
327
  # ─────────────────────────────────────────
328
  @app.get("/forecast/{session_id}")
329
+ def forecast(
330
+ session_id: str,
331
+ product_id: Optional[str] = None,
332
+ skip_n1 : bool = True,
333
+ ):
334
  """
335
+ Prediksi stok masa depan.
336
 
337
+ **skip_n1=true** (default): prediksi N+2 — laporan bulan N+1 belum masuk
338
+ **skip_n1=false**: prediksi N+1 — data sudah tersedia langsung
 
 
339
 
340
  Jika product_id diisi → prediksi 1 produk.
341
  Jika kosong → prediksi top 10 produk terlaris.
 
356
 
357
  # Prediksi
358
  if product_id:
359
+ raw = forecast_one_product(df_cont, product_id, model, le_cat, le_types, country_code, skip_n1)
360
  if "error" in raw:
361
  raise HTTPException(status_code=404, detail=raw["error"])
362
  forecasts = [raw]
363
  else:
364
  forecasts = forecast_all_products(
365
+ df_cont, model, le_cat, le_types, country_code, top_n=10, skip_n1=skip_n1
366
  )
367
 
368
  # Bangun response dengan chart gabungan
369
  output = []
370
  for f in forecasts:
 
 
 
 
 
371
  chart = _build_forecast_chart(
372
+ hist_labels = f["hist_labels"],
373
+ hist_qty = f["hist_qty"],
374
+ skip_month = f["skip_month"],
375
+ target_month = f["target_month"],
376
+ prediksi = f["prediksi"],
377
+ product_id = f["product_id"],
378
+ product_name = f["product_name"],
379
+ skip_n1 = f["skip_n1"],
380
  )
381
 
382
+ # Keterangan dinamis
383
+ if f["skip_n1"]:
384
+ ket = (f"Prediksi stok untuk {f['target_month']} "
385
+ f"(data terakhir: {f['last_data']}, "
386
+ f"bulan {f['skip_month']} dilewati — laporan belum masuk)")
387
+ else:
388
+ ket = (f"Prediksi stok untuk {f['target_month']} "
389
+ f"(data terakhir: {f['last_data']}, prediksi langsung N+1)")
390
+
391
  output.append({
392
+ "product_id" : f["product_id"],
393
+ "product_name": f["product_name"],
394
+ "category" : f["category"],
395
+ "types" : f["types"],
396
+ "forecast" : {
397
  "last_data" : f["last_data"],
398
  "skip_month" : f["skip_month"],
399
  "target_month": f["target_month"],
400
  "prediksi_pcs": f["prediksi"],
401
+ "pola" : f["pola_label"],
402
+ "keterangan" : ket,
 
 
 
403
  },
404
+ "chart_json" : json.loads(chart),
405
+ "fitur_input" : f["fitur_input"],
406
  })
407
 
408
+ pola_str = "N → skip N+1 → prediksi N+2" if skip_n1 else "N → prediksi N+1"
409
  return {
410
  "status" : "success",
411
+ "skip_n1" : skip_n1,
412
+ "pattern" : pola_str,
413
  "results" : output,
414
  }
415
 
 
422
  prediksi : int,
423
  product_id : str,
424
  product_name: str,
425
+ skip_n1 : bool = True,
426
  ) -> str:
427
  """Buat grafik Plotly gabungan historis + prediksi future."""
428
  import plotly.graph_objects as go
429
 
430
  fig = go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
431
  last_label = hist_labels[-1]
432
  last_qty = hist_qty[-1]
433
 
434
+ # Garis historis
435
  fig.add_trace(go.Scatter(
436
+ x=hist_labels, y=hist_qty,
437
+ mode="lines+markers", name="Historis Aktual",
438
+ line=dict(color="royalblue", width=2),
439
+ marker=dict(symbol="circle", size=7),
 
 
 
 
 
 
 
440
  ))
441
 
442
+ if skip_n1:
443
+ # Pola N+2: garis putus melewati skip_month (gap)
444
+ fig.add_trace(go.Scatter(
445
+ x=[last_label, skip_month, target_month],
446
+ y=[last_qty, None, prediksi],
447
+ mode="lines+markers",
448
+ name=f"Prediksi {target_month}",
449
+ line=dict(color="darkorange", width=2.5, dash="dot"),
450
+ marker=dict(symbol=["circle","x","star"], size=[0,10,14],
451
+ color=["darkorange","gray","darkorange"]),
452
+ connectgaps=False,
453
+ ))
454
+ # Anotasi skip
455
+ fig.add_annotation(
456
+ x=skip_month, y=max(hist_qty) * 0.1 if hist_qty else 0,
457
+ text="⏭ Laporan<br>belum masuk",
458
+ showarrow=False,
459
+ font=dict(size=10, color="#94a3b8"),
460
+ bgcolor="#f8fafc", bordercolor="#e2e8f0",
461
+ )
462
+ subtitle = f"Historis 12 bulan + Prediksi {target_month} (pola N+2)"
463
+ else:
464
+ # Pola N+1: garis langsung ke prediksi tanpa gap
465
+ fig.add_trace(go.Scatter(
466
+ x=[last_label, target_month],
467
+ y=[last_qty, prediksi],
468
+ mode="lines+markers",
469
+ name=f"Prediksi {target_month}",
470
+ line=dict(color="darkorange", width=2.5, dash="dot"),
471
+ marker=dict(symbol=["circle","star"], size=[0,14],
472
+ color=["darkorange","darkorange"]),
473
+ ))
474
+ subtitle = f"Historis 12 bulan + Prediksi {target_month} (pola N+1)"
475
+
476
+ # Anotasi nilai prediksi
477
  fig.add_annotation(
478
+ x=target_month, y=prediksi,
479
+ text=f"<b>{prediksi} pcs</b>",
480
+ showarrow=True, arrowhead=2, arrowcolor="darkorange",
481
+ font=dict(size=13, color="darkorange"),
482
+ bgcolor="#fff7ed", bordercolor="darkorange", borderwidth=1,
483
+ ay=-40,
 
484
  )
485
 
486
  fig.update_layout(
487
+ title=dict(
488
+ text=f"Prediksi Stok — {product_id} ({product_name})<br><sup>{subtitle}</sup>",
489
+ font=dict(size=14),
 
490
  ),
491
+ xaxis=dict(title="Bulan", tickangle=-45),
492
+ yaxis=dict(title="Jumlah Stok Keluar (pcs)", rangemode="tozero"),
493
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
494
+ plot_bgcolor="white", paper_bgcolor="white",
495
+ hovermode="x unified", margin=dict(l=50, r=30, t=100, b=80),
 
 
496
  )
497
  fig.update_xaxes(showgrid=True, gridcolor="#eee")
498
  fig.update_yaxes(showgrid=True, gridcolor="#eee")
static/index.html CHANGED
@@ -257,14 +257,35 @@
257
  <div class="card hidden" id="card-forecast">
258
  <h2><span class="step-badge">4</span> Prediksi Stok Masa Depan</h2>
259
 
260
- <div class="alert alert-info">
261
- 📅 <strong>Pola prediksi:</strong>
262
- Data terakhir <strong>(N)</strong> →
263
- Bulan <strong>(N+1)</strong> dilewati karena laporan belum masuk →
264
- Prediksi stok bulan <strong>(N+2)</strong>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  </div>
266
 
267
- <div class="prod-row" style="margin-top:16px">
268
  <div>
269
  <label>Pilih Produk (kosongkan untuk top 10 terlaris)</label>
270
  <select id="forecast-product-select">
@@ -508,6 +529,30 @@ async function loadProductList() {
508
  // ─────────────────────────────────────
509
  // FORECAST MASA DEPAN
510
  // ─────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  async function loadForecast() {
512
  if (!sessionId) return;
513
 
@@ -516,9 +561,10 @@ async function loadForecast() {
516
  output.innerHTML = '<div class="alert alert-info"><span class="spinner"></span> Menghitung prediksi masa depan...</div>';
517
 
518
  try {
 
519
  const url = pid
520
- ? `/forecast/${sessionId}?product_id=${encodeURIComponent(pid)}`
521
- : `/forecast/${sessionId}`;
522
  const res = await fetch(url);
523
  const data = await res.json();
524
 
@@ -530,9 +576,11 @@ async function loadForecast() {
530
  output.innerHTML = '';
531
 
532
  // Info pola
 
 
533
  output.innerHTML += `
534
- <div class="alert alert-success" style="margin-bottom:16px">
535
- Pola: <strong>${data.pattern}</strong>
536
  </div>`;
537
 
538
  data.results.forEach(r => {
@@ -565,9 +613,13 @@ async function loadForecast() {
565
  <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">Data Terakhir</div>
566
  <div style="font-size:1.1rem; font-weight:700; color:#15803d">${f.last_data}</div>
567
  </div>
568
- <div class="metric-card" style="background:#fffbeb; border-color:#fde68a">
569
- <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">Dilewati (laporan belum masuk)</div>
570
- <div style="font-size:1.1rem; font-weight:700; color:#92400e">${f.skip_month}</div>
 
 
 
 
571
  </div>
572
  <div class="metric-card" style="background:#eff6ff; border-color:#bfdbfe">
573
  <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">🎯 Prediksi Stok</div>
 
257
  <div class="card hidden" id="card-forecast">
258
  <h2><span class="step-badge">4</span> Prediksi Stok Masa Depan</h2>
259
 
260
+ <!-- Toggle pola prediksi -->
261
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
262
+ <div id="pola-skip" onclick="setPola(true)"
263
+ style="border:2px solid var(--primary); border-radius:10px; padding:14px 16px;
264
+ cursor:pointer; background:#eff6ff; transition:.15s;">
265
+ <div style="font-weight:700; color:var(--primary); margin-bottom:4px">
266
+ ⏭ Lewati N+1
267
+ <span style="font-size:.72rem; background:var(--primary); color:#fff;
268
+ border-radius:4px; padding:1px 7px; margin-left:6px">DEFAULT</span>
269
+ </div>
270
+ <div style="font-size:.82rem; color:var(--muted)">
271
+ Laporan bulan ini <strong>belum masuk</strong>.<br>
272
+ Prediksi untuk bulan <strong>N+2</strong>.
273
+ </div>
274
+ </div>
275
+ <div id="pola-direct" onclick="setPola(false)"
276
+ style="border:2px solid var(--border); border-radius:10px; padding:14px 16px;
277
+ cursor:pointer; background:#f8fafc; transition:.15s;">
278
+ <div style="font-weight:700; color:var(--muted); margin-bottom:4px">
279
+ ✅ Data Sudah Tersedia
280
+ </div>
281
+ <div style="font-size:.82rem; color:var(--muted)">
282
+ Laporan bulan ini <strong>sudah ada</strong>.<br>
283
+ Prediksi langsung bulan <strong>N+1</strong>.
284
+ </div>
285
+ </div>
286
  </div>
287
 
288
+ <div class="prod-row" style="margin-top:14px">
289
  <div>
290
  <label>Pilih Produk (kosongkan untuk top 10 terlaris)</label>
291
  <select id="forecast-product-select">
 
529
  // ─────────────────────────────────────
530
  // FORECAST MASA DEPAN
531
  // ─────────────────────────────────────
532
+ let skipN1 = true; // default: lewati N+1
533
+
534
+ function setPola(doSkip) {
535
+ skipN1 = doSkip;
536
+ const elSkip = document.getElementById('pola-skip');
537
+ const elDirect = document.getElementById('pola-direct');
538
+
539
+ if (doSkip) {
540
+ elSkip.style.border = '2px solid var(--primary)';
541
+ elSkip.style.background = '#eff6ff';
542
+ elSkip.querySelector('div').style.color = 'var(--primary)';
543
+ elDirect.style.border = '2px solid var(--border)';
544
+ elDirect.style.background = '#f8fafc';
545
+ elDirect.querySelector('div').style.color = 'var(--muted)';
546
+ } else {
547
+ elDirect.style.border = '2px solid var(--success)';
548
+ elDirect.style.background = '#f0fdf4';
549
+ elDirect.querySelector('div').style.color = 'var(--success)';
550
+ elSkip.style.border = '2px solid var(--border)';
551
+ elSkip.style.background = '#f8fafc';
552
+ elSkip.querySelector('div').style.color = 'var(--muted)';
553
+ }
554
+ }
555
+
556
  async function loadForecast() {
557
  if (!sessionId) return;
558
 
 
561
  output.innerHTML = '<div class="alert alert-info"><span class="spinner"></span> Menghitung prediksi masa depan...</div>';
562
 
563
  try {
564
+ const skipParam = `skip_n1=${skipN1}`;
565
  const url = pid
566
+ ? `/forecast/${sessionId}?product_id=${encodeURIComponent(pid)}&${skipParam}`
567
+ : `/forecast/${sessionId}?${skipParam}`;
568
  const res = await fetch(url);
569
  const data = await res.json();
570
 
 
576
  output.innerHTML = '';
577
 
578
  // Info pola
579
+ const polaColor = data.skip_n1 ? 'alert-info' : 'alert-success';
580
+ const polaIco = data.skip_n1 ? '⏭' : '✅';
581
  output.innerHTML += `
582
+ <div class="alert ${polaColor}" style="margin-bottom:16px">
583
+ ${polaIco} Pola aktif: <strong>${data.pattern}</strong>
584
  </div>`;
585
 
586
  data.results.forEach(r => {
 
613
  <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">Data Terakhir</div>
614
  <div style="font-size:1.1rem; font-weight:700; color:#15803d">${f.last_data}</div>
615
  </div>
616
+ <div class="metric-card" style="${f.skip_month ? 'background:#fffbeb; border-color:#fde68a' : 'background:#f0fdf4; border-color:#bbf7d0'}">
617
+ <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">
618
+ ${f.skip_month ? '⏭ Dilewati (N+1)' : '✅ Mode N+1'}
619
+ </div>
620
+ <div style="font-size:1.1rem; font-weight:700; color:${f.skip_month ? '#92400e' : '#15803d'}">
621
+ ${f.skip_month || '—'}
622
+ </div>
623
  </div>
624
  <div class="metric-card" style="background:#eff6ff; border-color:#bfdbfe">
625
  <div style="font-size:.75rem; color:var(--muted); margin-bottom:4px">🎯 Prediksi Stok</div>