TIF_E41253317/utils/pdf_report.py

432 lines
19 KiB
Python

"""
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 &amp; 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> &mdash; 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()