first commit
This commit is contained in:
commit
e527c775c7
|
|
@ -0,0 +1,19 @@
|
|||
# ===== Data & rahasia (JANGAN pernah di-commit) =====
|
||||
.streamlit/secrets.toml
|
||||
data/users.json
|
||||
backups/
|
||||
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# ===== OS / editor =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# config.toml
|
||||
# ===========
|
||||
# Konfigurasi tema & server Streamlit.
|
||||
#
|
||||
# Warna primer (#2563eb) sengaja disamakan dengan warna garis "Prediksi"
|
||||
# yang dipakai di grafik Plotly (lihat views/beranda.py & detail_peramalan.py),
|
||||
# supaya elemen native Streamlit (tombol, radio aktif, dsb) senada dengan
|
||||
# grafik custom.
|
||||
|
||||
[theme]
|
||||
primaryColor = "#2563eb"
|
||||
backgroundColor = "#ffffff"
|
||||
secondaryBackgroundColor = "#f1f5f9"
|
||||
textColor = "#0f172a"
|
||||
font = "sans serif"
|
||||
|
||||
[server]
|
||||
# Sedikit dinaikkan dari default (200MB tetap muat) karena file all_results.pkl
|
||||
# berisi beberapa model Prophet + data historis sekaligus untuk 5 produk.
|
||||
maxUploadSize = 200
|
||||
enableXsrfProtection = true
|
||||
|
||||
[browser]
|
||||
gatherUsageStats = false
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Sistem Peramalan Permintaan Produk - UD Aan Jaya Toko
|
||||
|
||||
Aplikasi web untuk memperkirakan permintaan lima produk (minyak, gula, tepung,
|
||||
telur, dan Sedap Goreng) menggunakan metode **Prophet**. Dibangun dengan
|
||||
**Streamlit** dan **Python**.
|
||||
|
||||
## Fitur
|
||||
|
||||
- **Login** dengan dua jenis akses: admin dan pemilik toko
|
||||
- **Beranda**: pilih periode peramalan, ringkasan permintaan, event/hari libur
|
||||
terdekat, dan kartu grafik mini per produk
|
||||
- **Detail Peramalan** per produk
|
||||
- Tab Peramalan: grafik, ringkasan insight, tabel harian (unduh CSV)
|
||||
- Tab Dekomposisi: tren, pola mingguan, pola tahunan, efek hari libur,
|
||||
dan rekomendasi aksi bisnis
|
||||
- Unduh laporan PDF
|
||||
- **Manajemen User** (khusus admin): tambah, ubah, dan hapus akun
|
||||
- **Edit Profil**: ubah username, nama tampilan, dan password
|
||||
|
||||
## Cara Menjalankan
|
||||
|
||||
Pastikan Python 3.11 sudah terpasang.
|
||||
|
||||
```bash
|
||||
# 1. (opsional) buat virtual environment
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # Linux / macOS
|
||||
|
||||
# 2. pasang dependensi
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. jalankan aplikasi
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
Aplikasi dapat dibuka di `http://localhost:8501`.
|
||||
|
||||
## Akun Bawaan
|
||||
|
||||
File `data/users.json` tidak disertakan di repository karena berisi data
|
||||
akun. File ini dibuat otomatis saat aplikasi pertama kali dijalankan, dengan
|
||||
akun bawaan:
|
||||
|
||||
| Username | Password | Role |
|
||||
|----------|-------------|---------|
|
||||
| admin | admin123 | admin |
|
||||
| pemilik | pemilik123 | pemilik |
|
||||
|
||||
> Segera ganti password bawaan lewat menu **Edit Profil** setelah login pertama.
|
||||
|
||||
## Struktur Proyek
|
||||
|
||||
```
|
||||
.
|
||||
├── app.py # entry point & navigasi
|
||||
├── requirements.txt
|
||||
├── .streamlit/
|
||||
│ └── config.toml # tema & konfigurasi server
|
||||
├── data/
|
||||
│ └── all_results.pkl # model Prophet & data historis 5 produk
|
||||
├── views/ # halaman aplikasi
|
||||
└── utils/ # autentikasi, peramalan, insight, laporan PDF
|
||||
```
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""
|
||||
app.py
|
||||
======
|
||||
Entry point aplikasi. Alurnya:
|
||||
1. Cek login (utils.auth.require_login) -- kalau belum login, form login
|
||||
ditampilkan dan eksekusi dihentikan di titik itu (st.stop()).
|
||||
2. Kalau sudah login: load all_results.pkl (di-cache lewat data_loader).
|
||||
3. Bangun daftar halaman sesuai role yang login, lalu jalankan navigasi.
|
||||
|
||||
Sengaja TIDAK memakai folder bawaan `pages/` milik Streamlit, karena itu
|
||||
akan otomatis menampilkan semua halaman ke semua orang tanpa peduli role.
|
||||
Sebagai gantinya dipakai st.navigation() yang daftar halamannya dibangun
|
||||
secara dinamis di bawah, tergantung role -- jadi halaman "Perbandingan
|
||||
Konfigurasi" memang tidak pernah dikirim ke pemilik toko, bukan cuma
|
||||
disembunyikan di tampilan.
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils import auth
|
||||
from utils import ui_components
|
||||
from utils.data_loader import load_all_results
|
||||
from views import beranda, detail_peramalan, edit_profil, manajemen_user, perbandingan_konfigurasi
|
||||
|
||||
|
||||
SHOW_PERBANDINGAN_KONFIGURASI = False # Set True kalau mau halaman "Perbandingan Konfigurasi" muncul di menu admin.
|
||||
st.set_page_config(
|
||||
page_title="Peramalan Permintaan Produk",
|
||||
page_icon="📈",
|
||||
layout="wide",
|
||||
)
|
||||
|
||||
# CSS global (berlaku di SEMUA halaman & popup, termasuk yang muncul di
|
||||
# dalam st.dialog) -- lihat docstring inject_global_css() untuk detail
|
||||
# kenapa ini harus dipanggil di sini, bukan diulang di tiap halaman.
|
||||
ui_components.inject_global_css()
|
||||
|
||||
# --- 1. Gerbang login -------------------------------------------------------
|
||||
if not auth.is_logged_in():
|
||||
login_page = st.Page(auth.render_login_form, title="Masuk", url_path="login")
|
||||
st.navigation([login_page]).run()
|
||||
st.stop()
|
||||
|
||||
# --- 2. Load data model (cached) --------------------------------------------
|
||||
try:
|
||||
all_results = load_all_results()
|
||||
except FileNotFoundError as e:
|
||||
st.error(str(e))
|
||||
st.caption("Pastikan file `data/all_results.pkl` sudah ada di folder proyek.")
|
||||
st.stop()
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
st.stop()
|
||||
|
||||
# Simpan di session_state supaya semua halaman bisa akses tanpa load ulang.
|
||||
# session_state adalah tempat Streamlit menyimpan data selama session pengguna.
|
||||
st.session_state["all_results"] = all_results
|
||||
|
||||
# --- 3. Bangun daftar halaman sesuai role ------------------------------------
|
||||
role = auth.get_current_role()
|
||||
|
||||
detail_page = st.Page(detail_peramalan.render, title="Detail Peramalan", icon="📈", url_path="detail-peramalan")
|
||||
|
||||
# Disimpan di session_state supaya views/beranda.py bisa panggil
|
||||
# st.switch_page(detail_page) langsung dari tombol "Lihat detail",
|
||||
# tanpa perlu import app.py (yang akan bikin circular import).
|
||||
st.session_state["_page_detail_peramalan"] = detail_page
|
||||
|
||||
pages = [
|
||||
st.Page(beranda.render, title="Beranda", icon="🏠", url_path="beranda", default=True),
|
||||
detail_page,
|
||||
]
|
||||
|
||||
if role == "admin":
|
||||
if SHOW_PERBANDINGAN_KONFIGURASI:
|
||||
pages.append(
|
||||
st.Page(
|
||||
perbandingan_konfigurasi.render,
|
||||
title="Perbandingan Konfigurasi",
|
||||
icon="⚖️",
|
||||
url_path="perbandingan-konfigurasi",
|
||||
)
|
||||
)
|
||||
pages.append(
|
||||
st.Page(
|
||||
manajemen_user.render_manajemen_user,
|
||||
title="Manajemen User",
|
||||
icon="👥",
|
||||
url_path="manajemen-user",
|
||||
)
|
||||
)
|
||||
|
||||
# "Edit Profil" tampil untuk SEMUA role (admin maupun pemilik) -- taruh
|
||||
# paling akhir supaya tidak mengganggu urutan menu utama di atas.
|
||||
pages.append(
|
||||
st.Page(edit_profil.render_edit_profil, title="Edit Profil", icon="⚙️", url_path="edit-profil")
|
||||
)
|
||||
|
||||
# --- Sidebar: info user + tombol keluar (di atas menu navigasi) -------------
|
||||
with st.sidebar:
|
||||
st.markdown(f"**{auth.get_display_name()}**")
|
||||
badge = "👤 Admin" if role == "admin" else "👤 Pemilik Toko"
|
||||
st.caption(badge)
|
||||
|
||||
if st.button("➜] Keluar", width="stretch"):
|
||||
auth.logout()
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Jalankan navigasi -------------------------------------------------------
|
||||
nav = st.navigation(pages)
|
||||
nav.run()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# assets/
|
||||
|
||||
Taruh `logo.png` di sini kalau mau ditampilkan (opsional, misalnya di
|
||||
sidebar atau halaman login `utils/auth.py`). Belum dipakai otomatis oleh
|
||||
kode manapun saat ini -- kalau mau diaktifkan, tambahkan misalnya di
|
||||
`utils/auth.py` dalam `require_login()`:
|
||||
|
||||
st.image("assets/logo.png", width=80)
|
||||
|
||||
atau di sidebar `app.py`:
|
||||
|
||||
st.sidebar.image("assets/logo.png", width=120)
|
||||
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
|||
streamlit
|
||||
pandas==1.5.3
|
||||
prophet==1.1.7
|
||||
cmdstanpy==1.2.5
|
||||
scikit-learn
|
||||
plotly
|
||||
numpy
|
||||
matplotlib
|
||||
reportlab
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
"""
|
||||
auth.py
|
||||
=======
|
||||
Verifikasi login, pengelolaan role login lewat st.session_state,
|
||||
ganti password (edit profil), dan manajemen user (khusus admin).
|
||||
|
||||
PENYIMPANAN USER:
|
||||
Data user disimpan di file data/users.json (BUKAN lagi di
|
||||
.streamlit/secrets.toml). Alasannya: secrets.toml cuma bisa DIBACA
|
||||
oleh aplikasi yang sedang berjalan, tidak bisa ditulis ulang secara
|
||||
resmi -- padahal fitur "tambah user" / "ganti password" butuh
|
||||
MENULIS data baru saat aplikasi jalan. File JSON biasa bisa
|
||||
dibaca & ditulis kapan saja.
|
||||
|
||||
File data/users.json WAJIB masuk .gitignore (sama seperti
|
||||
secrets.toml sebelumnya) karena isinya password ter-hash user asli.
|
||||
|
||||
MIGRASI OTOMATIS:
|
||||
Kalau data/users.json belum ada, tapi .streamlit/secrets.toml (dari
|
||||
setup sebelumnya) ada isinya, data akan otomatis dipindahkan sekali
|
||||
ke users.json saat pertama kali dijalankan -- supaya Anda tidak perlu
|
||||
input ulang password admin/pemilik dari awal.
|
||||
|
||||
Kalau dua-duanya belum ada sama sekali, dibuatkan default:
|
||||
admin / admin123 (role: admin)
|
||||
pemilik / pemilik123 (role: pemilik)
|
||||
-- SEGERA ganti password default ini lewat menu "Edit Profil"
|
||||
setelah login pertama kali.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
USERS_FILE = Path("data/users.json")
|
||||
|
||||
_DEFAULT_USERS: dict[str, dict[str, str]] = {
|
||||
"admin": {
|
||||
"password_hash": hashlib.sha256(b"admin123").hexdigest(),
|
||||
"role": "admin",
|
||||
"nama": "Administrator",
|
||||
},
|
||||
"pemilik": {
|
||||
"password_hash": hashlib.sha256(b"pemilik123").hexdigest(),
|
||||
"role": "pemilik",
|
||||
"nama": "Pemilik Toko",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _migrate_from_secrets_if_needed() -> dict[str, dict[str, str]] | None:
|
||||
"""Kalau users.json belum ada tapi secrets.toml (setup lama) ada,
|
||||
pindahkan datanya sekali. Return None kalau tidak ada yang bisa dimigrasi."""
|
||||
try:
|
||||
users_secrets = st.secrets["users"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
migrated = {
|
||||
username: {
|
||||
"password_hash": data["password_hash"],
|
||||
"role": data["role"],
|
||||
"nama": data["nama"],
|
||||
}
|
||||
for username, data in users_secrets.items()
|
||||
}
|
||||
return migrated or None
|
||||
|
||||
|
||||
def _load_users() -> dict[str, dict[str, str]]:
|
||||
if USERS_FILE.exists():
|
||||
with USERS_FILE.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
# users.json belum ada -> coba migrasi dari secrets.toml lama,
|
||||
# kalau tidak ada juga -> pakai default bawaan.
|
||||
users = _migrate_from_secrets_if_needed() or dict(_DEFAULT_USERS)
|
||||
_save_users(users)
|
||||
return users
|
||||
|
||||
|
||||
def _save_users(users: dict[str, dict[str, str]]) -> None:
|
||||
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with USERS_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(users, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status login (session_state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_logged_in() -> bool:
|
||||
return st.session_state.get("logged_in", False)
|
||||
|
||||
|
||||
def get_current_role() -> str | None:
|
||||
return st.session_state.get("role")
|
||||
|
||||
|
||||
def get_current_username() -> str | None:
|
||||
return st.session_state.get("username")
|
||||
|
||||
|
||||
def get_display_name() -> str:
|
||||
return st.session_state.get("nama_tampilan", get_current_username() or "Pengguna")
|
||||
|
||||
|
||||
def attempt_login(username: str, password: str) -> bool:
|
||||
users = _load_users()
|
||||
user = users.get(username)
|
||||
if user is not None and user["password_hash"] == _hash_password(password):
|
||||
st.session_state["logged_in"] = True
|
||||
st.session_state["username"] = username
|
||||
st.session_state["role"] = user["role"]
|
||||
st.session_state["nama_tampilan"] = user["nama"]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
for key in ("logged_in", "username", "role", "nama_tampilan"):
|
||||
st.session_state.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edit profil (ganti password sendiri) -- untuk admin MAUPUN pemilik
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POLA_USERNAME_VALID = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
def _validasi_password(password: str) -> str | None:
|
||||
"""
|
||||
Aturan password: WAJIB mengandung minimal 1 huruf dan 1 angka.
|
||||
(Tidak ada aturan simbol/huruf besar-kecil -- disengaja sesederhana
|
||||
ini karena pemakainya cuma internal toko sendiri, bukan publik.)
|
||||
|
||||
Return None kalau valid, atau pesan error kalau tidak valid.
|
||||
"""
|
||||
if not any(c.isalpha() for c in password):
|
||||
return "Password harus mengandung minimal 1 huruf."
|
||||
if not any(c.isdigit() for c in password):
|
||||
return "Password harus mengandung minimal 1 angka."
|
||||
return None
|
||||
|
||||
|
||||
def update_own_profile(username_lama: str, username_baru: str, nama_baru: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Ganti username & nama tampilan milik akun yang SEDANG LOGIN sendiri.
|
||||
|
||||
Validasi:
|
||||
- Username tidak boleh kosong, tidak boleh ada spasi/simbol aneh
|
||||
(cuma huruf, angka, titik, underscore, strip).
|
||||
- Kalau username baru BEDA dari yang lama, WAJIB belum dipakai user
|
||||
lain -- inilah "kontrol" yang diminta: tidak boleh bentrok dengan
|
||||
username yang sudah ada.
|
||||
- Boleh submit tanpa mengubah username sama sekali (username baru
|
||||
== username lama), itu dianggap valid, cuma nama-nya yang berubah.
|
||||
"""
|
||||
username_baru = username_baru.strip()
|
||||
nama_baru = nama_baru.strip()
|
||||
|
||||
if not username_baru:
|
||||
return False, "Username tidak boleh kosong."
|
||||
if not _POLA_USERNAME_VALID.match(username_baru):
|
||||
return False, "Username cuma boleh berisi huruf, angka, titik (.), underscore (_), atau strip (-) -- tanpa spasi."
|
||||
if not nama_baru:
|
||||
return False, "Nama tampilan tidak boleh kosong."
|
||||
|
||||
users = _load_users()
|
||||
if username_lama not in users:
|
||||
return False, "User tidak ditemukan."
|
||||
|
||||
if username_baru != username_lama and username_baru in users:
|
||||
return False, f"Username '{username_baru}' sudah dipakai user lain. Pilih username lain."
|
||||
|
||||
data = users.pop(username_lama)
|
||||
data["nama"] = nama_baru
|
||||
users[username_baru] = data
|
||||
_save_users(users)
|
||||
|
||||
# Kalau username berubah, session_state juga WAJIB ikut diperbarui --
|
||||
# kalau tidak, pada rerun berikutnya aplikasi masih mencari username
|
||||
# LAMA (yang sudah tidak ada lagi di users.json) dan user otomatis
|
||||
# ter-logout tanpa sebab yang jelas.
|
||||
if st.session_state.get("username") == username_lama:
|
||||
st.session_state["username"] = username_baru
|
||||
st.session_state["nama_tampilan"] = nama_baru
|
||||
|
||||
if username_baru != username_lama:
|
||||
return True, f"Profil berhasil diperbarui. Username Anda sekarang: '{username_baru}'."
|
||||
return True, "Profil berhasil diperbarui."
|
||||
|
||||
|
||||
def verify_password(username: str, password: str) -> bool:
|
||||
"""Cek apakah `password` cocok dengan password akun `username` saat ini.
|
||||
Dipakai untuk validasi password lama -- baik di dalam change_own_password
|
||||
di bawah, maupun langsung dari UI (edit_profil.py) supaya urutan pesan
|
||||
error bisa diatur sesuai urutan kolom di form (password lama dicek
|
||||
duluan, baru validasi password baru)."""
|
||||
users = _load_users()
|
||||
user = users.get(username)
|
||||
if user is None:
|
||||
return False
|
||||
return user["password_hash"] == _hash_password(password)
|
||||
|
||||
|
||||
def change_own_password(username: str, old_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Ganti password milik akun yang sedang login sendiri.
|
||||
WAJIB verifikasi password lama dulu -- supaya kalau sesi login
|
||||
ketinggalan terbuka, orang lain tidak bisa ambil alih akun cuma
|
||||
dengan mengisi form ganti password.
|
||||
|
||||
>>> FIX: urutan pengecekan diubah supaya PASSWORD LAMA dicek PALING
|
||||
AWAL (sebelum validasi format password baru) -- sebelumnya validasi
|
||||
format password baru dicek duluan, jadi kalau password baru tidak
|
||||
valid DAN password lama juga salah, yang ditampilkan cuma pesan soal
|
||||
password baru -- padahal harusnya password lama yang salah itu lebih
|
||||
mendasar dan perlu diberi tahu lebih dulu.
|
||||
|
||||
Return (berhasil: bool, pesan: str).
|
||||
"""
|
||||
users = _load_users()
|
||||
user = users.get(username)
|
||||
if user is None:
|
||||
return False, "User tidak ditemukan."
|
||||
|
||||
if user["password_hash"] != _hash_password(old_password):
|
||||
return False, "Password lama yang Anda masukkan salah."
|
||||
|
||||
error = _validasi_password(new_password)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
if _hash_password(new_password) == user["password_hash"]:
|
||||
return False, "Password baru tidak boleh sama dengan password lama."
|
||||
|
||||
user["password_hash"] = _hash_password(new_password)
|
||||
_save_users(users)
|
||||
return True, "Password berhasil diubah. Silakan gunakan password baru saat login berikutnya."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manajemen user -- KHUSUS ADMIN (tidak perlu password lama user lain,
|
||||
# karena otoritasnya sudah dibuktikan lewat admin sendiri sudah login)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_users() -> list[dict[str, str]]:
|
||||
"""Daftar semua user untuk ditampilkan di tabel admin (tanpa password_hash)."""
|
||||
users = _load_users()
|
||||
return [
|
||||
{"username": uname, "nama": data["nama"], "role": data["role"]}
|
||||
for uname, data in users.items()
|
||||
]
|
||||
|
||||
|
||||
def add_user(username: str, password: str, role: str, nama: str) -> tuple[bool, str]:
|
||||
if role not in ("admin", "pemilik"):
|
||||
return False, "Role harus 'admin' atau 'pemilik'."
|
||||
if not username or not username.strip():
|
||||
return False, "Username tidak boleh kosong."
|
||||
if not _POLA_USERNAME_VALID.match(username.strip()):
|
||||
return False, "Username cuma boleh berisi huruf, angka, titik (.), underscore (_), atau strip (-) -- tanpa spasi."
|
||||
error = _validasi_password(password)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
users = _load_users()
|
||||
username = username.strip()
|
||||
if username in users:
|
||||
return False, f"Username '{username}' sudah dipakai."
|
||||
|
||||
users[username] = {
|
||||
"password_hash": _hash_password(password),
|
||||
"role": role,
|
||||
"nama": nama.strip() or username,
|
||||
}
|
||||
_save_users(users)
|
||||
return True, f"User '{username}' berhasil ditambahkan."
|
||||
|
||||
|
||||
def admin_edit_user(
|
||||
username: str, nama: str, role: str, new_password: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Satu fungsi gabungan untuk tombol "Edit" di Manajemen User:
|
||||
- selalu update nama & role
|
||||
- kalau new_password diisi (tidak None/kosong), password ikut direset
|
||||
(TIDAK perlu password lama, karena otoritas admin sudah cukup)
|
||||
"""
|
||||
if role not in ("admin", "pemilik"):
|
||||
return False, "Role harus 'admin' atau 'pemilik'."
|
||||
if new_password:
|
||||
error = _validasi_password(new_password)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
users = _load_users()
|
||||
if username not in users:
|
||||
return False, "User tidak ditemukan."
|
||||
|
||||
users[username]["nama"] = nama.strip() or username
|
||||
users[username]["role"] = role
|
||||
if new_password:
|
||||
users[username]["password_hash"] = _hash_password(new_password)
|
||||
|
||||
_save_users(users)
|
||||
return True, f"Data user '{username}' berhasil diperbarui."
|
||||
|
||||
|
||||
def admin_reset_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Admin reset-kan password user lain, TANPA perlu tahu password lamanya."""
|
||||
error = _validasi_password(new_password)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
users = _load_users()
|
||||
if username not in users:
|
||||
return False, "User tidak ditemukan."
|
||||
|
||||
users[username]["password_hash"] = _hash_password(new_password)
|
||||
_save_users(users)
|
||||
return True, f"Password untuk '{username}' berhasil direset."
|
||||
|
||||
|
||||
def update_user_profile(username: str, role: str | None = None, nama: str | None = None) -> tuple[bool, str]:
|
||||
users = _load_users()
|
||||
if username not in users:
|
||||
return False, "User tidak ditemukan."
|
||||
if role is not None:
|
||||
if role not in ("admin", "pemilik"):
|
||||
return False, "Role harus 'admin' atau 'pemilik'."
|
||||
users[username]["role"] = role
|
||||
if nama is not None and nama.strip():
|
||||
users[username]["nama"] = nama.strip()
|
||||
_save_users(users)
|
||||
return True, f"Data user '{username}' berhasil diperbarui."
|
||||
|
||||
|
||||
def delete_user(username: str, requested_by: str) -> tuple[bool, str]:
|
||||
users = _load_users()
|
||||
if username not in users:
|
||||
return False, "User tidak ditemukan."
|
||||
if username == requested_by:
|
||||
return False, "Anda tidak bisa menghapus akun Anda sendiri saat sedang login."
|
||||
|
||||
jumlah_admin = sum(1 for u in users.values() if u["role"] == "admin")
|
||||
if users[username]["role"] == "admin" and jumlah_admin <= 1:
|
||||
return False, "Tidak bisa menghapus satu-satunya akun admin yang tersisa."
|
||||
|
||||
del users[username]
|
||||
_save_users(users)
|
||||
return True, f"User '{username}' berhasil dihapus."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Form login (tidak berubah dari sebelumnya)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_login_form() -> None:
|
||||
if is_logged_in():
|
||||
return
|
||||
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
div[data-testid="stForm"] {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
padding: 2.2rem 2rem 1.6rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
.block-container {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.st-key-login_card_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.st-key-login_card_wrapper > div {
|
||||
width: 480px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
_, col, _ = st.columns([0.7, 1.3, 0.7])
|
||||
with col:
|
||||
st.markdown("<div style='margin-top:8vh;'></div>", unsafe_allow_html=True)
|
||||
|
||||
with st.form("login_form", border=True):
|
||||
st.markdown(
|
||||
"""
|
||||
<div style='text-align:center; margin-bottom:1.6rem;'>
|
||||
<div style='font-size:50px; margin-bottom:5px;'>📈</div>
|
||||
<h2 style='margin:0; padding:0; font-size:24px; font-weight:700;
|
||||
line-height:1.3; color:#1a1a2e; white-space:nowrap;'>
|
||||
Peramalan Permintaan Produk
|
||||
</h2>
|
||||
<h4 style='margin:6px 0 0; padding:0; font-size:18px; font-style:italic;
|
||||
font-weight:500; line-height:1.3; color:#444;'>
|
||||
Fast-Moving Consumer Goods
|
||||
</h4>
|
||||
<p style='margin:6px 0 0; padding:0; font-size:15px; color:#64748B;'>
|
||||
UD Aan Jaya Toko
|
||||
</p>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
username = st.text_input("Username", placeholder="Masukan username")
|
||||
password = st.text_input("Password", type="password", placeholder="Masukan password")
|
||||
submitted = st.form_submit_button("Masuk", use_container_width=True, type="primary")
|
||||
|
||||
if submitted:
|
||||
if attempt_login(username, password):
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Username atau password salah.")
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
"""
|
||||
data_loader.py (sebagai "Gudang Data" atau "Penyedia Bahan Baku")
|
||||
==============
|
||||
Modul untuk membaca dan menyimpan cache file master `all_results.pkl`,
|
||||
serta menyediakan fungsi-fungsi bantu untuk mengambil informasi ringkas
|
||||
dari struktur data (daftar produk, skenario optimal, tabel metrik, dsb).
|
||||
|
||||
Struktur `all_results.pkl` (per produk):
|
||||
{
|
||||
"<nama_produk>": {
|
||||
"data": {
|
||||
"train": <DataFrame kolom: ds, y, status_operasional, ketersediaan_stok>,
|
||||
"test": <DataFrame kolom sama>
|
||||
},
|
||||
"scenarios": {
|
||||
"standar": {config, best_params, best_metrics, best_model, best_forecast, all_results},
|
||||
"log_transform":{...},
|
||||
"regresor": {...},
|
||||
"log_regresor": {...},
|
||||
},
|
||||
"optimal_scenario": "regresor", # salah satu dari 4 nama skenario di atas
|
||||
"scaler": { # HANYA ADA jika produk punya skenario berbasis regresor
|
||||
"scaler_status": MinMaxScaler(...),
|
||||
"scaler_stok": MinMaxScaler(...),
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
# Skenario yang WAJIB ada di setiap produk
|
||||
EXPECTED_SCENARIOS = {"standar", "log_transform", "regresor", "log_regresor"}
|
||||
|
||||
# Skenario yang membutuhkan regresor eksternal (dan karenanya, scaler)
|
||||
REGRESSOR_SCENARIOS = {"regresor", "log_regresor"}
|
||||
|
||||
DEFAULT_DATA_PATH = os.path.join("data", "all_results.pkl")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pembacaan file master (dengan cache, supaya tidak baca ulang tiap interaksi)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.cache_resource(show_spinner="Memuat data model...")
|
||||
def load_all_results(path: str = DEFAULT_DATA_PATH) -> dict[str, Any]:
|
||||
"""
|
||||
Baca file all_results.pkl dan simpan di cache resource Streamlit.
|
||||
|
||||
Menggunakan st.cache_resource (bukan st.cache_data) karena isinya
|
||||
mengandung objek Prophet (model) yang tidak bisa/tidak perlu di-hash
|
||||
seperti data biasa.
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
Jika file tidak ditemukan di path yang diberikan.
|
||||
ValueError
|
||||
Jika file berhasil dibaca tapi bukan dictionary yang valid.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(
|
||||
f"File model tidak ditemukan di '{path}'. "
|
||||
"Pastikan all_results.pkl sudah ditempatkan di folder data/."
|
||||
)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
if not isinstance(data, dict) or len(data) == 0:
|
||||
raise ValueError("File all_results.pkl tidak berisi dictionary produk yang valid.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def reload_all_results(path: str = DEFAULT_DATA_PATH) -> dict[str, Any]:
|
||||
"""
|
||||
Paksa baca ulang file dari disk (melewati cache).
|
||||
Dipanggil setelah admin berhasil upload & merge model baru,
|
||||
supaya perubahan langsung terlihat tanpa perlu restart aplikasi.
|
||||
"""
|
||||
load_all_results.clear() # kosongkan cache st.cache_resource
|
||||
return load_all_results(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fungsi bantu: info dasar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_product_list(all_results: dict[str, Any]) -> list[str]:
|
||||
"""Daftar nama produk, urutan sesuai urutan di file pkl."""
|
||||
return list(all_results.keys())
|
||||
|
||||
|
||||
def get_optimal_scenario(all_results: dict[str, Any], product: str) -> str:
|
||||
"""Nama skenario optimal untuk satu produk (mis. 'log_regresor')."""
|
||||
return all_results[product]["optimal_scenario"]
|
||||
|
||||
|
||||
def get_scenario_names(all_results: dict[str, Any], product: str) -> list[str]:
|
||||
"""Daftar nama skenario yang tersedia untuk satu produk."""
|
||||
return list(all_results[product]["scenarios"].keys())
|
||||
|
||||
|
||||
def uses_regressors(all_results: dict[str, Any], product: str, scenario: str) -> bool:
|
||||
"""True kalau skenario ini butuh regresor eksternal (status_operasional & ketersediaan_stok)."""
|
||||
return all_results[product]["scenarios"][scenario]["config"]["use_regressors"]
|
||||
|
||||
|
||||
def uses_log_transform(all_results: dict[str, Any], product: str, scenario: str) -> bool:
|
||||
"""True kalau target y dilatih dalam skala log (butuh inverse-transform expm1 saat prediksi)."""
|
||||
return all_results[product]["scenarios"][scenario]["config"]["use_log"]
|
||||
|
||||
|
||||
def has_scaler(all_results: dict[str, Any], product: str) -> bool:
|
||||
"""Cek apakah produk punya scaler tersimpan (dibutuhkan kalau skenario optimalnya pakai regresor)."""
|
||||
return "scaler" in all_results[product]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batas tanggal peramalan OTOMATIS per produk (bukan hardcode global)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_forecast_date_bounds(all_results: dict[str, Any], product: str) -> tuple[datetime, datetime]:
|
||||
"""
|
||||
Hitung batas tanggal peramalan yang VALID untuk 1 produk, otomatis dari
|
||||
isi .pkl-nya sendiri -- bukan angka hardcode yang sama untuk semua produk.
|
||||
|
||||
Batas Awal (min_date): 1 hari setelah tanggal terakhir di data.test
|
||||
produk ini. Karena data test adalah data riil terakhir yang dipakai
|
||||
untuk evaluasi model -- peramalan baru masuk akal dimulai SETELAH itu.
|
||||
|
||||
Batas Akhir (max_date): 31 Desember dari tahun TERAKHIR yang tercatat
|
||||
di dataset hari libur (`model.holidays`) yang dipakai skenario
|
||||
optimal produk ini. Karena Prophet butuh definisi holiday untuk
|
||||
tanggal yang diramal -- di luar tahun yang terdaftar, efek
|
||||
holiday-nya jadi tidak valid/tidak representatif.
|
||||
|
||||
Kalau produk tidak punya data holiday sama sekali (kasus langka),
|
||||
fallback: max_date = min_date + 1 tahun, supaya tetap ada rentang
|
||||
yang bisa dipilih (bukan error/rentang kosong).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(min_date, max_date) : tuple[datetime.date, datetime.date]
|
||||
"""
|
||||
entry = all_results[product]
|
||||
|
||||
# --- Batas awal: dari data test produk ini ---
|
||||
test_df = entry["data"]["test"]
|
||||
last_test_date = pd.to_datetime(test_df["ds"]).max()
|
||||
min_date = (last_test_date + pd.Timedelta(days=1)).date()
|
||||
|
||||
# --- Batas akhir: dari holidays yang dipakai skenario optimal produk ini ---
|
||||
optimal_scenario = entry["optimal_scenario"]
|
||||
model = entry["scenarios"][optimal_scenario]["best_model"]
|
||||
holidays_df = getattr(model, "holidays", None)
|
||||
|
||||
if holidays_df is not None and not holidays_df.empty:
|
||||
last_holiday_year = int(pd.to_datetime(holidays_df["ds"]).dt.year.max())
|
||||
max_date = datetime(last_holiday_year, 12, 31).date()
|
||||
else:
|
||||
# Fallback kalau model ini kebetulan tidak punya data holiday sama sekali.
|
||||
max_date = (last_test_date + pd.DateOffset(years=1)).date()
|
||||
|
||||
# Jaga-jaga: kalau karena suatu hal max_date < min_date (data holiday
|
||||
# sudah kadaluarsa dibanding data test), paksa max_date ikut min_date
|
||||
# supaya rentang kalender tidak pernah kosong/terbalik.
|
||||
if max_date < min_date:
|
||||
max_date = min_date
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
|
||||
|
||||
def get_aggregate_forecast_date_bounds(
|
||||
all_results: dict[str, Any], products: list[str] | None = None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""
|
||||
>>> FIX: Versi AGREGAT dari get_forecast_date_bounds() -- dipakai di
|
||||
halaman yang menampilkan BANYAK produk sekaligus dengan 1 periode yang
|
||||
sama (mis. Beranda), bukan 1 produk seperti Detail Peramalan.
|
||||
|
||||
Karena semua produk berbagi 1 periode yang sama, batasnya harus berupa
|
||||
IRISAN (intersection) supaya periode yang dipilih valid untuk SEMUA
|
||||
produk sekaligus -- bukan gabungan (union) yang malah bisa membuat
|
||||
sebagian produk gagal diramal.
|
||||
|
||||
(Prinsip IRISAN ini dipakai supaya user tidak bisa memilih rentang tanggal
|
||||
di kalender yang bakal bikin salah satu atau sebagian produk error/gagal
|
||||
diramal di halaman Beranda.)
|
||||
|
||||
- min_date = tanggal paling AKHIR di antara semua produk (produk dengan data test paling baru yang menentukan)
|
||||
- max_date = tanggal paling AWAL di antara semua produk (produk dengan cakupan data hari libur paling pendek yang menentukan)
|
||||
|
||||
Kalau `products` tidak diisi, otomatis pakai SEMUA produk yang ada
|
||||
di `all_results`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(min_date, max_date) : tuple[datetime.date, datetime.date]
|
||||
Kalau `products` kosong (tidak ada produk sama sekali), fallback
|
||||
ke hari ini sebagai min_date dan +1 tahun sebagai max_date --
|
||||
supaya tetap ada rentang yang bisa dipilih, bukan error.
|
||||
"""
|
||||
if products is None:
|
||||
products = list(all_results.keys())
|
||||
|
||||
if not products:
|
||||
today = datetime.now().date()
|
||||
return today, (pd.Timestamp(today) + pd.DateOffset(years=1)).date()
|
||||
|
||||
bounds = [get_forecast_date_bounds(all_results, p) for p in products]
|
||||
min_date = max(b[0] for b in bounds) # paling akhir di antara semua produk
|
||||
max_date = min(b[1] for b in bounds) # paling awal di antara semua produk
|
||||
|
||||
# Jaga-jaga: kalau irisan tidak ketemu (satu produk sangat baru datanya,
|
||||
# produk lain cakupan holiday-nya pendek) sehingga min > max, jangan
|
||||
# sampai rentang kalender jadi kosong/terbalik.
|
||||
if max_date < min_date:
|
||||
max_date = min_date
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fungsi bantu: tabel metrik & parameter (untuk halaman Perbandingan Konfigurasi)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_metric_comparison_table(all_results: dict[str, Any], metric: str = "MAPE") -> pd.DataFrame:
|
||||
"""
|
||||
Bangun tabel perbandingan 1 metrik (default MAPE) untuk semua produk x semua skenario.
|
||||
|
||||
Hasil: DataFrame dengan baris = produk, kolom = 4 skenario, nilai = metrik (%),
|
||||
plus kolom 'optimal_scenario' untuk penanda skenario mana yang terbaik per produk.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
metric : str
|
||||
Salah satu dari 'MAE', 'RMSE', 'MAPE'.
|
||||
"""
|
||||
rows = []
|
||||
for product, entry in all_results.items():
|
||||
row = {"produk": product}
|
||||
for scenario_name, scenario in entry["scenarios"].items():
|
||||
row[scenario_name] = scenario["best_metrics"].get(metric)
|
||||
row["optimal_scenario"] = entry["optimal_scenario"]
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows).set_index("produk")
|
||||
return df
|
||||
|
||||
|
||||
def get_product_metrics_detail(all_results: dict[str, Any], product: str) -> pd.DataFrame:
|
||||
"""
|
||||
Tabel MAE/RMSE/MAPE untuk 4 skenario, KHUSUS 1 produk.
|
||||
Dipakai di tab "Kinerja" pada halaman Detail Peramalan.
|
||||
"""
|
||||
entry = all_results[product]
|
||||
rows = []
|
||||
for scenario_name, scenario in entry["scenarios"].items():
|
||||
m = scenario["best_metrics"]
|
||||
rows.append({
|
||||
"skenario": scenario_name,
|
||||
"MAE": m.get("MAE"),
|
||||
"RMSE": m.get("RMSE"),
|
||||
"MAPE": m.get("MAPE"),
|
||||
"optimal": scenario_name == entry["optimal_scenario"],
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def get_hyperparameter_table(all_results: dict[str, Any]) -> pd.DataFrame:
|
||||
"""
|
||||
Tabel parameter terbaik (hasil grid search) dari SKENARIO OPTIMAL tiap produk.
|
||||
Dipakai di halaman Perbandingan Konfigurasi (khusus admin).
|
||||
"""
|
||||
rows = []
|
||||
for product, entry in all_results.items():
|
||||
optimal = entry["optimal_scenario"]
|
||||
params = entry["scenarios"][optimal]["best_params"]
|
||||
row = {"produk": product, "skenario_optimal": optimal, **params}
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fungsi bantu: metadata pembaruan (untuk panel "Perbarui Model")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_last_updated_info(all_results: dict[str, Any]) -> pd.DataFrame:
|
||||
"""
|
||||
Tabel ringkas: produk, skenario optimal, status scaler.
|
||||
NOTE: timestamp "terakhir diperbarui" per produk belum tersedia dari
|
||||
struktur pkl saat ini (tidak ada field waktu di dalam entry produk).
|
||||
Kalau mau ditampilkan seperti di mockup, perlu ditambah field baru,
|
||||
misalnya entry[produk]['updated_at'], diisi otomatis oleh model_manager.py
|
||||
setiap kali produk itu di-update lewat panel upload.
|
||||
"""
|
||||
rows = []
|
||||
for product, entry in all_results.items():
|
||||
rows.append({
|
||||
"produk": product,
|
||||
"skenario_optimal": entry["optimal_scenario"],
|
||||
"scaler_tersedia": has_scaler(all_results, product),
|
||||
"diperbarui_terakhir": entry.get("updated_at", "-"),
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def detect_peak_days(forecast: pd.DataFrame, threshold_std: float = 1.0) -> pd.DataFrame:
|
||||
"""
|
||||
Filter tanggal-tanggal di mana yhat > mean(yhat) + threshold_std * std(yhat).
|
||||
Hasil diurutkan dari yhat tertinggi ke terendah.
|
||||
|
||||
Kalau rentang periode cuma 1 hari, std tidak bisa dihitung (NaN) --
|
||||
dalam kasus itu, tidak ada yang ditandai sebagai lonjakan (butuh
|
||||
pembanding dari beberapa hari).
|
||||
"""
|
||||
yhat = forecast["yhat"].clip(lower=0)
|
||||
if len(forecast) < 2:
|
||||
return forecast.iloc[0:0][["ds", "yhat"]].copy()
|
||||
|
||||
threshold = yhat.mean() + threshold_std * yhat.std()
|
||||
peaks = forecast.loc[yhat > threshold, ["ds", "yhat"]].copy()
|
||||
return peaks.sort_values("yhat", ascending=False).reset_index(drop=True)
|
||||
|
||||
|
||||
def nearest_holiday(model: Any, on_or_after) -> dict | None:
|
||||
"""
|
||||
Cari hari libur (dari atribut model.holidays milik Prophet) yang jatuh
|
||||
paling dekat pada/setelah tanggal `on_or_after`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict {"nama": str, "tanggal": pd.Timestamp} atau None kalau model ini
|
||||
tidak dikonfigurasi dengan holidays, atau tidak ada hari libur yang
|
||||
jatuh pada/setelah tanggal tersebut di dalam kalender yang didaftarkan.
|
||||
"""
|
||||
holidays = getattr(model, "holidays", None)
|
||||
if holidays is None or holidays.empty:
|
||||
return None
|
||||
|
||||
cutoff = pd.Timestamp(on_or_after)
|
||||
upcoming = holidays.loc[holidays["ds"] >= cutoff].sort_values("ds")
|
||||
if upcoming.empty:
|
||||
return None
|
||||
|
||||
row = upcoming.iloc[0]
|
||||
return {"nama": row["holiday"], "tanggal": row["ds"]}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
forecast_helpers.py
|
||||
====================
|
||||
Modul untuk menghasilkan peramalan pada periode BEBAS (sesuai pilihan
|
||||
user di date picker), karena `best_forecast` yang tersimpan di dalam
|
||||
all_results.pkl hanya mencakup periode test asli (Jul-Des 2025).
|
||||
|
||||
Catatan penting soal transformasi:
|
||||
- Kalau config['use_log'] == True, model dilatih pada log1p(y). Prophet
|
||||
akan mengembalikan yhat dalam skala log lewat model.predict(), jadi
|
||||
HARUS di-inverse-transform (np.expm1) supaya kembali ke satuan asli
|
||||
(unit produk, misal Liter/Kg). Ini sudah dikonfirmasi dari data test:
|
||||
yhat di best_forecast untuk skenario log_transform/log_regresor sudah
|
||||
dalam skala asli (bukan skala log), jadi app harus meniru proses yang sama.
|
||||
- Komponen dekomposisi (trend, weekly, yearly, holidays) TETAP dalam skala
|
||||
model aslinya (skala log untuk skenario log_transform/log_regresor).
|
||||
Ini karena komponen tidak bisa di-expm1 satu-satu lalu dijumlah (hasilnya
|
||||
tidak akan sama dengan expm1 dari total). Ini perilaku standar Prophet,
|
||||
bukan bug -- tapi HARUS diberi label jelas di UI ("skala log") supaya
|
||||
tidak disalahartikan sebagai unit produk asli.
|
||||
- Kalau config['use_regressors'] == True, model butuh kolom
|
||||
'status_operasional' & 'ketersediaan_stok' (sudah di-scale MinMax(0,1))
|
||||
untuk setiap tanggal yang mau diramal. Karena kita tidak tahu nilai
|
||||
regresor di masa depan, dipakai ASUMSI KONSTAN "kondisi normal"
|
||||
(nilai mentah 2 untuk kedua kolom, sesuai kesepakatan), lalu di-scale
|
||||
pakai scaler yang tersimpan di entry['scaler'].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# Nilai mentah default untuk regresor pada periode masa depan
|
||||
# (2 = kondisi paling umum/dominan di data historis, lihat analisis awal)
|
||||
DEFAULT_STATUS_OPERASIONAL = 2
|
||||
DEFAULT_KETERSEDIAAN_STOK = 2
|
||||
|
||||
# Kolom komponen dekomposisi yang ingin ditampilkan di UI
|
||||
DECOMPOSITION_COLUMNS = ["trend", "weekly", "yearly", "holidays"]
|
||||
|
||||
|
||||
class ForecastError(Exception):
|
||||
"""Dilempar kalau forecast tidak bisa dibuat (mis. scaler hilang)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Membangun dataframe masa depan (input untuk model.predict())
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_future_dataframe(
|
||||
product_entry: dict[str, Any],
|
||||
scenario_name: str,
|
||||
start_date,
|
||||
end_date,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Bangun DataFrame `future` yang siap dipakai model.predict().
|
||||
|
||||
Kalau skenario butuh regresor, kolom status_operasional & ketersediaan_stok
|
||||
diisi dengan asumsi "kondisi normal" (nilai mentah 2), lalu di-scale
|
||||
pakai scaler produk tersebut.
|
||||
"""
|
||||
dates = pd.date_range(start=start_date, end=end_date, freq="D")
|
||||
future = pd.DataFrame({"ds": dates})
|
||||
|
||||
scenario = product_entry["scenarios"][scenario_name]
|
||||
needs_regressors = scenario["config"]["use_regressors"]
|
||||
|
||||
if needs_regressors:
|
||||
scaler = product_entry.get("scaler")
|
||||
if scaler is None:
|
||||
raise ForecastError(
|
||||
"Skenario ini butuh regresor eksternal, tapi produk tidak "
|
||||
"punya scaler tersimpan. Tidak bisa membuat peramalan masa depan."
|
||||
)
|
||||
|
||||
n = len(dates)
|
||||
# dibungkus sebagai DataFrame bernama kolom (bukan array polos) karena
|
||||
# scaler ini dilatih dari DataFrame -- kalau dikasih array biasa,
|
||||
# scikit-learn akan memberi warning "X does not have valid feature names"
|
||||
raw_status = pd.DataFrame({"status_operasional": [DEFAULT_STATUS_OPERASIONAL] * n})
|
||||
raw_stok = pd.DataFrame({"ketersediaan_stok": [DEFAULT_KETERSEDIAAN_STOK] * n})
|
||||
|
||||
future["status_operasional"] = scaler["scaler_status"].transform(raw_status).flatten()
|
||||
future["ketersediaan_stok"] = scaler["scaler_stok"].transform(raw_stok).flatten()
|
||||
|
||||
return future
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Menghasilkan forecast (dengan inverse-transform kalau perlu)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_forecast(
|
||||
product_entry: dict[str, Any],
|
||||
scenario_name: str,
|
||||
start_date,
|
||||
end_date,
|
||||
) -> tuple[pd.DataFrame, bool]:
|
||||
"""
|
||||
Hasilkan forecast untuk 1 produk, 1 skenario, pada rentang tanggal bebas.
|
||||
|
||||
Returns
|
||||
-------
|
||||
forecast : pd.DataFrame
|
||||
Semua kolom asli hasil model.predict(), DITAMBAH:
|
||||
- kolom yhat, yhat_lower, yhat_upper SUDAH dalam skala asli
|
||||
(sudah di-expm1 kalau skenarionya pakai log).
|
||||
- kolom komponen (trend, weekly, yearly, holidays, dst) MASIH
|
||||
dalam skala model asli (lihat `is_log_scale_components`).
|
||||
is_log_scale_components : bool
|
||||
True kalau kolom komponen (selain yhat) masih dalam skala log,
|
||||
sehingga UI wajib memberi label "skala log" saat menampilkannya.
|
||||
"""
|
||||
scenario = product_entry["scenarios"][scenario_name]
|
||||
model = scenario["best_model"]
|
||||
use_log = scenario["config"]["use_log"]
|
||||
|
||||
future = build_future_dataframe(product_entry, scenario_name, start_date, end_date)
|
||||
forecast = model.predict(future)
|
||||
|
||||
if use_log:
|
||||
for col in ("yhat", "yhat_lower", "yhat_upper"):
|
||||
if col in forecast.columns:
|
||||
forecast[col] = np.expm1(forecast[col])
|
||||
|
||||
return forecast, use_log
|
||||
|
||||
|
||||
def generate_optimal_forecast(
|
||||
product_entry: dict[str, Any],
|
||||
start_date,
|
||||
end_date,
|
||||
) -> tuple[pd.DataFrame, str, bool]:
|
||||
"""
|
||||
Shortcut: hasilkan forecast pakai skenario OPTIMAL produk ini
|
||||
(dipakai di Beranda & Detail Peramalan, di mana user tidak memilih
|
||||
skenario secara manual -- sistem yang menentukan otomatis).
|
||||
|
||||
Returns
|
||||
-------
|
||||
forecast, optimal_scenario_name, is_log_scale_components
|
||||
"""
|
||||
optimal = product_entry["optimal_scenario"]
|
||||
forecast, is_log = generate_forecast(product_entry, optimal, start_date, end_date)
|
||||
return forecast, optimal, is_log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ekstraksi komponen dekomposisi (untuk Tab "Dekomposisi")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_decomposition(forecast: pd.DataFrame) -> dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Ambil komponen-komponen dekomposisi dari hasil forecast, dikelompokkan
|
||||
supaya gampang dipakai untuk 4 mini-chart (Trend, Weekly, Yearly, Holidays).
|
||||
|
||||
Holiday effect dipecah lagi per nama hari libur (ramadhan, idul_fitri,
|
||||
idul_adha, maulid) kalau kolom tersebut ada, karena itu lebih informatif
|
||||
daripada cuma total 'holidays' gabungan.
|
||||
"""
|
||||
result: dict[str, pd.DataFrame] = {}
|
||||
|
||||
result["trend"] = forecast[["ds", "trend"]].copy()
|
||||
|
||||
if "weekly" in forecast.columns:
|
||||
result["weekly"] = forecast[["ds", "weekly"]].copy()
|
||||
|
||||
if "yearly" in forecast.columns:
|
||||
result["yearly"] = forecast[["ds", "yearly"]].copy()
|
||||
|
||||
holiday_cols = [c for c in ["ramadhan", "idul_fitri", "idul_adha", "maulid"] if c in forecast.columns]
|
||||
if holiday_cols:
|
||||
result["holidays"] = forecast[["ds", *holiday_cols]].copy()
|
||||
elif "holidays" in forecast.columns:
|
||||
result["holidays"] = forecast[["ds", "holidays"]].copy()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ringkasan angka (dipakai di header Detail Peramalan & agregasi Beranda)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def summarize_forecast(forecast: pd.DataFrame) -> dict[str, float]:
|
||||
"""
|
||||
Ringkasan sederhana dari 1 forecast: total, rata-rata harian, min, max.
|
||||
Dipakai untuk kalimat semacam:
|
||||
"Estimasi total penjualan 30 hari ke depan: 125 Liter (rata-rata 4.1 Liter/hari)"
|
||||
"""
|
||||
yhat = forecast["yhat"].clip(lower=0) # jaga-jaga, permintaan tidak boleh negatif
|
||||
return {
|
||||
"total": float(yhat.sum()),
|
||||
"rata_rata_harian": float(yhat.mean()),
|
||||
"minimum": float(yhat.min()),
|
||||
"maksimum": float(yhat.max()),
|
||||
"jumlah_hari": int(len(forecast)),
|
||||
}
|
||||
|
||||
|
||||
# build_future_dataframe → siapkan input tanggal (+ regresor asumsi) untuk Prophet
|
||||
# generate_forecast → jalankan model.predict(), urus inverse-transform log kalau perlu
|
||||
# generate_optimal_forecast → shortcut pakai skenario terbaik otomatis
|
||||
# get_decomposition → pecah hasil jadi komponen-komponen buat chart
|
||||
# summarize_forecast → ringkas jadi angka-angka kunci (total, rata-rata, min, max)
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"""
|
||||
model_manager.py
|
||||
=================
|
||||
Validasi file all_results.pkl baru yang diupload admin (panel "Perbarui
|
||||
Model" di views/perbandingan_konfigurasi.py), backup otomatis file lama,
|
||||
dan proses merge ke file master di data/all_results.pkl.
|
||||
|
||||
Alur pemakaian dari sisi views/perbandingan_konfigurasi.py:
|
||||
|
||||
1. admin upload file lewat st.file_uploader -> dapat raw bytes
|
||||
2. new_data, errors = validate_new_data(raw_bytes)
|
||||
-> kalau `errors` TIDAK kosong, JANGAN lanjut ke merge sama sekali.
|
||||
3. diff = preview_merge(current_all_results, new_data)
|
||||
-> tampilkan ke admin: produk mana yang baru / ditimpa / tidak berubah,
|
||||
sebelum admin menekan tombol konfirmasi.
|
||||
4. merged, backup_path = apply_merge(current_all_results, new_data)
|
||||
-> ini yang benar-benar menulis ke disk (dan sebelumnya membackup
|
||||
file lama ke folder backups/).
|
||||
|
||||
Validasi di sini SENGAJA memakai aturan struktur yang sama seperti asumsi
|
||||
di utils/data_loader.py (lihat EXPECTED_SCENARIOS, REGRESSOR_SCENARIOS) --
|
||||
supaya file yang lolos validasi di sini dijamin bisa dibaca data_loader
|
||||
tanpa error di kemudian hari.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils.data_loader import (
|
||||
DEFAULT_DATA_PATH,
|
||||
EXPECTED_SCENARIOS,
|
||||
REGRESSOR_SCENARIOS,
|
||||
reload_all_results,
|
||||
)
|
||||
|
||||
BACKUP_DIR = "backups"
|
||||
|
||||
# Key yang wajib ada di level produk, di level tiap skenario, dan di config skenario.
|
||||
REQUIRED_PRODUCT_KEYS = {"data", "scenarios", "optimal_scenario"}
|
||||
REQUIRED_SCENARIO_KEYS = {"config", "best_params", "best_metrics", "best_model", "best_forecast"}
|
||||
REQUIRED_CONFIG_KEYS = {"use_regressors", "use_log"}
|
||||
REQUIRED_METRIC_KEYS = {"MAE", "RMSE", "MAPE"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validasi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_new_data(raw_bytes: bytes) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
"""
|
||||
Unpickle bytes hasil upload lalu validasi strukturnya secara menyeluruh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(data, errors)
|
||||
`data` bernilai None kalau file gagal di-unpickle sama sekali
|
||||
(format rusak / bukan pickle / bukan dict).
|
||||
`errors` berisi daftar pesan masalah dalam Bahasa Indonesia,
|
||||
siap ditampilkan langsung ke admin lewat st.error/st.write.
|
||||
Kalau `errors` kosong, file AMAN untuk di-merge.
|
||||
|
||||
Catatan keamanan: pickle.loads() bisa menjalankan kode arbitrer kalau
|
||||
file berasal dari sumber tidak terpercaya. Di aplikasi ini upload
|
||||
dibatasi HANYA untuk role admin (lihat auth.py + app.py), jadi risiko
|
||||
ini diterima sebagai bagian dari desain (admin dianggap tepercaya).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
data = pickle.loads(raw_bytes)
|
||||
except Exception as e: # noqa: BLE001 -- sengaja luas, sumber file tidak terpercaya
|
||||
return None, [f"File tidak bisa dibaca sebagai pickle: {e}"]
|
||||
|
||||
if not isinstance(data, dict) or len(data) == 0:
|
||||
return None, ["File berhasil dibaca, tapi isi dictionary produk tidak valid (atau kosong)."]
|
||||
|
||||
for product, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
errors.append(f"[{product}] entry harus berupa dictionary, ditemukan {type(entry).__name__}.")
|
||||
continue
|
||||
|
||||
missing = REQUIRED_PRODUCT_KEYS - entry.keys()
|
||||
if missing:
|
||||
errors.append(f"[{product}] key wajib hilang di level produk: {sorted(missing)}.")
|
||||
continue
|
||||
|
||||
errors.extend(_validate_data_block(product, entry.get("data")))
|
||||
scenario_errors, scenarios = _validate_scenarios_block(product, entry.get("scenarios"))
|
||||
errors.extend(scenario_errors)
|
||||
|
||||
optimal = entry.get("optimal_scenario")
|
||||
if scenarios and optimal not in scenarios:
|
||||
errors.append(
|
||||
f"[{product}] optimal_scenario ('{optimal}') tidak cocok dengan salah satu "
|
||||
f"skenario yang ada: {sorted(scenarios.keys())}."
|
||||
)
|
||||
|
||||
if optimal in REGRESSOR_SCENARIOS and "scaler" not in entry:
|
||||
errors.append(
|
||||
f"[{product}] skenario optimal ('{optimal}') butuh regresor eksternal, "
|
||||
"tapi key 'scaler' tidak ditemukan di level produk."
|
||||
)
|
||||
|
||||
return data, errors
|
||||
|
||||
|
||||
def _validate_data_block(product: str, data_block: Any) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(data_block, dict):
|
||||
return [f"[{product}] 'data' harus berupa dictionary berisi 'train' dan 'test'."]
|
||||
|
||||
for split in ("train", "test"):
|
||||
if split not in data_block:
|
||||
errors.append(f"[{product}] 'data.{split}' tidak ditemukan.")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_scenarios_block(product: str, scenarios: Any) -> tuple[list[str], dict]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(scenarios, dict) or not scenarios:
|
||||
return [f"[{product}] 'scenarios' kosong atau bukan dictionary."], {}
|
||||
|
||||
missing_scenarios = EXPECTED_SCENARIOS - scenarios.keys()
|
||||
if missing_scenarios:
|
||||
errors.append(f"[{product}] skenario belum lengkap, hilang: {sorted(missing_scenarios)}.")
|
||||
|
||||
for scenario_name, scenario in scenarios.items():
|
||||
if not isinstance(scenario, dict):
|
||||
errors.append(f"[{product}][{scenario_name}] harus berupa dictionary.")
|
||||
continue
|
||||
|
||||
missing_keys = REQUIRED_SCENARIO_KEYS - scenario.keys()
|
||||
if missing_keys:
|
||||
errors.append(f"[{product}][{scenario_name}] key hilang: {sorted(missing_keys)}.")
|
||||
continue
|
||||
|
||||
config = scenario.get("config")
|
||||
if not isinstance(config, dict) or (REQUIRED_CONFIG_KEYS - config.keys()):
|
||||
missing_config = REQUIRED_CONFIG_KEYS - (config.keys() if isinstance(config, dict) else set())
|
||||
errors.append(f"[{product}][{scenario_name}] 'config' tidak lengkap, hilang: {sorted(missing_config)}.")
|
||||
|
||||
metrics = scenario.get("best_metrics")
|
||||
if not isinstance(metrics, dict) or (REQUIRED_METRIC_KEYS - metrics.keys()):
|
||||
errors.append(
|
||||
f"[{product}][{scenario_name}] 'best_metrics' harus memuat: {sorted(REQUIRED_METRIC_KEYS)}."
|
||||
)
|
||||
|
||||
return errors, scenarios
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview dampak merge (tanpa mengubah apa pun)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def preview_merge(current: dict[str, Any], new_data: dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Ringkasan dampak kalau `new_data` di-merge ke `current`, TANPA benar-benar
|
||||
menulis apa pun. Dipakai untuk ditampilkan ke admin sebelum tombol
|
||||
konfirmasi "Terapkan pembaruan" ditekan.
|
||||
"""
|
||||
existing = set(current.keys())
|
||||
incoming = set(new_data.keys())
|
||||
return {
|
||||
"produk_baru": sorted(incoming - existing),
|
||||
"produk_diperbarui": sorted(incoming & existing),
|
||||
"produk_tidak_berubah": sorted(existing - incoming),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup otomatis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def backup_current(path: str = DEFAULT_DATA_PATH, backup_dir: str = BACKUP_DIR) -> str | None:
|
||||
"""
|
||||
Salin file all_results.pkl yang sedang aktif ke folder backups/, dengan
|
||||
nama disisipi timestamp (format sesuai penamaan di rencana folder:
|
||||
all_results_backup_YYYYMMDD_HHMMSS.pkl).
|
||||
|
||||
Kalau file sumber belum ada sama sekali (kasus pertama kali dipakai di
|
||||
server baru), tidak ada yang perlu di-backup -- return None, BUKAN error.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%d%m%Y_%H%M%S")
|
||||
backup_path = os.path.join(backup_dir, f"all_results_backup_{timestamp}.pkl")
|
||||
|
||||
shutil.copy2(path, backup_path)
|
||||
return backup_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge + tulis ke disk + refresh cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_merge(
|
||||
current: dict[str, Any],
|
||||
new_data: dict[str, Any],
|
||||
path: str = DEFAULT_DATA_PATH,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""
|
||||
Proses lengkap penerapan pembaruan model:
|
||||
1. backup file lama (kalau ada)
|
||||
2. gabungkan current + new_data -- new_data MENANG untuk produk yang
|
||||
namanya sama, produk lain di current tetap dipertahankan apa adanya
|
||||
(bukan replace-all -- upload 1 produk tidak akan menghapus 4 produk lain)
|
||||
3. tulis hasil gabungan ke disk
|
||||
4. panggil reload_all_results() supaya st.cache_resource ikut di-refresh,
|
||||
jadi perubahan langsung kelihatan tanpa restart aplikasi
|
||||
|
||||
Setiap produk yang ikut ter-update diberi stempel 'updated_at', dipakai
|
||||
oleh utils.data_loader.get_last_updated_info() di tab "Status Model".
|
||||
|
||||
Returns
|
||||
-------
|
||||
(merged_data, backup_path)
|
||||
`backup_path` bernilai None kalau memang belum ada file lama untuk
|
||||
di-backup (lihat backup_current()).
|
||||
"""
|
||||
backup_path = backup_current(path)
|
||||
|
||||
merged = dict(current)
|
||||
timestamp = datetime.now().strftime("%d-%m-%Y %H:%M:%S")
|
||||
for product, entry in new_data.items():
|
||||
entry = dict(entry)
|
||||
entry["updated_at"] = timestamp
|
||||
merged[product] = entry
|
||||
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
pickle.dump(merged, f)
|
||||
|
||||
"""
|
||||
reload_all_results() = ganti resep di buku resep (lemari dapur)
|
||||
st.cache_data.clear() = buang semua masakan yang sudah jadi & tersimpan di kulkas
|
||||
(supaya kalau ada yang minta masakan itu lagi,
|
||||
koki TERPAKSA masak ULANG pakai resep BARU,
|
||||
bukan ngasih masakan lama dari kulkas)
|
||||
"""
|
||||
reload_all_results(path) # model/data-nya sendiri yang diupdate (objek Prophet baru masuk ke all_results, menggantikan yang lama)
|
||||
st.cache_data.clear() # menghapus/membuang hasil forecast LAMA
|
||||
|
||||
return merged, backup_path
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
"""
|
||||
utils/pdf_report.py
|
||||
====================
|
||||
Menghasilkan 1 file laporan PDF (bytes) per produk & periode terpilih --
|
||||
ringkasan/insight, grafik peramalan, tabel harian, dan dekomposisi model
|
||||
(trend, mingguan, tahunan, event/hari libur) beserta insight-nya.
|
||||
|
||||
Teks insight-nya memakai fungsi yang SAMA dengan yang dipakai UI Streamlit
|
||||
(lihat utils/report_insights.py), jadi angka & kalimat di PDF selalu
|
||||
konsisten dengan yang tampil di halaman Detail Peramalan.
|
||||
|
||||
Dipanggil dari views/detail_peramalan.py lewat build_report_pdf(...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # backend non-GUI, wajib untuk jalan di server/Streamlit
|
||||
import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
Image, KeepTogether, ListFlowable, ListItem, PageBreak, Paragraph,
|
||||
SimpleDocTemplate, Spacer, Table, TableStyle,
|
||||
)
|
||||
|
||||
from utils.forecast_helpers import get_decomposition
|
||||
from utils import report_insights as ri
|
||||
|
||||
_ACCENT = "#2563eb"
|
||||
_PURPLE = "#7c3aed"
|
||||
_AMBER = "#f59e0b"
|
||||
|
||||
_CONTENT_WIDTH = 17.6 * cm # A4 (21cm) dikurangi margin kiri+kanan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grafik (matplotlib -> PNG di memori, tidak menulis file ke disk)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fig_to_buffer(fig) -> io.BytesIO:
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=170, bbox_inches="tight", pad_inches=0.12)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
def _style_axes(ax) -> None:
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.grid(axis="y", alpha=0.25, linewidth=0.6)
|
||||
ax.tick_params(labelsize=7.5)
|
||||
|
||||
|
||||
def _main_chart_image(forecast: pd.DataFrame, unit: str) -> io.BytesIO:
|
||||
fig, ax = plt.subplots(figsize=(9.5, 3.6))
|
||||
x = forecast["ds"]
|
||||
ax.fill_between(
|
||||
x, forecast["yhat_lower"].clip(lower=0), forecast["yhat_upper"],
|
||||
color=_ACCENT, alpha=0.15, label="Rentang estimasi", linewidth=0,
|
||||
)
|
||||
ax.plot(
|
||||
x, forecast["yhat"].clip(lower=0), color=_ACCENT, linewidth=1.8,
|
||||
marker="o", markersize=3, label="Peramalan",
|
||||
)
|
||||
ax.set_ylabel(unit, fontsize=8)
|
||||
ax.legend(loc="upper left", fontsize=8, frameon=False)
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d-%m-%Y"))
|
||||
fig.autofmt_xdate(rotation=25)
|
||||
_style_axes(ax)
|
||||
return _fig_to_buffer(fig)
|
||||
|
||||
|
||||
def _mini_line_image(x, y, color: str = _ACCENT, figsize: tuple[float, float] = (4.6, 2.3)) -> io.BytesIO:
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
ax.plot(x, y, color=color, linewidth=1.6)
|
||||
# Catatan penting: ambil batas sumbu-y HASIL AUTOSCALE dulu (sebelum
|
||||
# menggambar garis bantu y=0), lalu paksa balik ke batas itu setelahnya.
|
||||
# Kalau tidak, ax.axhline(0, ...) akan MEMPERLEBAR sumbu-y supaya angka 0
|
||||
# ikut kelihatan -- padahal kalau data aslinya jauh dari 0 (mis. tren di
|
||||
# sekitar 4,90 atau musiman tahunan di sekitar -0,048), pelebaran ini
|
||||
# bikin variasi kecil yang sebenarnya ada jadi kelihatan rata/flat,
|
||||
# tidak seperti grafik Plotly di UI yang autoscale ketat ke data.
|
||||
ylim = ax.get_ylim()
|
||||
ax.axhline(0, color="#cbd5e1", linewidth=0.6, zorder=0)
|
||||
ax.set_ylim(ylim)
|
||||
if pd.api.types.is_datetime64_any_dtype(pd.Series(x)):
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d-%m-%Y"))
|
||||
fig.autofmt_xdate(rotation=25)
|
||||
_style_axes(ax)
|
||||
return _fig_to_buffer(fig)
|
||||
|
||||
|
||||
def _mini_multiline_image(x, series_dict: dict[str, list], colors_list: list[str], figsize: tuple[float, float] = (4.6, 2.3)) -> io.BytesIO:
|
||||
"""Versi PDF dari chart multi-garis per event -- lihat catatan yang
|
||||
sama di detail_peramalan.py::_mini_multiline (chart digambar TERPISAH
|
||||
per event, bukan dijumlah jadi 1 garis, supaya konsisten dengan
|
||||
insight teks yang juga menganalisis tiap event secara terisolasi)."""
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
for i, (nama, y) in enumerate(series_dict.items()):
|
||||
ax.plot(x, y, color=colors_list[i % len(colors_list)], linewidth=1.6, label=nama)
|
||||
ylim = ax.get_ylim()
|
||||
ax.axhline(0, color="#cbd5e1", linewidth=0.6, zorder=0)
|
||||
ax.set_ylim(ylim)
|
||||
if pd.api.types.is_datetime64_any_dtype(pd.Series(x)):
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d-%m-%Y"))
|
||||
fig.autofmt_xdate(rotation=25)
|
||||
if len(series_dict) > 1:
|
||||
ax.legend(fontsize=6.5, loc="upper left", frameon=False)
|
||||
_style_axes(ax)
|
||||
return _fig_to_buffer(fig)
|
||||
|
||||
|
||||
def _mini_bar_image(x, y, color: str = _ACCENT) -> io.BytesIO:
|
||||
fig, ax = plt.subplots(figsize=(4.6, 2.3))
|
||||
ax.bar(x, y, color=color)
|
||||
ylim = ax.get_ylim()
|
||||
ax.axhline(0, color="#cbd5e1", linewidth=0.6, zorder=0)
|
||||
ax.set_ylim(ylim)
|
||||
ax.tick_params(axis="x", labelrotation=30, labelsize=7)
|
||||
_style_axes(ax)
|
||||
return _fig_to_buffer(fig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tabel hasil peramalan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_forecast_table(forecast: pd.DataFrame, unit: str) -> Table:
|
||||
tabel = forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].copy()
|
||||
tabel.columns = ["Tanggal", f"Peramalan ({unit})", "Estimasi Bawah", "Estimasi Atas"]
|
||||
tabel["Tanggal"] = tabel["Tanggal"].dt.strftime("%d-%m-%Y")
|
||||
for col in tabel.columns[1:]:
|
||||
tabel[col] = tabel[col].clip(lower=0).apply(
|
||||
lambda v: f"{v:,.1f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
)
|
||||
|
||||
data = [list(tabel.columns)] + tabel.values.tolist()
|
||||
col_w = _CONTENT_WIDTH / 4
|
||||
tbl = Table(data, repeatRows=1, colWidths=[col_w] * 4)
|
||||
tbl.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor(_ACCENT)),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f1f5f9")]),
|
||||
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")),
|
||||
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3.5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3.5),
|
||||
]))
|
||||
return tbl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_report_pdf(
|
||||
product_entry: dict[str, Any], product: str, unit: str,
|
||||
forecast: pd.DataFrame, summary: dict, start: datetime.date, end: datetime.date,
|
||||
scenario: str, is_log: bool,
|
||||
) -> bytes:
|
||||
"""Rakit 1 laporan PDF lengkap. Mengembalikan bytes (siap untuk st.download_button)."""
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf, pagesize=A4,
|
||||
topMargin=1.6 * cm, bottomMargin=1.6 * cm, leftMargin=1.7 * cm, rightMargin=1.7 * cm,
|
||||
title=f"Laporan Peramalan - {product.title()}",
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle("TitleX", parent=styles["Title"], fontSize=18, alignment=TA_LEFT, spaceAfter=2)
|
||||
sub_style = ParagraphStyle("SubX", parent=styles["Normal"], fontSize=9.5, textColor=colors.HexColor("#64748b"), spaceAfter=16)
|
||||
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=13, spaceBefore=8, spaceAfter=6, textColor=colors.HexColor("#1e293b"))
|
||||
h3 = ParagraphStyle("H3", parent=styles["Heading3"], fontSize=10.5, spaceBefore=6, spaceAfter=3, textColor=colors.HexColor("#334155"))
|
||||
body = ParagraphStyle("BodyX", parent=styles["Normal"], fontSize=9.3, leading=14.5)
|
||||
footer_style = ParagraphStyle("FooterX", parent=styles["Normal"], fontSize=7.6, textColor=colors.HexColor("#64748b"), leading=10.5)
|
||||
caption_style = ParagraphStyle(
|
||||
"CaptionX", parent=styles["Normal"], fontSize=8.3, textColor=colors.HexColor("#64748b"), leading=12,
|
||||
)
|
||||
|
||||
def _boxed_insight(content, width: float) -> Table:
|
||||
"""
|
||||
Bungkus teks insight dalam kotak abu-abu pakai Table 1 sel, BUKAN
|
||||
lewat borderWidth/backColor milik Paragraph. Kenapa: kalau dipasang
|
||||
langsung di Paragraph (cara lama), begitu 2 kotak dipasangkan
|
||||
berdampingan dalam grid dan tinggi teksnya beda (mis. 4 baris vs 3
|
||||
baris), reportlab bisa menyisakan serpihan kotak kecil di sudut --
|
||||
seperti yang muncul di celah antar kolom pada laporan sebelumnya.
|
||||
Table 1-sel dengan BACKGROUND+BOX tidak punya masalah itu.
|
||||
|
||||
`content` boleh berupa 1 string (dirender sebagai paragraf biasa)
|
||||
atau list[str] (dirender sebagai bullet points -- dipakai saat 1
|
||||
komponen dekomposisi punya beberapa event sekaligus, mis. Ramadhan
|
||||
& Idulfitri di periode yang sama).
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
flowable = Paragraph(content, body)
|
||||
elif len(content) == 1:
|
||||
flowable = Paragraph(content[0], body)
|
||||
else:
|
||||
items = [
|
||||
ListItem(Paragraph(b, body), leftIndent=10, spaceAfter=6, bulletFontSize=5, value="circle")
|
||||
for b in content
|
||||
]
|
||||
flowable = ListFlowable(items, bulletType="bullet", leftIndent=8)
|
||||
|
||||
box = Table([[flowable]], colWidths=[width])
|
||||
box.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f8fafc")),
|
||||
("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#e2e8f0")),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 11),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 11),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 9),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
|
||||
]))
|
||||
return box
|
||||
|
||||
story: list = []
|
||||
|
||||
# ================= HEADER =================
|
||||
story.append(Paragraph(f"Laporan Peramalan Permintaan — {product.title()}", title_style))
|
||||
story.append(Paragraph(
|
||||
f"Periode: {start.strftime('%d-%m-%Y')} s.d. {end.strftime('%d-%m-%Y')}"
|
||||
# f" | Skenario model: {scenario}"
|
||||
f" | Dicetak tanggal: {datetime.date.today().strftime('%d-%m-%Y')}",
|
||||
sub_style,
|
||||
))
|
||||
|
||||
# ================= RINGKASAN & INSIGHT =================
|
||||
bullets, footer_note, _ = ri.compute_highlight_bullets(product, unit, forecast, summary, product_entry, start, end)
|
||||
story.append(Paragraph("1. Ringkasan Peramalan", h2))
|
||||
if len(bullets) == 1:
|
||||
story.append(_boxed_insight(bullets[0], _CONTENT_WIDTH))
|
||||
story.append(Spacer(1, 8))
|
||||
else:
|
||||
items = [
|
||||
ListItem(
|
||||
Paragraph(b, body), leftIndent=10, spaceAfter=4,
|
||||
bulletFontSize=5, value="circle",
|
||||
)
|
||||
for b in bullets
|
||||
]
|
||||
story.append(ListFlowable(items, bulletType="bullet", leftIndent=8))
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(Paragraph(footer_note, footer_style))
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
# ================= GRAFIK UTAMA =================
|
||||
story.append(Paragraph("2. Grafik Peramalan", h2))
|
||||
img_buf = _main_chart_image(forecast, unit)
|
||||
story.append(Image(img_buf, width=_CONTENT_WIDTH, height=_CONTENT_WIDTH * 3.6 / 9.5))
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
# ================= TABEL =================
|
||||
story.append(Paragraph("3. Tabel Hasil Peramalan Harian", h2))
|
||||
story.append(Paragraph(
|
||||
"Gunakan angka <b>peramalan</b> sebagai acuan utama dalam perencanaan jumlah stok. "
|
||||
"Siapkan stok cadangan untuk mengantisipasi kemungkinan permintaan naik hingga "
|
||||
"<b>estimasi atas peramalan.</b>",
|
||||
caption_style,
|
||||
))
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(_build_forecast_table(forecast, unit))
|
||||
|
||||
# ================= DEKOMPOSISI (halaman baru) =================
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("4. Dekomposisi Model & Insight", h2))
|
||||
|
||||
scenario_config = product_entry["scenarios"][scenario]
|
||||
model = scenario_config["best_model"]
|
||||
seasonality_mode = getattr(model, "seasonality_mode", "additive")
|
||||
is_relative = is_log or (seasonality_mode == "multiplicative") or ("multiplicative" in scenario.lower())
|
||||
|
||||
cara_baca_style = ParagraphStyle(
|
||||
"CaraBacaHead", parent=caption_style, fontSize=8.6, textColor=colors.HexColor("#475569"),
|
||||
spaceAfter=3,
|
||||
)
|
||||
story.append(Paragraph("Cara Membaca Grafik Dekomposisi:", cara_baca_style))
|
||||
cara_baca_items = [
|
||||
ListItem(
|
||||
Paragraph(poin, caption_style), leftIndent=8, spaceAfter=2, bulletColor=colors.HexColor("#64748a"),
|
||||
bulletFontSize=5, value="circle",
|
||||
)
|
||||
for poin in ri.how_to_read_decomposition_bullets(is_relative)
|
||||
]
|
||||
story.append(ListFlowable(cara_baca_items, bulletType="bullet", leftIndent=6))
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
decomposition = get_decomposition(forecast)
|
||||
|
||||
if is_log:
|
||||
story.append(Paragraph(
|
||||
"<b>Catatan:</b> Skenario optimal produk ini memakai transformasi logaritma. Grafik di bagian "
|
||||
"Ringkasan sudah dalam satuan asli, tapi grafik dekomposisi di bawah ini masih dalam "
|
||||
"<b>skala log</b> — polanya (naik/turun/musiman) tetap valid dibaca, hanya angkanya "
|
||||
"tidak langsung sepadan dengan satuan produk aslinya.",
|
||||
footer_style,
|
||||
))
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
# ---- Susun 4 komponen dekomposisi jadi grid 2 kolom (mirip tab UI) ----
|
||||
cell_w = _CONTENT_WIDTH / 2 - 0.3 * cm
|
||||
img_w = cell_w - 0.4 * cm
|
||||
|
||||
cells: list[list] = []
|
||||
|
||||
if "trend" in decomposition:
|
||||
img = _mini_line_image(decomposition["trend"]["ds"], decomposition["trend"]["trend"])
|
||||
cells.append([
|
||||
Paragraph("Tren Dasar Penjualan", h3),
|
||||
Spacer(1, 6),
|
||||
Image(img, width=img_w, height=img_w * 2.3 / 4.6),
|
||||
Spacer(1, 16),
|
||||
_boxed_insight(ri.trend_insight_text(decomposition), img_w),
|
||||
])
|
||||
|
||||
if "weekly" in decomposition:
|
||||
pola = ri.weekly_pattern(decomposition["weekly"])
|
||||
img = _mini_bar_image(pola.index, pola.values)
|
||||
cells.append([
|
||||
Paragraph("Pola Musiman Mingguan", h3),
|
||||
Spacer(1, 6),
|
||||
Image(img, width=img_w, height=img_w * 2.3 / 4.6),
|
||||
Spacer(1, 16),
|
||||
_boxed_insight(ri.weekly_insight_text(pola), img_w),
|
||||
])
|
||||
|
||||
# Pasangkan Tren & Mingguan jadi 1 baris tabel 2 kolom -- keduanya
|
||||
# SELALU pendek (chart ukuran tetap + 1-2 kalimat insight), jadi aman
|
||||
# digabung tanpa risiko 1 sel jadi sangat panjang dan menyeret sel
|
||||
# sebelahnya ikut kepotong/gagal muat halaman.
|
||||
if cells:
|
||||
pair = cells[:2] if len(cells) >= 2 else [cells[0], ""]
|
||||
grid = Table([pair], colWidths=[cell_w + 0.3 * cm] * 2)
|
||||
grid.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 14),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 26),
|
||||
]))
|
||||
story.append(grid)
|
||||
|
||||
# Pola Musiman Tahunan & Efek Event Lokal / Hari Libur dirender
|
||||
# TERPISAH (bukan dipasangkan dalam 1 baris tabel seperti Tren &
|
||||
# Mingguan) -- karena panjang "Efek Event Lokal / Hari Libur" TIDAK
|
||||
# tetap: untuk periode laporan yang panjang (mis. forecast 1-8 bulan),
|
||||
# banyak hari libur bisa aktif sekaligus sehingga daftar insight-nya
|
||||
# bisa jadi sangat panjang. Kalau dipasangkan dalam 1 baris tabel,
|
||||
# ReportLab memaksa kedua sel dalam baris itu punya TINGGI YANG SAMA,
|
||||
# dan karena 1 baris tabel tidak bisa dipecah ke halaman berikutnya,
|
||||
# seluruh baris gagal muat & laporan gagal dibuat ("too large on
|
||||
# page..."). Dengan dirender terpisah (full-width, bukan dalam tabel),
|
||||
# ReportLab bebas memecah tiap bagian ke halaman berikutnya sendiri-
|
||||
# sendiri tanpa saling mengunci tinggi.
|
||||
full_w = _CONTENT_WIDTH
|
||||
# Rasio lebar:tinggi yang LEBIH LEBAR (bukan cuma di-scale-up dari
|
||||
# figsize kecil (4.6, 2.3)) -- supaya chart full-width tidak jadi
|
||||
# kelihatan "terlalu tinggi/menonjol" dibanding chart Tren & Mingguan
|
||||
# yang kecil di atasnya. Proporsinya disamakan mendekati tinggi absolut
|
||||
# chart kecil (img_w * 2.3/4.6), cuma dilebarkan ke lebar penuh halaman.
|
||||
WIDE_FIGSIZE = (9.5, 2.6)
|
||||
full_h = full_w * WIDE_FIGSIZE[1] / WIDE_FIGSIZE[0]
|
||||
|
||||
if "yearly" in decomposition:
|
||||
img = _mini_line_image(
|
||||
decomposition["yearly"]["ds"], decomposition["yearly"]["yearly"],
|
||||
color=_PURPLE, figsize=WIDE_FIGSIZE,
|
||||
)
|
||||
# Judul + chart dijaga tetap SATU BLOK utuh (KeepTogether) supaya
|
||||
# kalau tidak muat di sisa halaman, keduanya pindah BERSAMA ke
|
||||
# halaman berikutnya -- bukan judulnya "ketinggalan sendirian" di
|
||||
# bawah halaman sementara chart-nya lompat ke halaman berikutnya.
|
||||
story.append(KeepTogether([
|
||||
Paragraph("Pola Musiman Tahunan", h3),
|
||||
Spacer(1, 6),
|
||||
Image(img, width=full_w, height=full_h),
|
||||
]))
|
||||
story.append(Spacer(1, 12))
|
||||
story.append(_boxed_insight(ri.yearly_insight_text(decomposition["yearly"]["yearly"].values), full_w))
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
if "holidays" in decomposition:
|
||||
hol = decomposition["holidays"]
|
||||
hol_cols = [c for c in hol.columns if c != "ds"]
|
||||
holidays_df = getattr(model, "holidays", None)
|
||||
# Chart digambar SEBAGAI GARIS TERPISAH per event (bukan dijumlah
|
||||
# jadi 1 garis total) -- lihat catatan detail di
|
||||
# detail_peramalan.py::_mini_multiline untuk alasannya.
|
||||
EVENT_COLORS = [_AMBER, _ACCENT, "#16a34a", "#dc2626", _PURPLE, "#0891b2", "#db2777"]
|
||||
active_cols = [c for c in hol_cols if hol[c].abs().max() > 1e-6]
|
||||
if active_cols:
|
||||
series_dict = {col.replace("_", " ").title(): hol[col].tolist() for col in active_cols}
|
||||
img = _mini_multiline_image(hol["ds"], series_dict, EVENT_COLORS, figsize=WIDE_FIGSIZE)
|
||||
else:
|
||||
# Seluruh periode yang dipilih TIDAK ADA event sama sekali --
|
||||
# tetap tampilkan grafiknya (bukan cuma teks pengganti), sebagai
|
||||
# garis datar di angka 0 warna hijau (kondisi "tenang"/tidak ada
|
||||
# efek event) -- SAMA PERSIS seperti tab Dekomposisi di UI
|
||||
# Streamlit (lihat detail_peramalan.py), supaya PDF & UI selalu
|
||||
# konsisten alih-alih PDF cuma menampilkan teks pengganti tanpa
|
||||
# grafik sama sekali.
|
||||
flat_zero = [0.0] * len(hol)
|
||||
img = _mini_multiline_image(
|
||||
hol["ds"], {"Tidak ada event": flat_zero}, ["#16a34a"], figsize=WIDE_FIGSIZE,
|
||||
)
|
||||
header_block = [
|
||||
Paragraph("Efek Event Lokal / Hari Libur", h3),
|
||||
Spacer(1, 6),
|
||||
Image(img, width=full_w, height=full_h),
|
||||
]
|
||||
story.append(KeepTogether(header_block))
|
||||
story.append(Spacer(1, 12))
|
||||
story.append(_boxed_insight(ri.holiday_insight_bullets(hol, hol_cols, holidays_df), full_w))
|
||||
|
||||
doc.build(story)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
|
@ -0,0 +1,999 @@
|
|||
"""
|
||||
utils/report_insights.py
|
||||
=========================
|
||||
Fungsi-fungsi PURE (tidak bergantung Streamlit / `st.*`) untuk menghasilkan
|
||||
teks insight dari hasil peramalan & dekomposisi.
|
||||
|
||||
Ditaruh terpisah di `utils/` (bukan di dalam `views/detail_peramalan.py`)
|
||||
supaya bisa dipakai bersama oleh:
|
||||
- views/detail_peramalan.py -> render ke UI Streamlit (highlight box & tab Dekomposisi)
|
||||
- utils/pdf_report.py -> render ke Laporan PDF
|
||||
|
||||
Dengan begitu, teks insight yang muncul di UI dan di PDF SELALU konsisten
|
||||
-- tidak ada risiko dua tempat punya logika/kalimat yang beda.
|
||||
|
||||
Catatan format teks: string yang dikembalikan boleh mengandung tag ringan
|
||||
ala HTML (`<b>...</b>`, `<br/>`) karena kompatibel baik dengan
|
||||
`st.markdown(..., unsafe_allow_html=True)` maupun `reportlab.Paragraph`
|
||||
(reportlab mendukung subset tag HTML termasuk <b> dan <br/>).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
|
||||
from utils.forecast_helpers import generate_optimal_forecast, get_decomposition
|
||||
|
||||
HARI_URUTAN = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pola mingguan (dipakai untuk chart bar & insight)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def weekly_pattern(weekly_df: pd.DataFrame) -> pd.Series:
|
||||
df = weekly_df.copy()
|
||||
df["hari"] = df["ds"].dt.day_name().map({
|
||||
"Monday": "Senin", "Tuesday": "Selasa", "Wednesday": "Rabu",
|
||||
"Thursday": "Kamis", "Friday": "Jumat", "Saturday": "Sabtu", "Sunday": "Minggu",
|
||||
})
|
||||
return df.groupby("hari")["weekly"].mean().reindex(HARI_URUTAN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ringkasan & insight utama (tab "Peramalan" / halaman 1 laporan PDF)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_highlight_bullets(
|
||||
product: str, unit: str, forecast: pd.DataFrame, summary: dict,
|
||||
product_entry: dict[str, Any], start: datetime.date, end: datetime.date,
|
||||
) -> tuple[list[str], str, bool]:
|
||||
"""
|
||||
Hitung poin-poin insight ringkasan peramalan.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bullets : list[str] -- masing-masing 1 poin insight (siap dipakai
|
||||
sebagai <li> di UI atau ListItem di PDF)
|
||||
footer : str -- catatan asumsi model (ditampilkan terpisah)
|
||||
is_event_period : bool -- True kalau ada event/hari libur yang
|
||||
menyentuh periode ini (dipakai untuk warna box di UI)
|
||||
"""
|
||||
n_hari = summary["jumlah_hari"]
|
||||
total_fmt = f"{summary['total']:,.0f}".replace(",", ".")
|
||||
rata_fmt = f"{summary['rata_rata_harian']:.3f}".replace(".", ",")
|
||||
|
||||
scenario_name = product_entry["optimal_scenario"]
|
||||
model = product_entry["scenarios"][scenario_name]["best_model"]
|
||||
|
||||
bullets: list[str] = []
|
||||
|
||||
# ---------- Pola mingguan ----------
|
||||
"""
|
||||
blok ini tugasnya mencari hari puncak (tertinggi) dan hari lembah (terendah) dari pola mingguan. Dan ya, kalau periode yang dipilih user kurang dari 7 hari, program tetap "diam-diam" mengambil data 1 minggu penuh untuk memastikan pola mingguan yang diambil representatif.
|
||||
"""
|
||||
decomposition = get_decomposition(forecast)
|
||||
weekly_top_day, weekly_bottom_day = None, None
|
||||
pola_mingguan = pd.Series(dtype=float)
|
||||
if "weekly" in decomposition:
|
||||
pola_mingguan = weekly_pattern(decomposition["weekly"]).dropna()
|
||||
if len(pola_mingguan) < 7:
|
||||
try:
|
||||
week_start = start - datetime.timedelta(days=start.weekday())
|
||||
week_end = week_start + datetime.timedelta(days=6)
|
||||
full_week_fc, _, _ = generate_optimal_forecast(product_entry, str(week_start), str(week_end))
|
||||
full_decomp = get_decomposition(full_week_fc)
|
||||
if "weekly" in full_decomp:
|
||||
pola_mingguan = weekly_pattern(full_decomp["weekly"]).dropna()
|
||||
except Exception:
|
||||
pass
|
||||
"""
|
||||
Kalau pola_mingguan ada isinya, DAN memang ada perbedaan nyata antara hari tertinggi & terendah (bukan semua hari sama rata), BARU tentukan siapa weekly_top_day dan weekly_bottom_day.
|
||||
"""
|
||||
if not pola_mingguan.empty and (pola_mingguan.max() - pola_mingguan.min()) > 1e-9:
|
||||
weekly_top_day = pola_mingguan.idxmax()
|
||||
weekly_bottom_day = pola_mingguan.idxmin()
|
||||
|
||||
# ---------- Event/hari libur yang menyentuh periode ----------
|
||||
"""
|
||||
1. Cek setiap hari libur yang dikenal model Prophet
|
||||
2. Saring — cuma ambil yang rentang waktunya (window) bersinggungan dengan periode peramalan yang dipilih user
|
||||
3. Buang duplikat (kalau event yang sama kebetulan tercatat lebih dari sekali)
|
||||
4. Urutkan dari tanggal paling awal
|
||||
|
||||
Hasil akhirnya: events_in_period = daftar bersih event-event yang relevan untuk periode ini, siap dipakai buat nyusun kalimat narasi.
|
||||
"""
|
||||
events_in_period = []
|
||||
holidays_df = getattr(model, "holidays", None)
|
||||
if holidays_df is not None and not holidays_df.empty:
|
||||
seen_events = set()
|
||||
for _, row in holidays_df.iterrows():
|
||||
ev_date = pd.to_datetime(row["ds"]).date()
|
||||
ev_name_raw = str(row["holiday"])
|
||||
lower_win = int(row["lower_window"]) if "lower_window" in row and not pd.isna(row["lower_window"]) else 0
|
||||
upper_win = int(row["upper_window"]) if "upper_window" in row and not pd.isna(row["upper_window"]) else 0
|
||||
win_start = ev_date + datetime.timedelta(days=lower_win)
|
||||
win_end = ev_date + datetime.timedelta(days=upper_win)
|
||||
|
||||
if win_end < start or win_start > end:
|
||||
continue
|
||||
key = (ev_name_raw, ev_date)
|
||||
if key in seen_events:
|
||||
continue
|
||||
seen_events.add(key)
|
||||
events_in_period.append({
|
||||
"name": ev_name_raw.replace("_", " ").title(),
|
||||
"ev_date": ev_date,
|
||||
"win_start": win_start,
|
||||
"win_end": win_end,
|
||||
})
|
||||
events_in_period.sort(key=lambda e: e["ev_date"])
|
||||
is_event_period = len(events_in_period) > 0
|
||||
|
||||
|
||||
overall_lower = forecast["yhat_lower"].clip(lower=0).min()
|
||||
overall_upper = forecast["yhat_upper"].max()
|
||||
overall_lower_fmt = f"{overall_lower:,.0f}".replace(",", ".")
|
||||
overall_upper_fmt = f"{overall_upper:,.0f}".replace(",", ".")
|
||||
|
||||
overall_yhatmin = forecast["yhat"].clip(lower=0).min()
|
||||
overall_yhatmax = forecast["yhat"].max()
|
||||
overall_yhatmin_fmt = f"{overall_yhatmin:,.0f}".replace(",", ".")
|
||||
overall_yhatmax_fmt = f"{overall_yhatmax:,.0f}".replace(",", ".")
|
||||
start_str = start.strftime("%d-%m-%Y")
|
||||
end_str = end.strftime("%d-%m-%Y")
|
||||
|
||||
"""
|
||||
khusus jika yang diramah itu 1 tanggal saja ya, hasil yang lihat itu apakah tanggal dengan permintaan tertinggi/terendah/normal dalam seminggu? ada event ga?
|
||||
"""
|
||||
if n_hari == 1:
|
||||
the_date = start
|
||||
the_dow = HARI_URUTAN[the_date.weekday()]
|
||||
|
||||
if weekly_top_day and the_dow == weekly_top_day:
|
||||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan tertinggi</b> dibandingkan hari lain berdasarkan pola mingguan."
|
||||
hubungkata = " Ditambah lagi,"
|
||||
elif weekly_bottom_day and the_dow == weekly_bottom_day:
|
||||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan terendah</b> dibandingkan hari lain berdasarkan pola mingguan."
|
||||
hubungkata = " Meski begitu,"
|
||||
else:
|
||||
weekly_txt = f" Hari <b>{the_dow}</b> merupakan hari dengan <b>permintaan normal</b> berdasarkan pola mingguan."
|
||||
hubungkata = " Selain itu,"
|
||||
|
||||
active_events_here = []
|
||||
for ev in events_in_period:
|
||||
if ev["win_start"] <= the_date <= ev["win_end"]:
|
||||
selisih = (the_date - ev["ev_date"]).days
|
||||
status_waktu = "Hari H" if selisih == 0 else (f"H{selisih}" if selisih < 0 else f"H+{selisih}")
|
||||
active_events_here.append(f"{status_waktu} {ev['name']}")
|
||||
|
||||
event_txt = ""
|
||||
if active_events_here:
|
||||
event_txt = f" {hubungkata} tanggal ini bertepatan dengan <b>{', '.join(active_events_here)}</b>, yang berpotensi memengaruhi permintaan."
|
||||
|
||||
bullets.append(
|
||||
f"Peramalan permintaan pada <b>{the_dow}, {start_str}</b> diperkirakan "
|
||||
f"<b>{total_fmt} {unit}</b> (kisaran estimasi: <b>{overall_lower_fmt}-{overall_upper_fmt} {unit}</b>)."
|
||||
f"{weekly_txt}{event_txt} Siapkan stok utama sebesar <b>{total_fmt} {unit}</b> dan pertimbangkan "
|
||||
f"stok cadangan hingga <b>{overall_upper_fmt} {unit}</b> untuk mengantisipasi lonjakan mendadak."
|
||||
)
|
||||
|
||||
# sebenarnya dari sini n > 1
|
||||
else:
|
||||
bullets.append(
|
||||
f"Peramalan permintaan periode {start_str} - {end_str} berkisar antara "
|
||||
f"<b>{overall_yhatmin_fmt} {unit} (terendah)</b> hingga <b>{overall_yhatmax_fmt} {unit} (tertinggi)</b>."
|
||||
)
|
||||
bullets.append(
|
||||
f"Total peramalan permintaan <b>{n_hari} hari</b> tersebut: "
|
||||
f"<b>{total_fmt} {unit}</b> (rata-rata {rata_fmt} {unit}/hari)."
|
||||
)
|
||||
"""
|
||||
temukan dulu FAKTA (angka tertinggi & tanggalnya), baru cari SEBAB
|
||||
(cocokkan ke event & pola mingguan yang sudah dihitung sebelumnya).
|
||||
"""
|
||||
if n_hari > 1:
|
||||
rata_harian = summary["rata_rata_harian"]
|
||||
|
||||
# ---- Titik terendah ----
|
||||
min_idx = forecast["yhat"].idxmin()
|
||||
min_row = forecast.loc[min_idx]
|
||||
|
||||
min_ds = min_row["ds"].date()
|
||||
min_yhat = min_row["yhat"]
|
||||
min_dow = HARI_URUTAN[min_ds.weekday()]
|
||||
|
||||
min_ds_str = min_ds.strftime("%d-%m-%Y")
|
||||
min_yhat_fmt = f"{min_yhat:,.0f}".replace(",", ".")
|
||||
pct_low_diff = ((min_yhat / rata_harian) - 1) * 100 if rata_harian > 0 else 0 # Contoh: min_yhat = 80, rata_harian = 100 → (80/100 - 1) × 100 = -20 → artinya 20% di bawah rata-rata.
|
||||
pct_low_fmt = f"{abs(pct_low_diff):.0f}".replace(".", ",")
|
||||
|
||||
low_sebab = ""
|
||||
if weekly_bottom_day and min_dow == weekly_bottom_day:
|
||||
low_sebab = (
|
||||
f" Ini sejalan dengan pola mingguan di hari <b>{min_dow}</b>, yang biasanya "
|
||||
"merupakan hari dengan permintaan rendah dibandingkan hari lain."
|
||||
)
|
||||
|
||||
bullets.append(
|
||||
f"Permintaan <b>TERENDAH</b> diperkirakan pada <b>{min_dow}, {min_ds_str}</b>: "
|
||||
f"<b>{min_yhat_fmt} {unit}</b> sekitar <b>{pct_low_fmt}%</b> di bawah rata-rata periode ini."
|
||||
f"{low_sebab} Bisa jadi waktu yang tepat untuk restock."
|
||||
)
|
||||
|
||||
# ---- Titik tertinggi ----
|
||||
max_idx = forecast["yhat"].idxmax()
|
||||
max_row = forecast.loc[max_idx]
|
||||
|
||||
max_ds = max_row["ds"].date()
|
||||
max_yhat = max_row["yhat"]
|
||||
max_dow = HARI_URUTAN[max_ds.weekday()]
|
||||
|
||||
max_ds_str = max_ds.strftime("%d-%m-%Y")
|
||||
max_yhat_fmt = f"{max_yhat:,.0f}".replace(",", ".")
|
||||
pct_diff = ((max_yhat / rata_harian) - 1) * 100 if rata_harian > 0 else 0 # Contoh: max_yhat = 120, rata_harian = 100 → (120/100 - 1) × 100 = 20 → artinya 20% di atas rata-rata.
|
||||
pct_fmt = f"{pct_diff:.0f}".replace(".", ",")
|
||||
|
||||
# ambil nama (ev["name"]) dari setiap event di events_in_period, TAPI hanya yang tanggal puncaknya (max_ds) jatuh di dalam rentang window event itu.
|
||||
peak_event_names = {
|
||||
ev["name"] for ev in events_in_period if ev["win_start"] <= max_ds <= ev["win_end"]
|
||||
}
|
||||
|
||||
sebab_list = []
|
||||
if peak_event_names: # Apakah tanggal PUNCAK ini (max_ds, hasil temuan sebelumnya (dalam periode yang ingin diramal)) kebetulan bertepatan dengan event apa saja?
|
||||
sebab_list.append(f"efek event <b>{', '.join(sorted(peak_event_names))}</b>")
|
||||
|
||||
|
||||
if weekly_top_day and max_dow == weekly_top_day: # Apakah HARI dari tanggal puncak ini (max_dow, misal 'Jumat') sama dengan HARI yang secara pola mingguan memang biasanya paling tinggi (weekly_top_day)?
|
||||
sebab_list.append(
|
||||
f"pola mingguan di hari <b>{max_dow}</b>, yang biasanya merupakan hari dengan "
|
||||
"permintaan tinggi dibandingkan hari lain"
|
||||
)
|
||||
sebab_str = " & ".join(sebab_list)
|
||||
|
||||
peak_txt = (
|
||||
f"Permintaan <b>TERTINGGI</b> diperkirakan pada <b>{max_dow}, {max_ds_str}</b>: "
|
||||
f"<b>{max_yhat_fmt} {unit}</b> sekitar <b>{pct_fmt}%</b> di atas rata-rata periode ini."
|
||||
)
|
||||
if sebab_str:
|
||||
peak_txt += f" Kenaikan ini dipengaruhi oleh {sebab_str}."
|
||||
peak_txt += " Pastikan stok mendekati batas atas tersedia."
|
||||
bullets.append(peak_txt)
|
||||
|
||||
# ---- Event lain dalam periode ----
|
||||
# ada tanggal lain yang menjadi event tapi bukan puncak permintaan? (misal: H-1, H+1, H+2 dari event utama)
|
||||
other_events = [ev for ev in events_in_period if ev["name"] not in peak_event_names]
|
||||
if other_events:
|
||||
nama_event_lain = ", ".join(sorted({ev["name"] for ev in other_events}))
|
||||
bullets.append(
|
||||
f"Periode ini terdeteksi efek event <b>{nama_event_lain}</b>. "
|
||||
"Lihat grafik/tabel peramalan untuk detail tanggalnya."
|
||||
)
|
||||
|
||||
footer = (
|
||||
"Catatan: peramalan ini mengasumsikan stok tersedia & operasional toko berjalan normal "
|
||||
"(tidak ada gangguan pasokan atau tutup mendadak). Kalau ada kendala operasional pada "
|
||||
"tanggal tertentu, angka aktual bisa berbeda dari peramalan ini."
|
||||
)
|
||||
|
||||
return bullets, footer, is_event_period
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Insight per komponen dekomposisi (tab "Dekomposisi" / halaman 2 laporan PDF)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pattern_explanation(is_relative: bool) -> str:
|
||||
if is_relative:
|
||||
return (
|
||||
"Nilai pola mingguan/tahunan/event di bawah ini merupakan <b>bobot penyesuaian relatif "
|
||||
"(persentase)</b> terhadap tren dasar. Contoh: angka -0,08 berarti penjualan pada titik "
|
||||
"tersebut cenderung turun ~8% dari tren utama."
|
||||
)
|
||||
return (
|
||||
"Nilai pola mingguan/tahunan/event di bawah ini merupakan <b>penyesuaian jumlah unit "
|
||||
"barang</b> terhadap tren dasar. Contoh: angka -0,08 berarti penjualan pada titik tersebut "
|
||||
"berkurang sekitar 0,08 unit dari tren utama."
|
||||
)
|
||||
|
||||
|
||||
def how_to_read_decomposition_bullets(is_relative: bool) -> list[str]:
|
||||
"""
|
||||
2 poin "Cara Membaca Grafik Dekomposisi" -- SAMA persis dengan yang
|
||||
ditampilkan di UI Streamlit (tab Dekomposisi): poin pertama tentang
|
||||
tren dasar, poin kedua tentang arti angka pola mingguan/tahunan/event.
|
||||
"""
|
||||
return [
|
||||
"<b>Tren dasar penjualan</b> merupakan angka perkiraan utama penjualan barang Anda.",
|
||||
f"<b>Pola mingguan, tahunan, dan efek event</b> {_pattern_explanation_clause(is_relative)}",
|
||||
]
|
||||
|
||||
|
||||
def _pattern_explanation_clause(is_relative: bool) -> str:
|
||||
"""Versi klausa (tanpa subjek kalimat) dari pattern_explanation, dipakai
|
||||
sebagai sambungan poin ke-2 di how_to_read_decomposition_bullets()."""
|
||||
if is_relative:
|
||||
return (
|
||||
"merupakan <b>bobot penyesuaian relatif (persentase)</b> terhadap tren. "
|
||||
"Contoh: angka -0,08 berarti penjualan pada titik tersebut cenderung "
|
||||
"<b>turun ~8%</b> dari tren utama."
|
||||
)
|
||||
return (
|
||||
"merupakan <b>penyesuaian jumlah unit barang</b> terhadap tren. "
|
||||
"Contoh: angka -0,08 berarti penjualan pada titik tersebut berkurang sekitar "
|
||||
"<b>0,08 unit</b> dari tren utama."
|
||||
)
|
||||
|
||||
def trend_insight_text(decomposition: dict[str, pd.DataFrame]) -> str:
|
||||
if "trend" not in decomposition:
|
||||
return "Tidak tersedia untuk skenario ini."
|
||||
|
||||
t_vals = decomposition["trend"]["trend"].values
|
||||
n = len(t_vals)
|
||||
if n < 2:
|
||||
return (
|
||||
"Periode ini hanya 1 hari, jadi arah tren dasar (naik/turun/stabil) belum bisa "
|
||||
"dibandingkan (butuh minimal 2 hari)."
|
||||
)
|
||||
|
||||
x = np.arange(n)
|
||||
slope, intercept = np.polyfit(x, t_vals, 1)
|
||||
total_change = slope * (n - 1)
|
||||
EPS = 1e-4
|
||||
if total_change > EPS:
|
||||
return "Pertimbangkan untuk meningkatkan kapasitas stok secara bertahap untuk mengantisipasi pertumbuhan permintaan di masa mendatang."
|
||||
elif total_change < -EPS:
|
||||
return "Atur pembelian grosir secukupnya agar modal usaha tidak tertahan terlalu lama di gudang."
|
||||
else:
|
||||
return "Permintaan dasar cenderung stabil. Lakukan pembelian persediaan barang dengan jumlah reguler."
|
||||
|
||||
|
||||
def weekly_insight_text(pola: pd.Series) -> str:
|
||||
pola_valid = pola.dropna() # Pola mingguan yang kita pilih untuk diberikan insight, buang hari yang tidak ada datanya. Terus gimana yang dibuang, jadi sisanya ga informatif dong? OH TIDAK ini sedikit beda dengan compute_highlight_bullets yang maksa ambil 1 minggu. Disini kan dekomposisi jadi kita atur dengan banyak pengkondisian yang nangani, misal kosong, hanya 1 hari, lonjakan=jurang atau tidak.
|
||||
if pola_valid.empty:
|
||||
return "Tidak tersedia untuk skenario ini."
|
||||
if len(pola_valid) == 1:
|
||||
only_day = pola_valid.index[0]
|
||||
return (
|
||||
f"Periode ini cuma mencakup 1 hari (<b>{only_day}</b>), jadi belum bisa dibandingkan "
|
||||
"dengan hari lain dalam seminggu."
|
||||
)
|
||||
peak_day = pola_valid.idxmax()
|
||||
low_day = pola_valid.idxmin()
|
||||
if peak_day == low_day:
|
||||
return "Pada periode ini, pola mingguan relatif merata (tidak ada hari yang menonjol jauh lebih tinggi atau rendah dari yang lain)."
|
||||
return (
|
||||
f"Pastikan stok mencukupi menjelang hari <b>{peak_day}</b>. "
|
||||
f"Jadwalkan pengisian/restock barang pada hari sepi (<b>{low_day}</b>)."
|
||||
)
|
||||
|
||||
|
||||
def _trend_direction(values) -> str: #
|
||||
"""
|
||||
ini untuk yearly_insight_text, untuk menentukan arah keseluruhan dari
|
||||
pola musiman tahunan (naik/turun/stabil) berdasarkan semua
|
||||
titik data, bukan cuma awal vs akhir.
|
||||
|
||||
Tentukan arah keseluruhan (meningkat/menurun/relatif stabil) dari
|
||||
SELURUH bentuk kurva -- pakai garis tren (regresi linear sederhana,
|
||||
least-squares) yang mempertimbangkan SEMUA titik, bukan cuma
|
||||
membandingkan titik paling awal vs titik paling akhir. Dengan begini,
|
||||
kalau titik awal/akhirnya kebetulan cuma noise kecil yang tidak
|
||||
mewakili keseluruhan pola, kesimpulan arahnya tidak gampang salah.
|
||||
|
||||
Ambang "signifikan" dihitung relatif terhadap rentang naik-turun data
|
||||
itu sendiri (bukan angka mutlak tetap), supaya konsisten di skala data
|
||||
berapa pun -- sejalan dengan prinsip yang sama dipakai di seluruh
|
||||
modul ini (EPS untuk noise floating-point, ambang relatif untuk
|
||||
"apakah perubahan ini cukup berarti untuk disebut tren").
|
||||
"""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
return "relatif stabil"
|
||||
y = np.asarray(values, dtype=float)
|
||||
rentang = y.max() - y.min()
|
||||
if rentang <= 1e-9:
|
||||
return "relatif stabil"
|
||||
x = np.arange(n)
|
||||
slope = np.polyfit(x, y, 1)[0]
|
||||
total_perubahan_tren = slope * (n - 1)
|
||||
if total_perubahan_tren > 0.05 * rentang:
|
||||
return "meningkat"
|
||||
if total_perubahan_tren < -0.05 * rentang:
|
||||
return "menurun"
|
||||
return "relatif stabil"
|
||||
|
||||
|
||||
def yearly_insight_text(y_vals) -> str:
|
||||
if len(y_vals) < 2:
|
||||
val = y_vals[0] if len(y_vals) else 0
|
||||
if val > 1e-4:
|
||||
return "Pada tanggal ini, pola musiman tahunan memberikan <b>efek kenaikan</b> terhadap tren dasar penjualan."
|
||||
if val < -1e-4:
|
||||
return "Pada tanggal ini, pola musiman tahunan memberikan <b>efek penurunan</b> terhadap tren dasar penjualan."
|
||||
return "Pada tanggal ini, pola musiman tahunan relatif netral (tidak menambah atau mengurangi tren dasar secara signifikan)."
|
||||
|
||||
|
||||
"""
|
||||
- .diff() = fungsi pandas yang menghitung selisih setiap elemen dengan elemen sebelumnya. Contoh: y_vals = [10, 12, 15, 13, 11] → hasil .diff() = [NaN, 2, 3, -2, -2] (elemen pertama selalu NaN, karena tidak ada "sebelumnya" untuk dikurangi).
|
||||
- .dropna() → buang NaN di awal itu → [2, 3, -2, -2].
|
||||
- Jadi diffs = daftar kenaikan/penurunan antar hari berturut-turut.
|
||||
"""
|
||||
diffs = pd.Series(y_vals).diff().dropna().values
|
||||
signs = [1 if d > 1e-6 else (-1 if d < -1e-6 else 0) for d in diffs]
|
||||
|
||||
"""
|
||||
untuk setiap nilai d di diffs, ubah jadi tanda saja: 1 kalau naik (d > 1e-6, ambang kecil untuk toleransi noise), -1 kalau turun, 0 kalau nyaris tidak berubah.
|
||||
Untuk contoh kita (diffs = [2, 3, -2, -2]) → signs = [1, 1, -1, -1].
|
||||
"""
|
||||
signs_nz = [s for s in signs if s != 0]
|
||||
|
||||
"""
|
||||
ini membandingkan nilai dengan nilai sebelumnya, tapi juga menghitung berapa kali arah berubah (turning points) di sepanjang kurva (kalau beda berarti tambah poin turning).
|
||||
Contoh: signs_nz = [1, 1, -1, -1] → turning_points = 1 (karena ada satu kali perubahan dari naik ke turun).
|
||||
"""
|
||||
turning_points = sum(1 for i in range(1, len(signs_nz)) if signs_nz[i] != signs_nz[i - 1])
|
||||
|
||||
if not signs_nz:
|
||||
return "Pola tahunan relatif <b>stabil/datar</b> sepanjang periode ini (tidak memberikan efek kenaikan/penurunan signifikan)."
|
||||
if turning_points == 0:
|
||||
if signs_nz[0] > 0:
|
||||
return "Pada periode ini, pola musiman tahunan sedang <b>naik konsisten</b>. Siapkan stok ekstra secara bertahap untuk mengantisipasi kenaikan permintaan."
|
||||
return "Pada periode ini, pola musiman tahunan <b>turun konsisten</b>. Sesuaikan pesanan pasokan barang agar tidak terjadi penumpukan stok."
|
||||
if turning_points == 1:
|
||||
if signs_nz[0] > 0:
|
||||
return "Pada periode ini, pola musiman tahunan <b>sempat meningkat lalu mulai melandai/menurun</b> menjelang akhir periode. Jika ingin menambah stok, lakukan di awal periode."
|
||||
return "Pada periode ini, pola musiman tahunan <b>sempat menurun lalu mulai bergerak naik</b> menjelang akhir periode. Pertimbangkan bersiap menambah pasokan kembali."
|
||||
|
||||
arah_netto = _trend_direction(y_vals)
|
||||
return (
|
||||
f"Pada periode ini, pola musiman tahunan <b>cukup fluktuatif (naik-turun)</b>. "
|
||||
f"Secara keseluruhan dari awal hingga akhir periode arahnya <b>{arah_netto}</b>."
|
||||
)
|
||||
|
||||
|
||||
def holiday_insight_bullets(hol: pd.DataFrame, hol_cols: list[str], holidays_df) -> list[str]:
|
||||
"""
|
||||
Insight efek event/hari libur -- dihitung PER EVENT (per kolom komponen
|
||||
Prophet), dengan 2 prinsip inti (revisi v6):
|
||||
|
||||
1. [PALING PENTING] Pencarian puncak/lembah & "kapan efek mereda ke
|
||||
normal" SEKARANG DIBATASI KETAT ke jendela aktif event itu sendiri
|
||||
(`lower_window`..`upper_window` dari `holidays_df`), bukan mencari
|
||||
bebas di SELURUH periode laporan. Dan "kembali normal" dihitung
|
||||
PASTI dari `upper_window` (persis H+{upper_window}+1), BUKAN
|
||||
ditebak dari data yang "kelihatannya sudah mendekati nol".
|
||||
|
||||
Kenapa ini krusial: sebelumnya sistem mencari titik ekstrem &
|
||||
"baseline" di SELURUH data yang tersedia (bisa jauh melampaui
|
||||
window event sungguhan), sehingga muncul hasil yang tidak masuk
|
||||
akal seperti "kembali normal H+29/H+40/H+45" padahal window
|
||||
event itu jauh lebih pendek dari itu -- dan titik puncak/lembah
|
||||
yang salah karena kecampur data di luar window yang seharusnya
|
||||
tidak relevan sama sekali untuk event tersebut.
|
||||
|
||||
Konsekuensi dari batasan ini:
|
||||
- Kalau periode laporan BERAKHIR SEBELUM window event ini selesai,
|
||||
sistem TIDAK mengklaim "sudah kembali normal" (karena memang
|
||||
belum tentu benar) -- cukup deskripsikan pola yang terlihat.
|
||||
- Kalau periode laporan mencakup akhir window event ini, baru
|
||||
ditambahkan kalimat "kembali normal pada H+{upper_window+1}"
|
||||
yang PASTI benar (bukan tebakan), karena itu bawaan definisi
|
||||
window Prophet: di luar window, efeknya otomatis nol.
|
||||
|
||||
2. Variasi kata sambung ("Setelah itu" / "Kemudian" / "Selanjutnya")
|
||||
dipakai bergantian untuk pola 3 segmen, supaya tidak mengulang kata
|
||||
yang sama berkali-kali dalam 1 kalimat.
|
||||
|
||||
3. [Fix v7] Suatu titik HANYA boleh disebut "puncak" (perlu siapkan
|
||||
stok) kalau nilainya BENERAN > 0, dan HANYA boleh disebut "titik
|
||||
terendah" (perlu kurangi stok) kalau nilainya BENERAN < 0 -- dicek
|
||||
dari TANDA nilainya langsung, bukan dari seberapa dekat/jauh
|
||||
nilainya terhadap skala data periode ini.
|
||||
|
||||
4. [Fix v8a -- titik ekstrem sesungguhnya] Tanggal "puncak"/"titik
|
||||
terendah" yang dilaporkan untuk tiap segmen sekarang dicari sebagai
|
||||
titik ekstrem SESUNGGUHNYA di dalam seluruh rentang segmen itu
|
||||
(bukan cuma nilai di titik batas hasil penggabungan titik balik
|
||||
kecil). Kalau ada wobble kecil ikut tergabung ke dalam 1 segmen,
|
||||
tanggal yang dilaporkan tetap yang BENAR-BENAR paling tinggi/rendah.
|
||||
|
||||
5. [Fix v8b -- "turun" tidak wajib nembus negatif] Beda dengan "naik"
|
||||
(yang wajib nilainya > 0 supaya genuine), fase "turun" dianggap
|
||||
genuine juga kalau segmen itu turun DARI titik yang sebelumnya
|
||||
genuine positif (dari[1] > EPS) -- walau titik akhirnya belum
|
||||
sempat < 0. Alasannya: "menurun dari puncak yang baru tinggi" tetap
|
||||
kabar bisnis nyata (kurangi stok bertahap), terutama kalau periode
|
||||
laporan terpotong sebelum sempat menembus negatif -- tanpa ini,
|
||||
penurunan pasca-puncak yang jelas kelihatan di grafik malah tidak
|
||||
dinarasikan sama sekali.
|
||||
|
||||
6. [Fix v8c -- kondisi awal yang sudah notable] Kalau segmen PERTAMA
|
||||
yang dianalisis ternyata sudah mulai dari nilai yang notable
|
||||
(tinggi/rendah), bukan dari baseline -- itu tandanya efeknya SUDAH
|
||||
ada sejak awal rentang yang dianalisis (mis. plateau tinggi sebelum
|
||||
Hari H yang baru anjlok setelahnya). Kondisi awal itu disebutkan
|
||||
dulu sebagai konteks, supaya tidak langsung lompat ke "Setelah Hari
|
||||
X, menurun..." padahal ceritanya sebenarnya sudah dimulai dari
|
||||
SEBELUM Hari X.
|
||||
|
||||
Klasifikasi bentuk kurva (naik/turun/naik-turun/turun-naik, sampai 2
|
||||
pembalikan arah) tetap memakai deteksi berbasis signifikansi (>=15%
|
||||
dari rentang data) seperti revisi sebelumnya -- supaya wobble kecil
|
||||
tidak dianggap pembalikan arah sungguhan.
|
||||
"""
|
||||
EPS = 1e-6
|
||||
SIGNIFIKAN = 0.15 #ini ambang untuk menentukan apakah ada "pembalikan arah sungguhan" (mirip konsep 0.05 * rentang di _trend_direction() sebelumnya, tapi di sini pakai 15%, lebih ketat, karena tujuannya beda — bukan cuma "naik/turun keseluruhan" tapi "berapa kali kurva ini beneran berbelok"
|
||||
CONNECTORS = ["Setelah itu", "Kemudian", "Selanjutnya"]
|
||||
|
||||
period_start = hol["ds"].min().date()
|
||||
period_end = hol["ds"].max().date()
|
||||
|
||||
def _find_occurrence(col: str): # col = nama event (misal: "idul_fitri")
|
||||
"""Cari kejadian (occurrence) event ini, ada ga di data hari libur model. Return (hari_h, lower_window, upper_window)
|
||||
atau None kalau tidak ketemu / tidak terdaftar.
|
||||
|
||||
|
||||
_find_occurrence berarti isinya uda nama hari libur, baru didalamnya ada seleksi tanggalnya, nanti cocokin sesuai tanggal apakah masuk kedalam yang dipilih kita untuk diramal, kalau uda pas baru return tanggal hari libur, lw, uw.
|
||||
Kalau detail jika diurut begini loh alurnya:
|
||||
|
||||
1. _find_occurrence(col) menerima nama event (col, misal "idul_fitri") sebagai parameter
|
||||
2. Di dalamnya, dia cari baris-baris di holidays_df yang namanya cocok dengan col itu (bisa lebih dari 1 baris, karena event yang sama muncul tiap tahun)
|
||||
3. Untuk tiap baris itu, dihitung rentang window-nya (win_start, win_end)
|
||||
4. Dicocokkan: apakah window itu beririsan dengan periode yang mau diramal/dilaporkan?
|
||||
5. Begitu ketemu yang cocok, langsung return tanggal Hari-H, lower_window, upper_window milik kejadian itu
|
||||
6. Kalau tidak ada satupun yang cocok, return None
|
||||
"""
|
||||
if holidays_df is None or holidays_df.empty: # ini cek dulu ada data hari libur model atau tidak, kalau ga ada ya langsung return None
|
||||
return None
|
||||
for _, row in holidays_df[holidays_df["holiday"] == col].iterrows():
|
||||
ev_date = pd.to_datetime(row["ds"]).date()
|
||||
lw = row["lower_window"] if "lower_window" in row else 0
|
||||
uw = row["upper_window"] if "upper_window" in row else 0
|
||||
lw = int(lw) if pd.notna(lw) else 0
|
||||
uw = int(uw) if pd.notna(uw) else 0
|
||||
win_start = ev_date + datetime.timedelta(days=lw)
|
||||
win_end = ev_date + datetime.timedelta(days=uw)
|
||||
if win_end >= period_start and win_start <= period_end: # ini apakah window event bersinggungan dengan salah satu hari dalam hari libur model? kalau ya, return tanggal puncak event + windownya
|
||||
return ev_date, lw, uw
|
||||
return None
|
||||
|
||||
# label hari H / H-{n} / H+{n} untuk narasi
|
||||
def _offset_label(tanggal: datetime.date, hari_h) -> str | None: # buat label H-{n} / H+{n} / Hari H.
|
||||
if hari_h is None:
|
||||
return None
|
||||
diff = (tanggal - hari_h).days
|
||||
if diff == 0:
|
||||
return "Hari H"
|
||||
return f"H-{abs(diff)}" if diff < 0 else f"H+{diff}"
|
||||
|
||||
def _fmt(tanggal: datetime.date, hari_h) -> str:
|
||||
lbl = _offset_label(tanggal, hari_h) # SIAP PAKAI menggunakan fungsi _offset_label untuk menentukan TANGGAL KEMUDIA DIIKUTI label H-{n} / H+{n} / Hari H.
|
||||
tgl_str = tanggal.strftime("%d-%m-%Y")
|
||||
return f"<b>{tgl_str}</b> (<b>{lbl}</b>)" if lbl else f"<b>{tgl_str}</b>"
|
||||
|
||||
# sebagai kalimat pembuka sebelum menyambung ke cerita "naik/turun/puncak" di badan paragraf.
|
||||
def _intro(nama: str, hari_h, hari_h_str, acuan_tanggal) -> str:
|
||||
if hari_h is None:
|
||||
return f"Terkait event <b>{nama}</b>,"
|
||||
diff = (acuan_tanggal - hari_h).days
|
||||
if diff < 0:
|
||||
return f"Menjelang Hari <b>{nama}</b> ({hari_h_str}),"
|
||||
if diff == 0:
|
||||
return f"Pada Hari <b>{nama}</b> ({hari_h_str}),"
|
||||
return f"Setelah Hari <b>{nama}</b> ({hari_h_str}),"
|
||||
|
||||
|
||||
"""
|
||||
_significant_turns() = "pembaca bentuk kurva" — dia melihat sederet angka (nilai efek event per hari),
|
||||
lalu menyimpulkan pola bentuknya seperti apa: naik terus, naik-turun, turun-naik, dst.
|
||||
|
||||
Tapi dia tidak peduli wobble kecil yang tidak signifikan (misal cuma 1-2% dari rentang data), karena itu noise.
|
||||
Dia hanya mencatat titik balik yang benar-benar signifikan (lebih dari 15% dari rentang data)
|
||||
sebagai "pembalikan arah sungguhan" untuk menentukan pola kurva.
|
||||
"""
|
||||
def _significant_turns(values) -> list[int] | None:
|
||||
n = len(values) # CONTOH values = [2, 5, 9, 6, 3, 4, 7]
|
||||
total_range = values.max() - values.min() # 9 - 2 = 7
|
||||
if n < 3 or total_range <= EPS: # KITA LOLOS
|
||||
return []
|
||||
|
||||
diffs = np.diff(values) # .diff(): selisih antar elemen berurutan. Maka, diffs = [3, 4, -3, -3, 1, 3]
|
||||
|
||||
signs = np.where(diffs > EPS, 1, np.where(diffs < -EPS, -1, 0)) # np.where(kondisi, jika_true, jika_false). Maka, signs = [1, 1, -1, -1, 1, 1] (1=naik, -1=turun, 0=stabil)
|
||||
|
||||
"""
|
||||
enumerate(signs) → menghasilkan pasangan (index, nilai) ((pasangan: posisi titik, tandanya))
|
||||
untuk tiap elemen: (0,1), (1,1), (2,-1), (3,-1), (4,1), (5,1).
|
||||
i + 1 → digeser +1 karena diffs[i] itu mewakili "perubahan dari titik i ke titik i+1",
|
||||
jadi lebih pas dicatat sebagai posisi titik ke-(i+1) (titik tujuannya), bukan titik asalnya.
|
||||
"""
|
||||
nz = [(i + 1, s) for i, s in enumerate(signs) if s != 0] # ingat s/sign = 0 ITU DILARANG JADI KALAU 0 MAKA DIBUANG. Di contoh kita (tidak ada yang 0), hasilnya: nz = [(1,1), (2,1), (3,-1), (4,-1), (5,1), (6,1)]
|
||||
if not nz: # KITA LOLOS
|
||||
return []
|
||||
|
||||
|
||||
"""
|
||||
Bandingkan tanda posisi k dengan posisi k-1, kalau beda, catat posisi sebelumnya (nz[k-1][0]) sebagai titik balik.
|
||||
Jalankan untuk contoh kita:
|
||||
|
||||
k=1: nz[1]=(2,1) vs nz[0]=(1,1) → sama, skip
|
||||
k=2: nz[2]=(3,-1) vs nz[1]=(2,1) → beda! catat nz[1][0] = 2
|
||||
k=3: nz[3]=(4,-1) vs nz[2]=(3,-1) → sama, skip
|
||||
k=4: nz[4]=(5,1) vs nz[3]=(4,-1) → beda! catat nz[3][0] = 4
|
||||
k=5: nz[5]=(6,1) vs nz[4]=(5,1) → sama, skip
|
||||
|
||||
Maka hasil raw_turns = [2, 4]
|
||||
posisi index 2 (nilai 9) dan posisi index 4 (nilai 3) adalah titik-titik balik mentah (belum difilter signifikansinya).
|
||||
"""
|
||||
raw_turns = []
|
||||
for k in range(1, len(nz)):
|
||||
if nz[k][1] != nz[k - 1][1]:
|
||||
raw_turns.append(nz[k - 1][0])
|
||||
if not raw_turns:
|
||||
return []
|
||||
|
||||
boundaries = [0] + raw_turns + [n - 1]
|
||||
# boundaries = [0, 2, 4, 6]. Ini bikin daftar batas: titik awal (0), semua titik balik (2, 4), titik akhir (6). Jadi ada 3 segmen: [0-2], [2-4], [4-6] — sesuai bentuk kurva "naik-turun-naik" tadi.
|
||||
|
||||
"""
|
||||
disini permainannya: kita mau ngecek tiap segmen [left, mid, right] (misal [0,2,4])
|
||||
apakah perubahan dari left ke mid dan dari mid ke right itu signifikan (lebih dari 15% dari rentang data).
|
||||
Kalau salah satu tidak signifikan, hapus titik mid itu (anggapnya cuma wobble kecil),
|
||||
dan ulangi proses sampai tidak ada lagi yang bisa dihapus.
|
||||
"""
|
||||
changed = True
|
||||
while changed and len(boundaries) > 2:
|
||||
changed = False
|
||||
for i in range(1, len(boundaries) - 1):
|
||||
left, mid, right = boundaries[i - 1], boundaries[i], boundaries[i + 1]
|
||||
leg1 = abs(values[mid] - values[left])
|
||||
leg2 = abs(values[right] - values[mid])
|
||||
# --- [Fix v10] Ambang signifikan dihitung dari rentang
|
||||
# LOKAL segmen [left, right] yang sedang dievaluasi, BUKAN
|
||||
# dari rentang total seluruh periode yang dianalisis. Kalau
|
||||
# pakai rentang total, wobble kecil di satu bagian kurva
|
||||
# bisa dianggap signifikan atau tidak signifikan TERGANTUNG
|
||||
# ada/tidaknya lonjakan besar yang tidak berhubungan di
|
||||
# bagian lain kurva (mis. crash tajam pasca Idulfitri
|
||||
# membuat wobble di awal Ramadhan yang identik jadi
|
||||
# "kelihatan kecil" padahal datanya sendiri sama persis) --
|
||||
# menyebabkan hasil klasifikasi berubah-ubah hanya karena
|
||||
# periode laporan diperpanjang/dipendekkan, walau bagian
|
||||
# data yang dievaluasi itu sendiri tidak berubah.
|
||||
local_range = values[left:right + 1].max() - values[left:right + 1].min()
|
||||
if local_range <= EPS:
|
||||
local_range = total_range
|
||||
if leg1 < SIGNIFIKAN * local_range or leg2 < SIGNIFIKAN * local_range:
|
||||
boundaries.pop(i)
|
||||
changed = True
|
||||
break
|
||||
|
||||
turns = boundaries[1:-1]
|
||||
return turns if len(turns) <= 2 else None
|
||||
|
||||
events: list[tuple] = []
|
||||
|
||||
for col in hol_cols:
|
||||
series_full = hol[col].reset_index(drop=True)
|
||||
tanggal_full = hol["ds"].reset_index(drop=True).dt.date
|
||||
|
||||
occ = _find_occurrence(col)
|
||||
|
||||
"""
|
||||
for col in hol_cols: ← loop ini cuma "pilih 1 nama event dulu"
|
||||
(di dalamnya, UNTUK NAMA EVENT ITU):
|
||||
1. ambil SEMUA nilai & tanggal event ini (series_full, tanggal_full)
|
||||
2. cari window aktifnya (_find_occurrence)
|
||||
3. filter (mask) → ambil yang beneran relevan (irisan window & periode ramalan)
|
||||
4. hasil filter = series, tanggal ← INI yang "nilai aktif dalam periode ramalan"
|
||||
5. proses lebih lanjut (cari titik ekstrem, dst) untuk event INI SAJA
|
||||
(lalu loop lanjut ke event BERIKUTNYA, ulangi langkah 1-5 untuk event itu)
|
||||
"""
|
||||
if occ is not None:
|
||||
hari_h, lw, uw = occ
|
||||
"""
|
||||
win_start = 2026-03-17 (window event)
|
||||
win_end = 2026-03-24 (window event)
|
||||
period_start = 2026-03-20 (periode laporan)
|
||||
period_end = 2026-03-30 (periode laporan)
|
||||
|
||||
batas_awal = max(17 Maret, 20 Maret) = 20 Maret
|
||||
batas_akhir = min(24 Maret, 30 Maret) = 24 Maret
|
||||
"""
|
||||
win_start = hari_h + datetime.timedelta(days=lw)
|
||||
win_end = hari_h + datetime.timedelta(days=uw)
|
||||
# >>> Batasi analisis HANYA ke irisan [window event] x [periode laporan/yg ingin dilamar] <<<
|
||||
batas_awal = max(win_start, period_start)
|
||||
batas_akhir = min(win_end, period_end)
|
||||
mask = (tanggal_full >= batas_awal) & (tanggal_full <= batas_akhir)
|
||||
else:
|
||||
hari_h, hari_h_str, win_end = None, None, None
|
||||
mask = pd.Series([True] * len(series_full))
|
||||
|
||||
# series dan tanggal ini nanti ambil nilai dan tanggalnya yang beneran relevan (irisan window event & periode ingin diramal) untuk dianalisis lebih lanjut
|
||||
series = series_full[mask].reset_index(drop=True)
|
||||
tanggal = tanggal_full[mask].reset_index(drop=True)
|
||||
if len(series) == 0:
|
||||
continue
|
||||
|
||||
v_max, v_min = series.max(), series.min()
|
||||
if v_max <= EPS and v_min >= -EPS:
|
||||
continue # tidak ada efek nyata dari event ini di dalam window/periode ini
|
||||
|
||||
nama = col.replace("_", " ").title()
|
||||
hari_h_str = hari_h.strftime("%d-%m-%Y") if hari_h else None
|
||||
|
||||
# "Kembali normal" HANYA diklaim kalau periode laporan benar-benar
|
||||
# mencakup akhir window event ini (bukan tebakan dari data).
|
||||
tanggal_normal = None
|
||||
if occ is not None and period_end >= win_end:
|
||||
tanggal_normal = win_end + datetime.timedelta(days=1)
|
||||
|
||||
if len(series) < 2:
|
||||
t_only = tanggal.iloc[0]
|
||||
v_only = series.iloc[0]
|
||||
arah = "meningkatkan" if v_only > 0 else "menurunkan"
|
||||
teks = f"Pada {_fmt(t_only, hari_h)}, event <b>{nama}</b> diperkirakan {arah} permintaan dibanding tren dasar."
|
||||
if tanggal_normal:
|
||||
teks += f" Efek ini diperkirakan kembali normal pada {_fmt(tanggal_normal, hari_h)}."
|
||||
events.append((hari_h or t_only, teks))
|
||||
continue
|
||||
|
||||
"""
|
||||
ini jika ada 2 titik atau lebih, kita cek dulu apakah ada titik balik yang signifikan (lebih dari 15% dari rentang data) di antara titik-titik itu. Kalau tidak ada, berarti kurvanya cuma naik terus atau turun terus, jadi kita bisa langsung simpulkan arah keseluruhan. Kalau ada titik balik signifikan, kita lanjutkan analisis untuk menentukan pola naik-turun atau turun-naik. ATAU BAHKAN TERLALU FLUKTUATIF (lebih dari 2 titik balik signifikan) sehingga kita tidak bisa simpulkan pola yang jelas.
|
||||
"""
|
||||
turns = _significant_turns(series.values)
|
||||
|
||||
if turns is None:
|
||||
idx_max, idx_min = int(series.idxmax()), int(series.idxmin())
|
||||
t_start, t_end = tanggal.iloc[0], tanggal.iloc[-1]
|
||||
arah_netto = _trend_direction(series.values)
|
||||
# Anotasi aksi (siapkan/kurangi stok) HANYA ditambahkan kalau
|
||||
# titik tertinggi/terendahnya beneran melewati 0 -- sama seperti
|
||||
# cek sign-based di kasus 1-2 titik balik, supaya konsisten.
|
||||
aksi_max = " (siapkan stok tambahan)" if series.iloc[idx_max] > EPS else ""
|
||||
aksi_min = " (kurangi pasokan stok)" if series.iloc[idx_min] < -EPS else ""
|
||||
teks = (
|
||||
f"Permintaan terkait <b>{nama}</b>{f' ({hari_h_str})' if hari_h_str else ''} "
|
||||
f"<b>berfluktuasi (naik-turun)</b> sepanjang periode ini. Secara keseluruhan dari "
|
||||
f"{_fmt(t_start, hari_h)} sampai {_fmt(t_end, hari_h)}, arahnya <b>{arah_netto}</b>. "
|
||||
f"Titik terendah di {_fmt(tanggal.iloc[idx_min], hari_h)}{aksi_min} dan "
|
||||
f"titik tertinggi di {_fmt(tanggal.iloc[idx_max], hari_h)}{aksi_max} "
|
||||
f" -- gunakan sebagai acuan penyesuaian stok harian."
|
||||
)
|
||||
if tanggal_normal:
|
||||
teks += f" Efek ini diperkirakan kembali normal pada {_fmt(tanggal_normal, hari_h)}."
|
||||
events.append((hari_h or tanggal.iloc[idx_max], teks))
|
||||
continue
|
||||
|
||||
|
||||
"""
|
||||
"mengecek ulang" LAGI LAGI LAGI hasil _significant_turns(), memastikan titik-titik balik yang ditemukan benar-benar valid (tidak ada 2 segmen berturutan yang arahnya kebetulan sama, yang seharusnya digabung jadi 1).
|
||||
"""
|
||||
anchor_idxs = [0] + turns + [len(series) - 1]
|
||||
|
||||
# Pastikan arah antar segmen BENAR-BENAR berselang-seling (naik-turun-
|
||||
# naik atau turun-naik-turun). Kalau ternyata 2 segmen berturutan
|
||||
# arahnya SAMA (residu dari proses penggabungan titik balik yang
|
||||
# tidak sempurna), gabungkan jadi 1 segmen -- buang titik tengahnya,
|
||||
# supaya tidak muncul 2 kalimat "meningkat...meningkat lagi" yang
|
||||
# janggal dan redundan.
|
||||
cleaned = [anchor_idxs[0]]
|
||||
for idx in anchor_idxs[1:]:
|
||||
if len(cleaned) >= 2:
|
||||
arah_sebelum = series.iloc[cleaned[-1]] > series.iloc[cleaned[-2]]
|
||||
arah_sekarang = series.iloc[idx] > series.iloc[cleaned[-1]]
|
||||
if arah_sebelum == arah_sekarang:
|
||||
cleaned[-1] = idx
|
||||
continue
|
||||
cleaned.append(idx)
|
||||
anchor_idxs = cleaned
|
||||
points = [(tanggal.iloc[i], series.iloc[i]) for i in anchor_idxs]
|
||||
|
||||
clauses = []
|
||||
conn_i = 0
|
||||
|
||||
|
||||
"""
|
||||
INSIGHT PER SEGMENT:
|
||||
ini yang menentukan detail per fase:
|
||||
1. Di mana naiknya (segmen mana yang arahnya naik)
|
||||
2. Di mana turunnya (segmen mana yang arahnya turun)
|
||||
3. Titik ekstrem sesungguhnya di tiap fase itu (bukan cuma titik batasnya, tapi dicari ulang nilai paling tinggi/rendah di seluruh rentang segmen — ini yang [Fix v8a])
|
||||
4. Apakah perubahan itu "genuine"/sungguhan (layak dikasih saran aksi atau tidak — ini yang [Fix v8b])
|
||||
"""
|
||||
for seg_i in range(1, len(points)):
|
||||
lo_idx, hi_idx = anchor_idxs[seg_i - 1], anchor_idxs[seg_i]
|
||||
dari, ke_boundary = points[seg_i - 1], points[seg_i]
|
||||
naik = ke_boundary[1] > dari[1]
|
||||
|
||||
# --- [Fix v8a] Titik ekstrem yang DILAPORKAN untuk segmen ini
|
||||
# dicari sebagai TITIK EKSTREM SESUNGGUHNYA di DALAM seluruh
|
||||
# rentang segmen (bukan cuma nilai di titik batas hasil proses
|
||||
# penggabungan titik balik kecil). Tanpa ini, kalau ada wobble
|
||||
# kecil di tengah segmen yang ikut tergabung, tanggal "titik
|
||||
# terendah/tertinggi" yang dilaporkan bisa salah -- bukan
|
||||
# tanggal yang benar-benar paling rendah/tinggi. ---
|
||||
seg_slice = series.iloc[lo_idx:hi_idx + 1]
|
||||
seg_tgl = tanggal.iloc[lo_idx:hi_idx + 1]
|
||||
ext_pos = int(seg_slice.values.argmax() if naik else seg_slice.values.argmin())
|
||||
ke = (seg_tgl.iloc[ext_pos], seg_slice.iloc[ext_pos])
|
||||
|
||||
# --- [Fix v8b] Cek "genuine" TIDAK SIMETRIS antara naik & turun:
|
||||
# - "naik" HANYA genuine (perlu siapkan stok) kalau titik
|
||||
# ekstremnya BENERAN > 0 -- pulih dari negatif menuju nol
|
||||
# BUKAN lonjakan permintaan sungguhan (lihat v7).
|
||||
# - "turun" genuine (perlu kurangi stok) kalau titik ekstremnya
|
||||
# BENERAN < 0, ATAU kalau segmen ini turun DARI puncak yang
|
||||
# genuine (dari[1] > EPS) -- karena "menurun dari puncak yang
|
||||
# baru saja tinggi" tetap kabar bisnis yang nyata & perlu
|
||||
# direspons (kurangi stok bertahap), walau belum sempat
|
||||
# menembus di bawah nol. Tanpa ini, penurunan dari puncak yang
|
||||
# terpotong periode laporan (belum sempat sampai negatif)
|
||||
# malah tidak dinarasikan sama sekali. ---
|
||||
genuine = (naik and ke[1] > EPS) or ((not naik) and (ke[1] < -EPS or dari[1] > EPS))
|
||||
|
||||
if not genuine:
|
||||
is_last_segment = seg_i == len(points) - 1
|
||||
if tanggal_normal is not None and is_last_segment:
|
||||
# Segmen terakhir yang tidak genuine ini PERSIS sama
|
||||
# ceritanya dengan kalimat "kembali normal" di penutup
|
||||
# (sama-sama mendeskripsikan proses pulih menuju 0,
|
||||
# cuma dipotong di titik data terakhir yang kebetulan
|
||||
# tersedia) -- jangan disebut dobel, biarkan kalimat
|
||||
# penutup (arah_pulih di bawah) yang menanganinya,
|
||||
# supaya tidak ada 2 kalimat berturut-turut bilang
|
||||
# "meningkat"/"menurun" untuk pergerakan yang sama.
|
||||
continue
|
||||
# --- [Fix v11] Segmen ini BUKAN puncak/lembah bisnis
|
||||
# sungguhan (belum tembus sisi lain angka 0), TAPI
|
||||
# pergerakannya tetap nyata dan tetap disebutkan -- supaya
|
||||
# tidak ada bagian penting dari perjalanan grafik yang
|
||||
# dilewatkan diam-diam tanpa penjelasan (mis. tren naik
|
||||
# yang berlangsung berhari-hari sebelum akhirnya ada dip
|
||||
# kecil di ujung -- tren naiknya tetap cerita utama yang
|
||||
# perlu disebut, bukan cuma dip-nya). Bedanya dengan
|
||||
# segmen genuine: TIDAK ada saran aksi stok (siapkan/
|
||||
# kurangi), karena memang belum ada dampak bisnis nyata
|
||||
# (nilainya masih di sisi yang sama dari kondisi normal).
|
||||
verb_soft = "meningkat" if naik else "menurun"
|
||||
if not clauses:
|
||||
intro = _intro(nama, hari_h, hari_h_str, ke[0])
|
||||
clauses.append(
|
||||
f"{intro} permintaan <b>{verb_soft}</b> hingga {_fmt(ke[0], hari_h)}. "
|
||||
# f"meski masih di sisi yang sama dari kondisi normal."
|
||||
)
|
||||
else:
|
||||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||||
conn_i += 1
|
||||
clauses.append(
|
||||
f"{connector}, permintaan <b>{verb_soft}</b> hingga {_fmt(ke[0], hari_h)}."
|
||||
)
|
||||
continue
|
||||
|
||||
if naik:
|
||||
verb, aksi, frasa = "meningkat", "siapkan stok tambahan menjelang tanggal tersebut", "dengan puncak"
|
||||
else:
|
||||
verb, aksi, frasa = "menurun", "kurangi pasokan stok pada tanggal tersebut", "hingga titik terendah"
|
||||
|
||||
if not clauses:
|
||||
# --- [Fix v8c] Kalau segmen PERTAMA ini sudah mulai dari
|
||||
# nilai yang genuine notable (tinggi/rendah), bukan dari
|
||||
# baseline -- itu artinya efeknya SUDAH ada sejak awal
|
||||
# rentang yang dianalisis (mis. plateau tinggi sebelum
|
||||
# Hari H yang lalu anjlok). Sebutkan dulu kondisi awal itu
|
||||
# sebagai konteks, supaya tidak langsung lompat ke "Setelah
|
||||
# Hari X, menurun..." padahal ceritanya sebenarnya dimulai
|
||||
# dari SEBELUM Hari X. ---
|
||||
# --- [Fix v8c] Kalau segmen PERTAMA ini sudah mulai dari
|
||||
# nilai yang SEBANDING besarnya dengan titik ekstrem yang
|
||||
# dilaporkan (bukan cuma "sedikit di atas nol" seperti
|
||||
# awal ramp yang wajar) -- itu tandanya efeknya SUDAH ada
|
||||
# sejak awal rentang yang dianalisis (mis. plateau tinggi
|
||||
# sebelum Hari H yang baru anjlok setelahnya). Sebutkan
|
||||
# kondisi awal itu dulu sebagai konteks, supaya tidak
|
||||
# langsung lompat ke "Setelah Hari X, menurun..." padahal
|
||||
# ceritanya sebenarnya dimulai dari SEBELUM Hari X.
|
||||
# Pembanding pakai skala terhadap titik ekstrem (bukan EPS
|
||||
# mentah) supaya titik awal ramp yang wajar -- yang
|
||||
# nilainya emang jauh lebih kecil dari puncaknya -- tidak
|
||||
# ikut ke-flag.
|
||||
awal_notable = abs(dari[1]) > EPS and abs(dari[1]) >= 0.5 * abs(ke[1])
|
||||
if awal_notable and dari[0] != ke[0]:
|
||||
awal_kata = "tinggi" if dari[1] > EPS else "rendah"
|
||||
intro = _intro(nama, hari_h, hari_h_str, dari[0])
|
||||
clauses.append(
|
||||
f"{intro} permintaan terkait <b>{nama}</b> sudah <b>{awal_kata}</b> sejak "
|
||||
f"{_fmt(dari[0], hari_h)}. Kemudian, permintaan <b>{verb}</b> {frasa} pada "
|
||||
f"{_fmt(ke[0], hari_h)} — {aksi}."
|
||||
)
|
||||
else:
|
||||
intro = _intro(nama, hari_h, hari_h_str, ke[0])
|
||||
clauses.append(f"{intro} permintaan <b>{verb}</b> {frasa} pada {_fmt(ke[0], hari_h)} — {aksi}.")
|
||||
else:
|
||||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||||
conn_i += 1
|
||||
frasa2 = "hingga puncaknya" if naik else "hingga titik terendah"
|
||||
clauses.append(f"{connector}, permintaan <b>{verb}</b> {frasa2} pada {_fmt(ke[0], hari_h)} — {aksi}.")
|
||||
|
||||
# --- [Fix v9] Arah kalimat "kembali normal" sekarang eksplisit
|
||||
# menyebut arah PERGERAKAN TITIK-PER-TITIKNYA (meningkat/menurun),
|
||||
# bukan cuma kata generik "berangsur mereda" -- supaya kelihatan
|
||||
# jelas bahwa fase pemulihan ini SUNGGUHAN bergerak naik (kalau
|
||||
# datang dari titik terendah negatif) atau menurun (kalau datang
|
||||
# dari puncak positif), walau nilainya sendiri belum/tidak sempat
|
||||
# tembus ke sisi lain angka 0. Klasifikasi "genuine puncak/lembah"
|
||||
# (butuh aksi stok) TETAP berbasis tanda nilai seperti sebelumnya --
|
||||
# ini cuma menambahkan deskripsi ARAH pergerakannya, bukan mengubah
|
||||
# kapan aksi stok direkomendasikan.
|
||||
last_val = series.iloc[-1]
|
||||
if last_val < -EPS:
|
||||
arah_pulih = "meningkat"
|
||||
elif last_val > EPS:
|
||||
arah_pulih = "menurun"
|
||||
else:
|
||||
arah_pulih = "mereda"
|
||||
|
||||
if tanggal_normal:
|
||||
if clauses:
|
||||
connector = CONNECTORS[min(conn_i, len(CONNECTORS) - 1)]
|
||||
clauses.append(
|
||||
f"{connector}, efek <b>{nama}</b> berangsur <b>{arah_pulih}</b> menuju kondisi normal, "
|
||||
f"diperkirakan tercapai sekitar {_fmt(tanggal_normal, hari_h)}."
|
||||
)
|
||||
else:
|
||||
# Semua segmen lain ke-skip (kasus ekstrem: dari awal sampai
|
||||
# akhir cuma proses mereda) -- tetap kasih 1 kalimat wajar.
|
||||
intro = _intro(nama, hari_h, hari_h_str, tanggal_normal)
|
||||
clauses.append(
|
||||
f"{intro} efek permintaan berangsur <b>{arah_pulih}</b> menuju kondisi normal, "
|
||||
f"diperkirakan tercapai sekitar {_fmt(tanggal_normal, hari_h)}."
|
||||
)
|
||||
elif not clauses:
|
||||
# Tidak ada segmen genuine sama sekali & periode belum mencakup
|
||||
# akhir window (jadi tidak bisa klaim "kembali normal" juga) --
|
||||
# tetap kasih 1 kalimat faktual biar tidak kosong, plus arah
|
||||
# pergerakan titik-per-titiknya kalau memang ada pergerakan.
|
||||
t_start, t_end = tanggal.iloc[0], tanggal.iloc[-1]
|
||||
intro = _intro(nama, hari_h, hari_h_str, t_end)
|
||||
arah_txt = f" dengan kecenderungan <b>{arah_pulih}</b>" if arah_pulih != "mereda" else ""
|
||||
clauses.append(
|
||||
f"{intro} efek permintaan relatif kecil (belum benar-benar menyimpang dari kondisi normal){arah_txt} "
|
||||
f"antara {_fmt(t_start, hari_h)} sampai {_fmt(t_end, hari_h)}."
|
||||
)
|
||||
|
||||
teks = " ".join(clauses)
|
||||
anchor = anchor_idxs[1] if len(anchor_idxs) > 1 else anchor_idxs[0]
|
||||
events.append((hari_h or tanggal.iloc[anchor], teks))
|
||||
|
||||
if not events:
|
||||
return ["Operasional periode ini berjalan normal tanpa lonjakan hari libur/event."]
|
||||
|
||||
events.sort(key=lambda pair: pair[0])
|
||||
return [teks for _, teks in events]
|
||||
|
||||
|
||||
"""
|
||||
bagian agak rumit
|
||||
1. _significant_turns(series.values)
|
||||
→ DI SINI SEMUA PENGECEKAN FLUKTUASI TERJADI (cuma 1 kali, tuntas)
|
||||
→ hasilnya: None ATAU list posisi titik balik
|
||||
|
||||
2. if turns is None:
|
||||
→ CUMA BACA hasil di atas, PILIH jalur (bukan cek ulang)
|
||||
→ kalau None → Jalur A (kalimat generik, selesai)
|
||||
|
||||
3. anchor_idxs = [0] + turns + [...]
|
||||
cleaned = ...
|
||||
→ CUMA MERAPIKAN titik balik yang SUDAH DIKETAHUI jadi batas segmen
|
||||
→ BUKAN mengecek ulang apakah fluktuatif — itu sudah settled di langkah 1
|
||||
|
||||
4. for seg_i in range(...):
|
||||
→ BARU DI SINI bikin kalimat/insight per segmen
|
||||
"""
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
"""
|
||||
ui_components.py
|
||||
=================
|
||||
Komponen UI kecil yang dipakai berulang di beberapa halaman (marquee,
|
||||
badge status, header seksi), supaya gaya visual (warna, spacing) konsisten
|
||||
tanpa copy-paste HTML/CSS di setiap views/*.py.
|
||||
|
||||
Modul ini sengaja tidak menyimpan state apa pun -- murni fungsi yang
|
||||
menampilkan atau mengembalikan markup, mirip komponen "dumb" di React.
|
||||
Tidak ada dependensi ke utils/data_loader.py atau lainnya, supaya modul
|
||||
ini bisa dipakai di halaman mana pun tanpa risiko circular import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import datetime
|
||||
import streamlit as st
|
||||
|
||||
PRIMARY_COLOR = "#2563eb"
|
||||
|
||||
_BADGE_COLORS = {
|
||||
"ok": ("#dcfce7", "#166534"), # hijau -- sukses / optimal
|
||||
"warn": ("#fef3c7", "#92400e"), # kuning -- perhatian
|
||||
"error": ("#fee2e2", "#991b1b"), # merah -- gagal / invalid
|
||||
"neutral": ("#e5e7eb", "#374151"), # abu-abu -- info netral
|
||||
}
|
||||
|
||||
_BULAN_ID = {
|
||||
1: "Jan", 2: "Feb", 3: "Mar", 4: "Apr", 5: "Mei", 6: "Jun",
|
||||
7: "Jul", 8: "Agt", 9: "Sep", 10: "Okt", 11: "Nov", 12: "Des",
|
||||
}
|
||||
|
||||
|
||||
def strip_spasi_username(widget_key: str) -> None:
|
||||
"""
|
||||
Callback untuk parameter on_change di st.text_input(key=widget_key)
|
||||
pada kolom Username. Streamlit tidak bisa mencegah karakter tertentu
|
||||
diketik secara real-time (tidak ada event per-keystroke di Python),
|
||||
tapi callback ini otomatis jalan begitu kolom kehilangan fokus / user
|
||||
menekan Enter -- efeknya spasi yang sempat diketik langsung hilang
|
||||
tanpa perlu submit form dulu untuk melihat pesan error.
|
||||
|
||||
Contoh pakai:
|
||||
st.text_input(
|
||||
"Username", key="tu_username",
|
||||
on_change=strip_spasi_username, args=("tu_username",),
|
||||
)
|
||||
"""
|
||||
if widget_key in st.session_state:
|
||||
st.session_state[widget_key] = st.session_state[widget_key].replace(" ", "")
|
||||
|
||||
|
||||
def inject_global_css() -> None:
|
||||
"""
|
||||
CSS yang berlaku untuk SELURUH aplikasi, dipanggil SEKALI dari app.py
|
||||
(setelah st.set_page_config), bukan diulang-ulang di tiap halaman.
|
||||
|
||||
Isinya: sembunyikan toggle "show password" bawaan browser (Edge/IE)
|
||||
supaya tidak dobel dengan toggle mata bawaan Streamlit. Sebelumnya
|
||||
fix ini cuma ditulis di dalam render_login_form(), jadi cuma
|
||||
berlaku di halaman login -- begitu ada form password baru (Edit
|
||||
Profil, Tambah/Edit User di Manajemen User), bug icon mata dobel
|
||||
muncul lagi di situ karena CSS-nya tidak ikut kepakai. Dengan
|
||||
dipindah ke sini dan dipanggil sekali di app.py, otomatis berlaku
|
||||
di SEMUA input password di seluruh halaman, termasuk yang muncul
|
||||
di dalam st.dialog (popup).
|
||||
"""
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marquee (banner berjalan)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def marquee(parts: list[str], separator: str = " ┃ ", speed_seconds: int = 20) -> None:
|
||||
"""
|
||||
Banner berjalan satu baris (dipakai di atas halaman Beranda untuk info
|
||||
lonjakan permintaan & hari libur terdekat).
|
||||
|
||||
Kalau `parts` kosong, tidak menampilkan apa pun (tidak ada div kosong
|
||||
yang tetap makan tempat di layout).
|
||||
"""
|
||||
if not parts:
|
||||
return
|
||||
|
||||
text = separator.join(parts)
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="overflow:hidden;white-space:nowrap;background:#fef3c7;
|
||||
border-radius:8px;padding:6px 0;margin-bottom:16px;">
|
||||
<div style="display:inline-block;padding-left:100%;
|
||||
animation:ui_marquee {speed_seconds}s linear infinite;
|
||||
color:#92400e;font-size:13px;">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes ui_marquee {{
|
||||
from {{ transform: translateX(0); }}
|
||||
to {{ transform: translateX(-100%); }}
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Badge status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def status_badge(label: str, status: str = "neutral") -> str:
|
||||
"""
|
||||
Bangun 1 badge HTML kecil (pill). Return string HTML mentah -- pemanggil
|
||||
tetap harus lewat st.markdown(..., unsafe_allow_html=True) atau
|
||||
menyisipkannya ke dalam blok HTML yang lebih besar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
status : str
|
||||
Salah satu dari 'ok' (hijau), 'warn' (kuning), 'error' (merah),
|
||||
atau 'neutral' (abu-abu, default kalau nilai tidak dikenali).
|
||||
"""
|
||||
bg, fg = _BADGE_COLORS.get(status, _BADGE_COLORS["neutral"])
|
||||
return (
|
||||
f"<span style='background:{bg};color:{fg};padding:2px 10px;"
|
||||
f"border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap;'>"
|
||||
f"{label}</span>"
|
||||
)
|
||||
|
||||
|
||||
def optimal_scenario_badge(scenario_name: str) -> str:
|
||||
"""Shortcut: badge hijau berlabel nama skenario optimal (dipakai di tabel evaluasi)."""
|
||||
return status_badge(f"⭐ {scenario_name}", status="ok")
|
||||
|
||||
|
||||
def render_badge(label: str, status: str = "neutral") -> None:
|
||||
"""Versi status_badge() yang langsung dirender (bukan cuma return string)."""
|
||||
st.markdown(status_badge(label, status), unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header seksi & KPI card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def section_header(icon: str, title: str, caption: str | None = None) -> None:
|
||||
"""Header seksi konsisten: ikon + judul (markdown h5) + caption opsional di bawahnya."""
|
||||
st.markdown(f"##### {icon} {title}")
|
||||
if caption:
|
||||
st.caption(caption)
|
||||
|
||||
|
||||
def kpi_card(label: str, value: str, delta: str | None = None) -> None:
|
||||
"""
|
||||
Wrapper tipis di atas st.metric.
|
||||
|
||||
Ada sebagai fungsi terpisah (bukan langsung st.metric di views/*.py)
|
||||
supaya kalau nanti style KPI card mau diseragamkan lebih jauh (mis.
|
||||
ganti ke st.container dengan border custom), cukup ubah di satu tempat.
|
||||
"""
|
||||
st.metric(label, value, delta)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date-range picker (1 field, ikon kalender bawaan Streamlit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def inject_calendar_id_script():
|
||||
"""
|
||||
Menyisipkan skrip untuk menerjemahkan teks bawaan di widget kalender Streamlit
|
||||
menjadi Bahasa Indonesia yang rapi.
|
||||
"""
|
||||
st.html(
|
||||
"""
|
||||
<script>
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Terjemahkan instruksi di bawah kalender
|
||||
document.querySelectorAll('div, span, p').forEach(el => {
|
||||
if (el.textContent.trim() === 'Choose a date range') {
|
||||
el.textContent = 'Pilih rentang tanggal';
|
||||
}
|
||||
if (el.textContent.trim() === 'None') {
|
||||
el.textContent = 'Tidak ada';
|
||||
}
|
||||
});
|
||||
|
||||
// Terjemahkan nama bulan
|
||||
const monthMap = {
|
||||
'January': 'Januari', 'February': 'Februari', 'March': 'Maret',
|
||||
'April': 'April', 'May': 'Mei', 'June': 'Juni',
|
||||
'July': 'Juli', 'August': 'Agustus', 'September': 'September',
|
||||
'October': 'Oktober', 'November': 'November', 'December': 'Desember'
|
||||
};
|
||||
|
||||
// Terjemahkan nama hari singkat
|
||||
const dayMap = {
|
||||
'Su': 'Min', 'Mo': 'Sen', 'Tu': 'Sel',
|
||||
'We': 'Rab', 'Th': 'Kam', 'Fr': 'Jum', 'Sa': 'Sab'
|
||||
};
|
||||
|
||||
document.querySelectorAll('button, div').forEach(el => {
|
||||
const text = el.textContent.trim();
|
||||
if (monthMap[text]) {
|
||||
el.textContent = monthMap[text];
|
||||
}
|
||||
if (dayMap[text]) {
|
||||
el.textContent = dayMap[text];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
</script>
|
||||
"""
|
||||
)
|
||||
|
||||
def format_tanggal_id(d: datetime.date) -> str:
|
||||
"""'2026-08-04' -> '04 Agt 2026' (format singkat ala Indonesia)."""
|
||||
return f"{d.day:02d} {_BULAN_ID[d.month]} {d.year}"
|
||||
|
||||
def date_range_picker(
|
||||
container,
|
||||
label: str,
|
||||
start: datetime.date,
|
||||
end: datetime.date,
|
||||
key: str,
|
||||
min_date: datetime.date | None = None, # >>> FIX: opsional, batas per-produk
|
||||
max_date: datetime.date | None = None, # >>> FIX: opsional, batas per-produk
|
||||
):
|
||||
# inject_calendar_id_script()
|
||||
|
||||
# Batas rentang DEFAULT (dipakai kalau pemanggil tidak kasih min_date/
|
||||
# max_date sendiri -- menjaga kompatibilitas dengan pemanggilan lama).
|
||||
DEFAULT_MIN_FORECAST_DATE = datetime.date(2026, 1, 1)
|
||||
DEFAULT_MAX_FORECAST_DATE = datetime.date(2030, 12, 31)
|
||||
|
||||
# >>> FIX: kalau pemanggil kasih min_date/max_date eksplisit (mis. hasil
|
||||
# dari data_loader.get_forecast_date_bounds() per produk), itu yang
|
||||
# dipakai. Kalau tidak, fallback ke rentang default lama.
|
||||
lower_bound = min_date if min_date is not None else DEFAULT_MIN_FORECAST_DATE
|
||||
upper_bound = max_date if max_date is not None else DEFAULT_MAX_FORECAST_DATE
|
||||
|
||||
# Sesuaikan start & end bawaan agar tetap di dalam rentang valid produk ini
|
||||
val_start = max(min(start, upper_bound), lower_bound)
|
||||
val_end = max(min(end, upper_bound), lower_bound)
|
||||
|
||||
date_range = container.date_input(
|
||||
label,
|
||||
value=(val_start, val_end),
|
||||
min_value=lower_bound, # >>> FIX: sebelumnya MIN_FORECAST_DATE hardcode
|
||||
max_value=upper_bound, # >>> FIX: sebelumnya MAX_FORECAST_DATE hardcode
|
||||
key=key,
|
||||
format="DD-MM-YYYY",
|
||||
)
|
||||
|
||||
if isinstance(date_range, tuple) and len(date_range) == 2:
|
||||
new_start, new_end = date_range
|
||||
else:
|
||||
new_start, new_end = val_start, val_end
|
||||
|
||||
return new_start, new_end
|
||||
|
||||
# get_forecast_date_bounds -> itu soal data & logika bisnis — "kapan sebenarnya boleh diramal, secara matematis/bisnis". Tidak peduli sama sekali soal tampilan.
|
||||
# date_range_picker -> itu soal UI/tampilan — "bagaimana caranya kasih widget kalender yang enak dipakai user, rata tengah, dst". Tidak peduli soal kenapa rentang tanggalnya segitu.
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
views/beranda.py
|
||||
=================
|
||||
Halaman utama setelah login (semua role bisa akses). Menampilkan:
|
||||
- marquee banner (info lonjakan permintaan & hari libur terdekat)
|
||||
- 4 KPI card ringkas
|
||||
- grid produk (mini grafik, total peramalan, skenario optimal, tombol detail)
|
||||
|
||||
Tidak ada logika manajemen stok di sini -- semua angka murni turunan dari
|
||||
hasil forecast Prophet (lihat utils/demand_insights.py untuk detail aturan
|
||||
deteksi lonjakan).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from utils import ui_components
|
||||
from utils.data_loader import get_aggregate_forecast_date_bounds, get_product_list
|
||||
from utils.demand_insights import detect_peak_days, nearest_holiday
|
||||
from utils.forecast_helpers import generate_optimal_forecast, summarize_forecast
|
||||
|
||||
PRODUCT_ICONS = {
|
||||
"minyak": "🛢️",
|
||||
"gula": "🧂",
|
||||
"tepung": "🌾",
|
||||
"telur": "🥚",
|
||||
"sedap goreng": "🍜",
|
||||
}
|
||||
|
||||
PRODUCT_UNITS = {
|
||||
"minyak": "Kg",
|
||||
"gula": "Kg",
|
||||
"tepung": "Kg",
|
||||
"telur": "Kg",
|
||||
"sedap goreng": "Pcs",
|
||||
}
|
||||
|
||||
|
||||
@st.cache_data(show_spinner=False)
|
||||
def _cached_forecast(_all_results: dict, product: str, start: str, end: str):
|
||||
"""
|
||||
Wrapper ber-cache di sekitar generate_optimal_forecast.
|
||||
|
||||
Parameter `_all_results` diberi awalan underscore -- ini konvensi
|
||||
Streamlit supaya argumen tsb TIDAK ikut di-hash untuk menentukan cache
|
||||
key (isinya objek Prophet yang tidak cocok/tidak perlu di-hash).
|
||||
Cache key yang efektif jadinya cukup (product, start, end).
|
||||
"""
|
||||
entry = _all_results[product]
|
||||
return generate_optimal_forecast(entry, start, end)
|
||||
|
||||
|
||||
def _period_picker(all_results: dict, products: list[str]) -> tuple[datetime.date, datetime.date]:
|
||||
st.markdown("###### 📅 Periode peramalan")
|
||||
preset = st.radio(
|
||||
"Preset periode",
|
||||
options=["7 hari", "14 hari", "30 hari", "Kustom"],
|
||||
horizontal=True,
|
||||
label_visibility="collapsed",
|
||||
key="beranda_preset",
|
||||
)
|
||||
today = datetime.date.today()
|
||||
|
||||
if preset == "Kustom":
|
||||
# >>> FIX: batas kalender otomatis, hasil IRISAN dari semua produk
|
||||
# (bukan rentang statis 2026-2030) -- supaya periode yang dipilih
|
||||
# pasti valid untuk SEMUA produk yang ditampilkan di Beranda.
|
||||
min_d, max_d = get_aggregate_forecast_date_bounds(all_results, products)
|
||||
start, end = ui_components.date_range_picker(
|
||||
st, "Periode kustom", today, today,
|
||||
key="beranda_custom_period",
|
||||
min_date=min_d, max_date=max_d,
|
||||
)
|
||||
else:
|
||||
n_hari = {"7 hari": 7, "14 hari": 14, "30 hari": 30}[preset]
|
||||
start = today
|
||||
end = today + datetime.timedelta(days=n_hari - 1)
|
||||
|
||||
if end < start:
|
||||
st.error("Tanggal akhir harus setelah atau sama dengan tanggal awal.")
|
||||
st.stop()
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def _sparkline(forecast: pd.DataFrame) -> go.Figure:
|
||||
fig = go.Figure()
|
||||
yhat_formatted = forecast["yhat"].clip(lower=0).apply(
|
||||
lambda x: f"{x:,.0f}".replace(",", ".")
|
||||
)
|
||||
|
||||
fig.add_trace(go.Scatter(
|
||||
x=forecast["ds"], y=forecast["yhat"].clip(lower=0),
|
||||
mode="lines", marker=dict(size=3),line=dict(width=2, color="#2563eb"),
|
||||
customdata=yhat_formatted,
|
||||
hovertemplate="%{x|%d-%m-%Y}: %{customdata}<extra></extra>",
|
||||
))
|
||||
fig.update_layout(
|
||||
height=60, margin=dict(l=0, r=0, t=6, b=6),
|
||||
xaxis=dict(visible=False), yaxis=dict(visible=False),
|
||||
plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
|
||||
showlegend=False,
|
||||
hoverlabel=dict(
|
||||
font_size=11,
|
||||
namelength=0,
|
||||
)
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _go_to_detail(product: str, start: datetime.date, end: datetime.date) -> None:
|
||||
st.session_state["selected_product"] = product
|
||||
st.session_state["selected_period"] = (start, end)
|
||||
st.session_state.pop("detail_product_select", None)
|
||||
st.session_state.pop("detail_period_select", None)
|
||||
detail_page = st.session_state.get("_page_detail_peramalan")
|
||||
if detail_page is not None:
|
||||
st.switch_page(detail_page)
|
||||
else:
|
||||
st.info("Buka menu 'Detail Peramalan' di sidebar untuk melihat produk ini.")
|
||||
|
||||
|
||||
def render() -> None:
|
||||
ui_components.inject_calendar_id_script()
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
div[data-testid="stMetricValue"] {
|
||||
font-size: 26px;
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
div[data-testid="stMetricLabel"] { font-size: 13px; }
|
||||
div[data-testid="stMetricDelta"] { font-size: 13px; }
|
||||
div[data-testid="stMetricDelta"] svg { display: none !important; }
|
||||
div[data-testid="stMetricDelta"] > div { padding: 2px 6px !important; }
|
||||
|
||||
hr {
|
||||
margin: 1rem 0 2rem 0 !important;
|
||||
}
|
||||
|
||||
div[data-testid="stVerticalBlockBorderWrapper"] {
|
||||
padding: 0.85rem 1rem 0.75rem !important;
|
||||
}
|
||||
div[data-testid="stVerticalBlockBorderWrapper"] .stMarkdown p {
|
||||
font-size: 13.5px !important;
|
||||
line-height: 1.35 !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
div[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stCaptionContainer"] p {
|
||||
font-size: 12px !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
div[data-testid="stVerticalBlockBorderWrapper"] button {
|
||||
font-size: 13px !important;
|
||||
padding: 0.3rem 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
all_results = st.session_state.get("all_results", {})
|
||||
if not all_results:
|
||||
st.error("Data model belum dimuat.")
|
||||
return
|
||||
|
||||
products = get_product_list(all_results)
|
||||
|
||||
st.title("Peramalan Permintaan Produk")
|
||||
st.caption("UD Aan Jaya Toko")
|
||||
|
||||
start, end = _period_picker(all_results, products)
|
||||
n_hari = (end - start).days + 1
|
||||
|
||||
# --- Hitung forecast semua produk untuk periode ini ---------------------
|
||||
summaries: dict[str, dict] = {}
|
||||
forecasts: dict[str, pd.DataFrame] = {}
|
||||
peaks: dict[str, pd.DataFrame] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for product in products:
|
||||
try:
|
||||
forecast, scenario, _is_log = _cached_forecast(all_results, product, str(start), str(end))
|
||||
except Exception as e: # noqa: BLE001
|
||||
# sengaja ditangkap luas: 1 produk gagal (mis. scaler belum ada)
|
||||
# tidak boleh bikin seluruh halaman Beranda ikut error
|
||||
errors[product] = str(e)
|
||||
continue
|
||||
|
||||
forecasts[product] = forecast
|
||||
summary = summarize_forecast(forecast)
|
||||
summary["scenario"] = scenario
|
||||
summaries[product] = summary
|
||||
peaks[product] = detect_peak_days(forecast)
|
||||
|
||||
if errors:
|
||||
with st.expander(f"⚠️ {len(errors)} produk gagal diramal untuk periode ini"):
|
||||
for p, msg in errors.items():
|
||||
st.write(f"**{p.title()}**: {msg}")
|
||||
|
||||
if not summaries:
|
||||
st.warning("Tidak ada produk yang berhasil diramal untuk periode ini.")
|
||||
return
|
||||
|
||||
# --- Cari event/hari libur terdekat -------------------------------------
|
||||
nearest_event = None
|
||||
for product in forecasts:
|
||||
scenario_name = all_results[product]["optimal_scenario"]
|
||||
model = all_results[product]["scenarios"][scenario_name]["best_model"]
|
||||
ev = nearest_holiday(model, start)
|
||||
# Kalau ditemukan event (ev) dan tanggal event tersebut lebih dekat dibanding event yang sebelumnya disimpan, maka jadikan event tersebut sebagai nearest_event.
|
||||
if ev and (nearest_event is None or ev["tanggal"] < nearest_event["tanggal"]):
|
||||
nearest_event = ev
|
||||
|
||||
produk_lonjakan = [p for p, df in peaks.items() if len(df) > 0]
|
||||
|
||||
# --- Marquee banner -------------------------------------------------------
|
||||
# marquee_parts = []
|
||||
# if nearest_event is not None:
|
||||
# selisih = (nearest_event["tanggal"].date() - start).days
|
||||
# if selisih >= 0:
|
||||
# marquee_parts.append(f"📅 {nearest_event['nama'].replace('_', ' ').title()} dalam {selisih} hari lagi")
|
||||
# if produk_lonjakan:
|
||||
# nama = ", ".join(p.title() for p in produk_lonjakan)
|
||||
# marquee_parts.append(f"🚨 Terdeteksi lonjakan permintaan pada: {nama}")
|
||||
|
||||
# # Dipindah ke utils/ui_components.py supaya style marquee konsisten
|
||||
# # kalau nanti dipakai lagi di halaman lain.
|
||||
# ui_components.marquee(marquee_parts)
|
||||
|
||||
# --- 4 KPI Cards -------------------------------------------------------------
|
||||
total_semua = sum(s["total"] for s in summaries.values())
|
||||
top_produk = max(summaries, key=lambda p: summaries[p]["total"])
|
||||
jumlah_produk = len(summaries)
|
||||
|
||||
# st.markdown(
|
||||
# """
|
||||
# <style>
|
||||
# div[data-testid="stMetricValue"] {
|
||||
# font-size: 26px;
|
||||
# white-space: normal;
|
||||
# line-height: 1.25;
|
||||
# overflow-wrap: break-word;
|
||||
# }
|
||||
# div[data-testid="stMetricLabel"] {
|
||||
# font-size: 13px;
|
||||
# }
|
||||
# div[data-testid="stMetricDelta"] {
|
||||
# font-size: 13px;
|
||||
# }
|
||||
# </style>
|
||||
# """,
|
||||
# unsafe_allow_html=True,
|
||||
# )
|
||||
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
k1.metric("Total jenis produk", f"{jumlah_produk} produk")
|
||||
k2.metric(f"Total permintaan ({n_hari} hari)", f"{total_semua:,.0f}".replace(",", ".") + " unit")
|
||||
k3.metric("Permintaan tinggi", top_produk.title(), f"{summaries[top_produk]['total']:,.0f}".replace(",", ".") + " unit")
|
||||
|
||||
if nearest_event is not None:
|
||||
selisih = (nearest_event["tanggal"].date() - start).days
|
||||
tgl_str = nearest_event["tanggal"].strftime("%d-%m-%Y")
|
||||
nama_event = nearest_event["nama"].replace("_", " ").title()
|
||||
badge_text = f"H-{selisih}" if selisih > 0 else "Berlangsung"
|
||||
|
||||
k4.metric(
|
||||
"Event terdekat",
|
||||
nama_event,
|
||||
f"{tgl_str} ({badge_text})"
|
||||
)
|
||||
else:
|
||||
k4.metric("Event terdekat", "-")
|
||||
|
||||
# CSS khusus untuk memperlebar badge delta & merapatkan caption tanggal
|
||||
# st.markdown(
|
||||
# """
|
||||
# <style>
|
||||
# /* Sembunyikan ikon panah SVG pada delta metric */
|
||||
# div[data-testid="stMetricDelta"] svg {
|
||||
# display: none !important;
|
||||
# }
|
||||
# /* Perlebar ruang (padding) background hijau badge delta */
|
||||
# div[data-testid="stMetricDelta"] > div {
|
||||
# padding: 2px 6px !important;
|
||||
# }
|
||||
# </style>
|
||||
# """,
|
||||
# unsafe_allow_html=True,
|
||||
# )
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Grid produk ---------------------------------------------------------------
|
||||
cols = st.columns(len(summaries))
|
||||
single_day = n_hari == 1
|
||||
|
||||
# CSS supaya kartu produk tetap rapi & kompak di zoom 100%,
|
||||
# tanpa perlu user turunkan zoom browser
|
||||
# st.markdown(
|
||||
# """
|
||||
# <style>
|
||||
# div[data-testid="stVerticalBlockBorderWrapper"] {
|
||||
# padding: 0.85rem 1rem 0.75rem !important;
|
||||
# }
|
||||
# div[data-testid="stVerticalBlockBorderWrapper"] .stMarkdown p {
|
||||
# font-size: 13.5px !important;
|
||||
# line-height: 1.35 !important;
|
||||
# margin-bottom: 4px !important;
|
||||
# }
|
||||
# div[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stCaptionContainer"] p {
|
||||
# font-size: 12px !important;
|
||||
# line-height: 1.3 !important;
|
||||
# }
|
||||
# div[data-testid="stVerticalBlockBorderWrapper"] button {
|
||||
# font-size: 13px !important;
|
||||
# padding: 0.3rem 0.5rem !important;
|
||||
# }
|
||||
# </style>
|
||||
# """,
|
||||
# unsafe_allow_html=True,
|
||||
# )
|
||||
|
||||
for col, product in zip(cols, summaries.keys()):
|
||||
with col:
|
||||
with st.container(border=True):
|
||||
icon = PRODUCT_ICONS.get(product, "📦")
|
||||
unit = PRODUCT_UNITS.get(product, "unit")
|
||||
s = summaries[product]
|
||||
|
||||
st.markdown(
|
||||
f"<p style='font-size:15px; font-weight:600; margin:0 0 6px; "
|
||||
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;' "
|
||||
f"title='{product.title()}'>{icon} {product.title()}</p>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
if single_day:
|
||||
# variasi Big Number: rentang cuma 1 hari, sparkline tidak
|
||||
# ada gunanya (cuma 1 titik data)
|
||||
total_fmt = f"{s['total']:,.0f}".replace(",", ".")
|
||||
st.markdown(
|
||||
f"<p style='font-size:26px;font-weight:600;margin:6px 0 0;'>"
|
||||
f"{total_fmt} {unit}</p>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
st.plotly_chart(
|
||||
_sparkline(forecasts[product]),
|
||||
width="stretch",
|
||||
config={"displayModeBar": False},
|
||||
key=f"spark_{product}",
|
||||
)
|
||||
total_fmt = f"{s['total']:,.0f}".replace(",", ".")
|
||||
st.markdown(
|
||||
f"<p style='font-size:12px; font-weight:600; margin:4px 0 2px; "
|
||||
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'>"
|
||||
f"{total_fmt} {unit}</p>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
rata_text = f"Rata2: {s['rata_rata_harian']:.1f}".replace(".", ",") + f" {unit}/hari"
|
||||
st.markdown(
|
||||
f"<p style='font-size:12px; color:#888; margin:0 0 6px; "
|
||||
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;' "
|
||||
f"title='{rata_text}'>{rata_text}</p>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# legenda 🟢 Normal / 🟠 Lonjakan di KPI "Peringatan lonjakan"
|
||||
# is_lonjakan = product in produk_lonjakan
|
||||
# badge_bg = "#fff2e0" if is_lonjakan else "#e7f7ee"
|
||||
# badge_fg = "#b45309" if is_lonjakan else "#1a7f4e"
|
||||
# badge_text = "🟠 Lonjakan" if is_lonjakan else "🟢 Normal"
|
||||
# st.markdown(
|
||||
# f"<span style='display:inline-block; background:{badge_bg}; "
|
||||
# f"color:{badge_fg}; padding:2px 8px; border-radius:10px; "
|
||||
# f"font-size:11.5px; font-weight:500; margin:2px 0 2px;'>"
|
||||
# f"{badge_text}</span>",
|
||||
# unsafe_allow_html=True,
|
||||
# )
|
||||
|
||||
# PENTING: st.switch_page TIDAK bekerja kalau dipanggil dari
|
||||
# dalam callback on_click -- harus dipanggil langsung di sini,
|
||||
# di dalam blok if. Sudah diverifikasi lewat pengujian manual.
|
||||
if st.button("Lihat detail →", key=f"detail_btn_{product}", width="stretch"):
|
||||
_go_to_detail(product, start, end)
|
||||
# st.markdown(
|
||||
# "<div style='text-align:left; margin-top:-10px;'>"
|
||||
# "<span style='font-size:14px; color:#8C8D93D1;'>Keterangan warna menunjukkan peramalan pemintaan </span>"
|
||||
# "<span style='background:#e7f7ee; color:#1a7f4e; padding:2px 9px; "
|
||||
# "border-radius:10px; font-size:11.5px; font-weight:600; margin-right:6px;'>"
|
||||
# "🟢 Normal </span>"
|
||||
# "<span style='font-size:14px; color:#8C8D93D1;'>dan </span>"
|
||||
# "<span style='background:#fff2e0; color:#b45309; padding:2px 9px; "
|
||||
# "border-radius:10px; font-size:11.5px; font-weight:600;'>"
|
||||
# "🟠 Lonjakan</span>"
|
||||
# "</div>",
|
||||
# unsafe_allow_html=True,
|
||||
# )
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,127 @@
|
|||
"""
|
||||
edit_profil.py
|
||||
===============
|
||||
Halaman untuk user (admin ATAUPUN pemilik) mengubah profil akunnya
|
||||
sendiri: username, nama tampilan, dan ganti password.
|
||||
|
||||
Dibagi 2 bagian terpisah (2 form independen):
|
||||
1. "Informasi Akun" -> ubah username & nama tampilan.
|
||||
Username BOLEH diubah, TAPI wajib divalidasi supaya tidak bentrok
|
||||
dengan username yang sudah dipakai user lain.
|
||||
2. "Ganti Password" -> tetap wajib isi password lama (verifikasi diri
|
||||
sendiri), tidak berubah dari sebelumnya.
|
||||
|
||||
Setelah berhasil (di bagian manapun): tampil popup "Berhasil", lalu
|
||||
form kembali ke tampilan awal -- pola yang sama seperti Manajemen User.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils import auth
|
||||
|
||||
|
||||
@st.dialog("✅ Berhasil")
|
||||
def _dialog_sukses(pesan: str) -> None:
|
||||
st.write(pesan)
|
||||
if st.button("Oke", type="primary", width="stretch", key="btn_ep_sukses_ok"):
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_edit_profil() -> None:
|
||||
st.markdown("### Edit Profil")
|
||||
st.caption(f"Login sebagai **{auth.get_display_name()}** ({auth.get_current_role()})")
|
||||
|
||||
# Popup sukses (kalau ada pesan "dititipkan" dari submit sebelumnya)
|
||||
pending_success = st.session_state.pop("_ep_success_msg", None)
|
||||
if pending_success:
|
||||
_dialog_sukses(pending_success)
|
||||
|
||||
# >>> Trik versi key: dipakai di KEDUA form supaya, begitu salah satu
|
||||
# form berhasil disimpan, semua input (termasuk yang belum sempat
|
||||
# disubmit di form satunya) ikut kembali kosong/ke nilai awal.
|
||||
form_version = st.session_state.get("_ep_form_version", 0)
|
||||
username_sekarang = auth.get_current_username()
|
||||
nama_sekarang = auth.get_display_name()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bagian 1: Informasi Akun (username & nama tampilan)
|
||||
# ------------------------------------------------------------------
|
||||
with st.form(f"form_info_akun_{form_version}", border=True):
|
||||
st.markdown("**Informasi Akun**")
|
||||
|
||||
# >>> FIX: Streamlit TIDAK MENGIZINKAN on_change di widget dalam
|
||||
# st.form (cuma boleh di st.form_submit_button) -- makanya strip
|
||||
# spasi username dipindah ke sini, dijalankan pas tombol "Simpan"
|
||||
# ditekan (bukan real-time saat mengetik seperti di Tambah User).
|
||||
username_baru = st.text_input("Username", value=username_sekarang)
|
||||
nama_baru = st.text_input("Nama Tampilan", value=nama_sekarang)
|
||||
|
||||
submitted_info = st.form_submit_button("Simpan Informasi Akun", type="primary")
|
||||
|
||||
if submitted_info:
|
||||
username_baru = username_baru.replace(" ", "")
|
||||
berhasil, pesan = auth.update_own_profile(
|
||||
username_lama=username_sekarang,
|
||||
username_baru=username_baru,
|
||||
nama_baru=nama_baru,
|
||||
)
|
||||
if berhasil:
|
||||
st.session_state["_ep_form_version"] = form_version + 1
|
||||
st.session_state["_ep_success_msg"] = pesan
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(pesan)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bagian 2: Ganti Password (tetap wajib password lama)
|
||||
# ------------------------------------------------------------------
|
||||
with st.form(f"form_ganti_password_{form_version}", border=True):
|
||||
st.markdown("**Ganti Password**")
|
||||
|
||||
password_lama = st.text_input(
|
||||
"Password saat ini",
|
||||
type="password",
|
||||
placeholder="Masukkan password yang sedang Anda pakai",
|
||||
)
|
||||
password_baru = st.text_input(
|
||||
"Password baru",
|
||||
type="password",
|
||||
placeholder="Wajib ada huruf & angka",
|
||||
)
|
||||
konfirmasi_password_baru = st.text_input(
|
||||
"Konfirmasi password baru",
|
||||
type="password",
|
||||
placeholder="Ulangi password baru",
|
||||
)
|
||||
|
||||
submitted_pw = st.form_submit_button("Simpan Password Baru", type="primary")
|
||||
|
||||
if submitted_pw:
|
||||
username_login_ini = auth.get_current_username()
|
||||
|
||||
# >>> FIX: urutan pengecekan disamakan dengan urutan kolom di
|
||||
# form (password lama ada paling atas -> dicek paling dulu).
|
||||
# Sebelumnya kecocokan password baru/konfirmasi dicek DULUAN,
|
||||
# jadi kalau password lama JUGA salah, pesan yang muncul cuma
|
||||
# soal password baru tidak cocok -- padahal password lama yang
|
||||
# salah itu lebih mendasar dan seharusnya diberi tahu lebih dulu.
|
||||
if not password_lama or not password_baru or not konfirmasi_password_baru:
|
||||
st.error("Semua kolom wajib diisi.")
|
||||
elif not auth.verify_password(username_login_ini, password_lama):
|
||||
st.error("Password lama yang Anda masukkan salah.")
|
||||
elif password_baru != konfirmasi_password_baru:
|
||||
st.error("Password baru dan konfirmasinya tidak sama.")
|
||||
else:
|
||||
berhasil, pesan = auth.change_own_password(
|
||||
username=username_login_ini,
|
||||
old_password=password_lama,
|
||||
new_password=password_baru,
|
||||
)
|
||||
if berhasil:
|
||||
st.session_state["_ep_form_version"] = form_version + 1
|
||||
st.session_state["_ep_success_msg"] = pesan
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(pesan)
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
"""
|
||||
manajemen_user.py
|
||||
==================
|
||||
Halaman khusus ADMIN untuk mengelola akun user.
|
||||
|
||||
Layout:
|
||||
"Manajemen User"
|
||||
"Daftar User" [+ Tambah User]
|
||||
|
||||
Tabel (st.dataframe asli -- sama seperti tabel di halaman lain):
|
||||
No | Username | Nama | Role
|
||||
|
||||
Klik salah satu baris untuk memilihnya -> muncul tombol
|
||||
"✏️ Edit User Ini" dan "🗑️ Hapus User Ini" di bawah tabel.
|
||||
Kalau baris yang dipilih adalah akun sendiri yang sedang login,
|
||||
tombol hapus tetap tampil tapi dinonaktifkan (disabled).
|
||||
|
||||
Semua aksi (tambah / edit / hapus) tampil sebagai POPUP (st.dialog),
|
||||
mengikuti pola yang sama seperti panel "Perbarui Model (.pkl)" di
|
||||
Perbandingan Konfigurasi: aksi dikonfirmasi di dalam popup -> pesan
|
||||
sukses "dititipkan" ke session_state -> st.rerun() -> popup sukses
|
||||
tampil sebentar -> halaman kembali ke tampilan awal (tabel ter-refresh,
|
||||
pilihan baris ikut ter-reset).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils import auth, ui_components
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Popup sukses (dipakai untuk semua jenis aksi: tambah/edit/hapus)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("✅ Berhasil")
|
||||
def _dialog_sukses(pesan: str) -> None:
|
||||
st.write(pesan)
|
||||
if st.button("Oke", type="primary", width="stretch", key="btn_um_sukses_ok"):
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _tutup_dan_refresh(pesan_sukses: str | None = None) -> None:
|
||||
"""Bersihkan semua state popup yang sedang terbuka, titipkan pesan
|
||||
sukses (kalau ada), lalu rerun -- persis pola di panel upload model."""
|
||||
st.session_state.pop("_um_action", None)
|
||||
if pesan_sukses:
|
||||
st.session_state["_um_success_msg"] = pesan_sukses
|
||||
# >>> FIX: naikkan versi key tabel supaya widget dataframe jadi
|
||||
# "baru" (pilihan baris ter-reset) -- mencegah index nyasar kalau
|
||||
# user yang tadi dipilih ternyata baru saja dihapus/berubah posisi.
|
||||
st.session_state["_um_table_version"] = st.session_state.get("_um_table_version", 0) + 1
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _batalkan_aksi() -> None:
|
||||
"""
|
||||
Callback untuk on_dismiss di st.dialog -- Streamlit otomatis
|
||||
menjalankan ini kapan pun popup ditutup DENGAN CARA APAPUN, termasuk
|
||||
klik tombol "x" atau klik di luar area popup (bukan cuma lewat
|
||||
tombol "Batal" buatan kita).
|
||||
|
||||
>>> FIX BUG: sebelumnya, kalau popup ditutup lewat "x", session_state
|
||||
["_um_action"] tidak ikut kebersihkan -- akibatnya popup yang sama
|
||||
muncul lagi tiba-tiba di rerun berikutnya (misal cuma karena klik
|
||||
baris tabel), bahkan masih nyangkut walau sudah pindah halaman lalu
|
||||
balik lagi. Dengan on_dismiss, pembersihan ini sekarang terjadi
|
||||
OTOMATIS untuk semua cara menutup popup, tidak cuma tombol Batal.
|
||||
"""
|
||||
st.session_state.pop("_um_action", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Popup: Tambah User
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("➕ Tambah User Baru", on_dismiss=_batalkan_aksi)
|
||||
def _dialog_tambah_user() -> None:
|
||||
username = st.text_input(
|
||||
"Username", key="tu_username",
|
||||
on_change=ui_components.strip_spasi_username, args=("tu_username",),
|
||||
)
|
||||
nama = st.text_input("Nama tampilan", key="tu_nama")
|
||||
role = st.selectbox("Role", options=["pemilik", "admin"], key="tu_role")
|
||||
password = st.text_input("Password awal", type="password", placeholder="Wajib ada huruf & angka", key="tu_password")
|
||||
konfirmasi = st.text_input("Konfirmasi password", type="password", key="tu_konfirmasi")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
if col1.button("Simpan", type="primary", width="stretch", key="btn_tu_simpan"):
|
||||
if password != konfirmasi:
|
||||
st.error("Password dan konfirmasinya tidak sama.")
|
||||
else:
|
||||
berhasil, pesan = auth.add_user(username=username, password=password, role=role, nama=nama)
|
||||
if berhasil:
|
||||
_tutup_dan_refresh(pesan)
|
||||
else:
|
||||
st.error(pesan)
|
||||
if col2.button("Batal", width="stretch", key="btn_tu_batal"):
|
||||
_tutup_dan_refresh()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Popup: Edit User (nama + role + password baru, digabung jadi satu)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("✏️ Edit User", on_dismiss=_batalkan_aksi)
|
||||
def _dialog_edit_user(username: str, nama_lama: str, role_lama: str) -> None:
|
||||
# >>> FIX v2: pendekatan CSS :has(input:disabled) sebelumnya tidak
|
||||
# match (struktur HTML Streamlit versi ini beda dari dugaan awal).
|
||||
# Sekarang dibungkus st.container(key=...) supaya dapat class CSS
|
||||
# pasti (".st-key-eu_username_wrapper"), lalu SEMUA label di dalam
|
||||
# wrapper itu dipaksa warnanya normal -- tidak bergantung tebakan
|
||||
# struktur disabled/aria-disabled Streamlit lagi.
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
.st-key-eu_username_wrapper label p,
|
||||
.st-key-eu_username_wrapper label {
|
||||
color: inherit !important;
|
||||
opacity: 1 !important;
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
with st.container(key="eu_username_wrapper"):
|
||||
st.text_input("Username", value=username, disabled=True, key=f"eu_username_display_{username}")
|
||||
|
||||
# >>> FIX: key widget disisipi 'username' supaya UNIK per-user.
|
||||
# Sebelumnya key-nya statis ("eu_nama", "eu_role", dst) -- akibatnya
|
||||
# kalau Anda edit user A lalu tutup, lalu edit user B, kolom Nama/
|
||||
# Role/Password bisa ikut menampilkan sisa nilai dari user A
|
||||
# (Streamlit menyimpan nilai widget berdasarkan key, bukan berdasarkan
|
||||
# parameter value= yang dikirim ulang).
|
||||
nama = st.text_input("Nama tampilan", value=nama_lama, key=f"eu_nama_{username}")
|
||||
role = st.selectbox(
|
||||
"Role", options=["admin", "pemilik"],
|
||||
index=["admin", "pemilik"].index(role_lama), key=f"eu_role_{username}",
|
||||
)
|
||||
|
||||
password_baru = st.text_input("Password baru", type="password", key=f"eu_password_{username}")
|
||||
konfirmasi_baru = st.text_input("Konfirmasi password baru", type="password", key=f"eu_konfirmasi_{username}")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
if col1.button("Simpan", type="primary", width="stretch", key="btn_eu_simpan"):
|
||||
# >>> FIX BUG: sebelumnya pakai 'return' di sini kalau password
|
||||
# tidak cocok -- di Python, 'return' menghentikan SELURUH fungsi,
|
||||
# jadi baris col2.button("Batal") di bawah ini ikut tidak sempat
|
||||
# digambar pada render itu (makanya tombol Batal terlihat
|
||||
# "hilang sesaat" setelah error muncul). Diganti if/else supaya
|
||||
# fungsi tetap lanjut sampai akhir tanpa melewati Batal.
|
||||
if password_baru and password_baru != konfirmasi_baru:
|
||||
st.error("Password baru dan konfirmasinya tidak sama.")
|
||||
else:
|
||||
berhasil, pesan = auth.admin_edit_user(
|
||||
username=username, nama=nama, role=role, new_password=password_baru or None,
|
||||
)
|
||||
if berhasil:
|
||||
_tutup_dan_refresh(pesan)
|
||||
else:
|
||||
st.error(pesan)
|
||||
if col2.button("Batal", width="stretch", key="btn_eu_batal"):
|
||||
_tutup_dan_refresh()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Popup: Konfirmasi Hapus User
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("🗑️ Hapus User", on_dismiss=_batalkan_aksi)
|
||||
def _dialog_hapus_user(username: str) -> None:
|
||||
st.warning(f"Yakin ingin menghapus user **{username}**? Tindakan ini tidak bisa dibatalkan.")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
if col1.button("Ya, Hapus", type="primary", width="stretch", key="btn_hu_ya"):
|
||||
berhasil, pesan = auth.delete_user(username, requested_by=auth.get_current_username())
|
||||
if berhasil:
|
||||
_tutup_dan_refresh(pesan)
|
||||
else:
|
||||
st.error(pesan)
|
||||
if col2.button("Batal", width="stretch", key="btn_hu_batal"):
|
||||
_tutup_dan_refresh()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Halaman utama
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_manajemen_user() -> None:
|
||||
# >>> Cuma SATU aturan CSS untuk icon toolbar tabel (mengecilkan ke
|
||||
# ukuran normal 0.9rem). Versi sebelumnya sempat ada 2 aturan CSS
|
||||
# icon yang saling bentrok (satu memperbesar 1.3x, satu memperkecil
|
||||
# ke 0.9rem) -- sudah dibersihkan, cuma disisakan yang mengecilkan.
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
[data-testid="stElementToolbarButton"] svg {
|
||||
width: 0.9rem !important;
|
||||
height: 0.9rem !important;
|
||||
}
|
||||
[data-testid="stElementToolbarButton"] {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# Pakai st.title() supaya konsisten dengan judul di halaman lain.
|
||||
# CSS di bawah cuma mengecilkan margin bawahnya saja -- ini yang
|
||||
# sebelumnya bikin jarak ke "Daftar User" terasa kelebaran.
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
div[data-testid="stAppViewContainer"] h1 {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.title("Manajemen User")
|
||||
|
||||
# Popup sukses (kalau ada pesan "dititipkan" dari aksi sebelum rerun)
|
||||
pending_success = st.session_state.pop("_um_success_msg", None)
|
||||
if pending_success:
|
||||
_dialog_sukses(pending_success)
|
||||
|
||||
# Buka popup sesuai aksi yang sedang aktif (tersimpan di session_state
|
||||
# supaya tetap terbuka selagi user mengisi form di dalam popup-nya)
|
||||
aksi = st.session_state.get("_um_action")
|
||||
daftar = auth.list_users()
|
||||
peta_user = {u["username"]: u for u in daftar}
|
||||
|
||||
if aksi is not None:
|
||||
jenis, target_username = aksi
|
||||
if jenis == "tambah":
|
||||
_dialog_tambah_user()
|
||||
elif jenis == "edit" and target_username in peta_user:
|
||||
data = peta_user[target_username]
|
||||
_dialog_edit_user(target_username, data["nama"], data["role"])
|
||||
elif jenis == "hapus" and target_username in peta_user:
|
||||
_dialog_hapus_user(target_username)
|
||||
|
||||
# --- Header: "Daftar User" + tombol Tambah User di kanan ---
|
||||
# vertical_alignment="center" supaya teks & tombol sejajar tengah
|
||||
# (defaultnya top-aligned, makanya sebelumnya terlihat tidak lurus).
|
||||
col_judul, col_tombol = st.columns([4, 1], vertical_alignment="center")
|
||||
with col_judul:
|
||||
st.markdown("**Daftar User**")
|
||||
with col_tombol:
|
||||
if st.button("➕ Tambah User", width="stretch", key="btn_buka_tambah"):
|
||||
st.session_state["_um_action"] = ("tambah", None)
|
||||
st.rerun()
|
||||
|
||||
# --- Tabel: pakai st.dataframe() ASLI (sama seperti tabel di halaman
|
||||
# lain -- otomatis rapi & responsif), bukan st.columns() manual lagi.
|
||||
#
|
||||
# Konsekuensinya: st.dataframe tidak bisa diisi tombol di dalam sel,
|
||||
# jadi kolom "Aksi" (edit/hapus per baris) diganti pola "klik baris
|
||||
# untuk pilih -> tombol aksi muncul di bawah tabel" (selection_mode
|
||||
# bawaan Streamlit). Klik baris yang sudah terpilih lagi untuk
|
||||
# membatalkan pilihan.
|
||||
username_login_ini = auth.get_current_username()
|
||||
|
||||
tampil = [
|
||||
{"No": i, "Username": u["username"], "Nama": u["nama"], "Role": u["role"]}
|
||||
for i, u in enumerate(daftar, start=1)
|
||||
]
|
||||
|
||||
table_version = st.session_state.get("_um_table_version", 0)
|
||||
event = st.dataframe(
|
||||
tampil,
|
||||
width="stretch",
|
||||
hide_index=True,
|
||||
on_select="rerun",
|
||||
selection_mode="single-row",
|
||||
key=f"tabel_manajemen_user_{table_version}",
|
||||
)
|
||||
|
||||
baris_terpilih = event.selection.rows if event and event.selection else []
|
||||
|
||||
if not baris_terpilih:
|
||||
st.caption("👆 Klik salah satu baris di atas untuk mengedit atau menghapus user tersebut.")
|
||||
else:
|
||||
user_terpilih = daftar[baris_terpilih[0]]
|
||||
st.markdown(f"**User terpilih: `{user_terpilih['username']}`**")
|
||||
|
||||
col_edit, col_hapus = st.columns(2)
|
||||
if col_edit.button("✏️ Edit User Ini", width="stretch", key="btn_edit_terpilih"):
|
||||
st.session_state["_um_action"] = ("edit", user_terpilih["username"])
|
||||
st.rerun()
|
||||
|
||||
# User terpilih = akun sendiri yang sedang login -> tombol tetap
|
||||
# tampil tapi dinonaktifkan (bukan disembunyikan/diganti teks)
|
||||
is_akun_sendiri = user_terpilih["username"] == username_login_ini
|
||||
if col_hapus.button(
|
||||
"🗑️ Hapus User Ini",
|
||||
width="stretch",
|
||||
key="btn_hapus_terpilih",
|
||||
disabled=is_akun_sendiri,
|
||||
help="Tidak bisa menghapus akun sendiri." if is_akun_sendiri else None,
|
||||
):
|
||||
st.session_state["_um_action"] = ("hapus", user_terpilih["username"])
|
||||
st.rerun()
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
"""
|
||||
views/perbandingan_konfigurasi.py
|
||||
==================================
|
||||
KHUSUS ADMIN. Halaman ini TIDAK PERNAH dikirim ke role 'pemilik' -- lihat
|
||||
app.py, daftar `pages` untuk role tsb tidak menyertakan Page ini sama
|
||||
sekali (bukan cuma disembunyikan di sidebar).
|
||||
|
||||
Isi (3 tab):
|
||||
1. "Evaluasi Metrik" -- tabel perbandingan MAE/RMSE/MAPE untuk 4 skenario
|
||||
x N produk (skenario optimal tiap produk ditandai hijau), + tabel
|
||||
parameter hasil grid search terbaik.
|
||||
2. "Status Model" -- ringkasan skenario optimal, ketersediaan scaler,
|
||||
dan kapan terakhir tiap produk diperbarui.
|
||||
3. "Perbarui Model" -- panel upload file all_results.pkl baru: validasi
|
||||
struktur -> preview dampak merge -> konfirmasi -> backup otomatis lalu
|
||||
tulis & reload (lihat utils/model_manager.py untuk detail prosesnya).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from utils import model_manager, ui_components
|
||||
from utils.data_loader import (
|
||||
DEFAULT_DATA_PATH,
|
||||
get_hyperparameter_table,
|
||||
get_last_updated_info,
|
||||
get_metric_comparison_table,
|
||||
)
|
||||
|
||||
METRIC_OPTIONS = ["MAPE", "MAE", "RMSE"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tab 1: Evaluasi Metrik
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _highlight_optimal_row(row):
|
||||
"""Beri background hijau pada kolom skenario yang jadi optimal_scenario baris ini."""
|
||||
optimal = row.get("optimal_scenario")
|
||||
return [
|
||||
"background-color:#dcfce7;font-weight:600;" if col == optimal else ""
|
||||
for col in row.index
|
||||
]
|
||||
|
||||
|
||||
def _render_metric_table(all_results: dict) -> None:
|
||||
ui_components.section_header(
|
||||
"📊", "Perbandingan Metrik Antar Skenario",
|
||||
"**Kinerja 4 skenario pemodelan** (model standar, model dengan transformasi logaritmik, model dengan penambahan regresor eksternal, dan model dengan kombinasi transformasi logaritmik dan regresor eksternal) **untuk tiap produk.**",
|
||||
)
|
||||
|
||||
metric = st.radio("Metrik", METRIC_OPTIONS, horizontal=True, key="metric_choice")
|
||||
table = get_metric_comparison_table(all_results, metric=metric)
|
||||
|
||||
if table.empty:
|
||||
st.info("Belum ada data metrik untuk ditampilkan.")
|
||||
return
|
||||
|
||||
# 'optimal_scenario' berisi teks (nama skenario), jadi HARUS dikecualikan
|
||||
# dari .format(precision=...) -- kalau ikut, akan error karena bukan angka.
|
||||
numeric_cols = [c for c in table.columns if c != "optimal_scenario"]
|
||||
|
||||
styled = (
|
||||
table.style
|
||||
.apply(_highlight_optimal_row, axis=1)
|
||||
.format(subset=numeric_cols, precision=2)
|
||||
)
|
||||
st.dataframe(styled, width="stretch")
|
||||
st.caption("🟩 Sel hijau menunjukkan skenario optimal.")
|
||||
|
||||
|
||||
def _render_hyperparameter_table(all_results: dict) -> None:
|
||||
ui_components.section_header(
|
||||
"🧪", "Hyperparameter Skenario Optimal",
|
||||
"Hasil grid search terbaik -- hanya untuk skenario yang sedang dipakai tiap produk.",
|
||||
)
|
||||
table = get_hyperparameter_table(all_results)
|
||||
st.dataframe(table, width="stretch", hide_index=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tab 2: Status Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_status_table(all_results: dict) -> None:
|
||||
ui_components.section_header(
|
||||
"🕘", "Status Model per Produk",
|
||||
"Skenario optimal, ketersediaan scaler, dan kapan terakhir diperbarui lewat panel upload.",
|
||||
)
|
||||
table = get_last_updated_info(all_results)
|
||||
st.dataframe(table, width="stretch", hide_index=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tab 3: Perbarui Model (upload & merge)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.dialog("✅ Model Berhasil Diperbarui")
|
||||
def _show_success_dialog(message: str) -> None:
|
||||
"""
|
||||
Popup konfirmasi setelah update model berhasil. Muncul di tengah layar,
|
||||
bisa ditutup dengan klik "Oke" ATAU klik di luar area popup (perilaku
|
||||
bawaan st.dialog) -- keduanya sama-sama menutup dialog & mengosongkan
|
||||
kembali panel upload seperti baru pertama kali dibuka.
|
||||
"""
|
||||
st.write(message)
|
||||
if st.button("Oke", type="primary", width="stretch", key="btn_dialog_ok"):
|
||||
st.rerun()
|
||||
|
||||
|
||||
def _render_upload_panel(all_results: dict) -> None:
|
||||
ui_components.section_header(
|
||||
"📤", "Perbarui Model (Upload .pkl)",
|
||||
"Sistem hanya akan memperbarui produk yang berubah. Data produk lainnya tetap aman.",
|
||||
)
|
||||
|
||||
# >>> FIX (baca): kalau ada pesan sukses yang "dititipkan" dari proses
|
||||
# SEBELUM rerun (lihat >>> FIX (tulis) di bagian tombol "Terapkan" di
|
||||
# bawah), tampilkan sebagai POPUP (bukan banner biasa). Dihapus dari
|
||||
# session_state setelah dibaca, supaya tidak muncul lagi tiap buka tab.
|
||||
pending_success = st.session_state.pop("_merge_success_msg", None)
|
||||
if pending_success:
|
||||
_show_success_dialog(pending_success)
|
||||
|
||||
# >>> FIX: key file_uploader disisipi angka versi yang berubah tiap
|
||||
# kali update berhasil -- ini "trik" resmi Streamlit untuk mengosongkan
|
||||
# file_uploader secara total (termasuk nama file yang tampil), bukan
|
||||
# cuma pesannya saja yang hilang.
|
||||
uploader_version = st.session_state.get("_uploader_version", 0)
|
||||
uploaded = st.file_uploader(
|
||||
"Pilih file all_results.pkl", type=["pkl"], key=f"uploader_pkl_{uploader_version}",
|
||||
)
|
||||
|
||||
if uploaded is None:
|
||||
# File dibatalkan/dihapus dari uploader -> buang state pending
|
||||
# supaya tombol "Terapkan" tidak bisa dipakai dengan data basi.
|
||||
st.session_state.pop("_pending_merge_data", None)
|
||||
return
|
||||
|
||||
raw_bytes = uploaded.getvalue()
|
||||
new_data, errors = model_manager.validate_new_data(raw_bytes)
|
||||
|
||||
if errors:
|
||||
st.error(f"❌ File ditolak, ditemukan {len(errors)} masalah:")
|
||||
for e in errors:
|
||||
st.write(f"- {e}")
|
||||
st.session_state.pop("_pending_merge_data", None)
|
||||
return
|
||||
|
||||
st.success("✅ File siap diterapkan! Berikut rincian perubahannya:")
|
||||
diff = model_manager.preview_merge(all_results, new_data)
|
||||
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("Produk baru", len(diff["produk_baru"]))
|
||||
c2.metric("Produk diperbarui", len(diff["produk_diperbarui"]))
|
||||
c3.metric("Produk tidak berubah", len(diff["produk_tidak_berubah"]))
|
||||
|
||||
if diff["produk_baru"]:
|
||||
st.caption("🆕 Baru: " + ", ".join(p.title() for p in diff["produk_baru"]))
|
||||
if diff["produk_diperbarui"]:
|
||||
st.caption("♻️ Diperbarui: " + ", ".join(p.title() for p in diff["produk_diperbarui"]))
|
||||
|
||||
# Simpan data yang sudah divalidasi ke session_state supaya tombol
|
||||
# konfirmasi di bawah tidak perlu parse ulang bytes yang sama.
|
||||
st.session_state["_pending_merge_data"] = new_data
|
||||
|
||||
st.warning("🛡️ Jangan khawatir, data yang sedang digunakan akan disimpan otomatis (ke folder `backups/`) sebagai cadangan sebelum diperbarui.")
|
||||
if st.button("✅ Terapkan pembaruan", type="primary", width="stretch", key="btn_apply_merge"):
|
||||
pending = st.session_state.get("_pending_merge_data")
|
||||
if pending is None:
|
||||
st.error("Tidak ada data valid. Upload ulang filenya!")
|
||||
return
|
||||
|
||||
try:
|
||||
merged, backup_path = model_manager.apply_merge(
|
||||
all_results, pending, path=DEFAULT_DATA_PATH,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
st.error(f"Gagal menerapkan pembaruan: {e}")
|
||||
return
|
||||
|
||||
st.session_state["all_results"] = merged
|
||||
st.session_state.pop("_pending_merge_data", None)
|
||||
|
||||
# >>> FIX: naikkan versi supaya file_uploader di render berikutnya
|
||||
# jadi widget BARU yang kosong (bukan cuma pesannya yang hilang).
|
||||
st.session_state["_uploader_version"] = uploader_version + 1
|
||||
|
||||
# >>> FIX (tulis): pesan sukses DITITIPKAN ke session_state dulu
|
||||
# (bukan langsung st.success()/dialog di sini), karena st.rerun()
|
||||
# di baris terakhir akan memotong render sebelum sempat tampil.
|
||||
# Nanti dibaca & ditampilkan sebagai popup di atas (>>> FIX (baca)).
|
||||
if backup_path:
|
||||
st.session_state["_merge_success_msg"] = (
|
||||
f"Model berhasil diperbarui. File lama disimpan di `{backup_path}`."
|
||||
)
|
||||
else:
|
||||
st.session_state["_merge_success_msg"] = (
|
||||
"Model berhasil diperbarui (belum ada file lama untuk di-backup)."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render() -> None:
|
||||
st.markdown(
|
||||
"""
|
||||
<style>
|
||||
[data-testid="stElementToolbar"] button svg {
|
||||
transform: scale(1.3) !important;
|
||||
}
|
||||
[data-testid="stElementToolbar"] button {
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
[data-testid="stElementToolbar"] {
|
||||
padding: 4px 6px !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
st.title("Perbandingan Konfigurasi")
|
||||
|
||||
all_results = st.session_state.get("all_results", {})
|
||||
if not all_results:
|
||||
st.error("Data model belum dimuat.")
|
||||
return
|
||||
|
||||
tab1, tab2, tab3 = st.tabs(["📊 Evaluasi Metrik", "🕘 Status Model", "📤 Perbarui Model"])
|
||||
|
||||
with tab1:
|
||||
_render_metric_table(all_results)
|
||||
st.divider()
|
||||
_render_hyperparameter_table(all_results)
|
||||
|
||||
with tab2:
|
||||
_render_status_table(all_results)
|
||||
|
||||
with tab3:
|
||||
_render_upload_panel(all_results)
|
||||
Loading…
Reference in New Issue