432 lines
19 KiB
Python
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 & 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() |