""" 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 peramalan sebagai acuan utama dalam perencanaan jumlah stok. " "Siapkan stok cadangan untuk mengantisipasi kemungkinan permintaan naik hingga " "estimasi atas peramalan.", 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( "Catatan: Skenario optimal produk ini memakai transformasi logaritma. Grafik di bagian " "Ringkasan sudah dalam satuan asli, tapi grafik dekomposisi di bawah ini masih dalam " "skala log — 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()