PRESUNI_BPOM / src /report_generator.py
Expanic's picture
Deploy BPOM Compliance App
86f1108
Raw
History Blame Contribute Delete
14.4 kB
"""
BPOM Compliance System β€” Step 7: Report Generator
Purpose:
Generate final compliance reports in PDF format using FPDF2.
Includes:
- Header with product info
- Test results table (PASS highlighted green, FAIL highlighted red)
- Violations section with pasal references
- Recommendations
- Legal basis section
Usage:
python src/report_generator.py
"""
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
from fpdf import FPDF
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ─── PDF Report Class ───────────────────────────────────────────────────────
class BPOMReport(FPDF):
"""Custom FPDF class for BPOM compliance reports."""
def __init__(self):
super().__init__()
self.set_auto_page_break(auto=True, margin=20)
def header(self):
"""Page header with title."""
self.set_font("Helvetica", "B", 14)
self.cell(0, 10, "LAPORAN COMPLIANCE BPOM", border=0, align="C", new_x="LMARGIN", new_y="NEXT")
self.set_font("Helvetica", "", 9)
self.cell(0, 5, "Sistem Cerdas Pemeriksaan Kepatuhan Registrasi Produk Pangan", border=0, align="C", new_x="LMARGIN", new_y="NEXT")
self.line(10, self.get_y() + 2, 200, self.get_y() + 2)
self.ln(5)
def footer(self):
"""Page footer with timestamp and page number."""
self.set_y(-15)
self.set_font("Helvetica", "I", 8)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.cell(0, 10, f"Generated: {timestamp} | Page {self.page_no()}/{{nb}}", align="C")
# ─── Report Generation ──────────────────────────────────────────────────────
def generate_pdf_report(
extracted_data: dict,
category: str,
compliance_result: dict,
narration: str = "",
output_path: str = "laporan_compliance.pdf",
) -> str:
"""
Generate a PDF compliance report.
Args:
extracted_data: parsed lab data
category: product category
compliance_result: full result from rule engine
narration: LLM-generated narration text
output_path: where to save the PDF
Returns:
Path to generated PDF file
"""
logger.info(f"πŸ“„ Generating PDF report: {output_path}")
pdf = BPOMReport()
pdf.alias_nb_pages()
pdf.add_page()
# ── Product Info Section ─────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "INFORMASI PRODUK", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
info_items = [
("Nama Produk", extracted_data.get("nama_produk", "N/A")),
("Perusahaan", extracted_data.get("perusahaan", "N/A")),
("Tanggal Uji", extracted_data.get("tanggal_uji", "N/A")),
("Kategori", category),
("Status", compliance_result.get("overall_status", "N/A")),
]
for label, value in info_items:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(45, 7, f"{label}:", new_x="RIGHT")
pdf.set_font("Helvetica", "", 10)
pdf.cell(0, 7, str(value), new_x="LMARGIN", new_y="NEXT")
pdf.ln(5)
# ── Executive Summary ────────────────────────────────────────────────
overall = compliance_result.get("overall_status", "N/A")
violations = compliance_result.get("violations", [])
passed = compliance_result.get("passed", [])
missing = compliance_result.get("missing", [])
total = len(violations) + len(passed) + len(missing)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "RINGKASAN EKSEKUTIF", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
if overall == "FAIL":
pdf.set_text_color(200, 0, 0)
summary = (
f"TIDAK MEMENUHI STANDAR. Dari {total} parameter yang diperiksa, "
f"{len(violations)} parameter tidak memenuhi persyaratan BPOM."
)
else:
pdf.set_text_color(0, 128, 0)
summary = (
f"MEMENUHI STANDAR. Seluruh {total} parameter yang diperiksa "
f"memenuhi persyaratan BPOM yang berlaku."
)
pdf.multi_cell(0, 6, summary)
pdf.set_text_color(0, 0, 0)
pdf.ln(5)
# ── Test Results Table ───────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "DETAIL HASIL UJI", new_x="LMARGIN", new_y="NEXT")
# Table header
col_widths = [45, 35, 35, 25, 50]
headers = ["Parameter", "Nilai", "Batas", "Status", "Pasal"]
pdf.set_font("Helvetica", "B", 9)
pdf.set_fill_color(220, 220, 220)
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 7, h, border=1, fill=True)
pdf.ln()
# Table rows β€” PASS items
pdf.set_font("Helvetica", "", 8)
all_results = passed + violations + missing
for r in all_results:
param = str(r.get("param", ""))[:20]
found = str(r.get("found", ""))[:15]
threshold = str(r.get("threshold_max", r.get("required", "")))[:15]
status = r.get("status", "")
pasal = str(r.get("pasal", ""))[:25]
# Color coding
if status == "FAIL":
pdf.set_fill_color(255, 200, 200) # Red background
elif status == "PASS":
pdf.set_fill_color(200, 255, 200) # Green background
else:
pdf.set_fill_color(255, 255, 200) # Yellow background
pdf.cell(col_widths[0], 6, param, border=1, fill=True)
pdf.cell(col_widths[1], 6, found, border=1, fill=True)
pdf.cell(col_widths[2], 6, threshold, border=1, fill=True)
pdf.cell(col_widths[3], 6, status, border=1, fill=True)
pdf.cell(col_widths[4], 6, pasal, border=1, fill=True)
pdf.ln()
pdf.ln(5)
# ── Violations Detail ────────────────────────────────────────────────
if violations:
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "TEMUAN KETIDAKSESUAIAN", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
for i, v in enumerate(violations, 1):
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(200, 0, 0)
pdf.cell(0, 6, f"{i}. {v.get('param', 'N/A')}", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(0, 0, 0)
pdf.set_font("Helvetica", "", 9)
message = v.get("message", "")
# Sanitize for Latin-1 encoding
message = message.encode("latin-1", errors="replace").decode("latin-1")
pdf.multi_cell(0, 5, f" {message}")
pdf.ln(2)
pdf.ln(3)
# ── AI Narration ─────────────────────────────────────────────────────
if narration:
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "ANALISIS DAN REKOMENDASI", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
# Sanitize narration for Latin-1
safe_narration = narration.encode("latin-1", errors="replace").decode("latin-1")
pdf.multi_cell(0, 5, safe_narration)
pdf.ln(5)
# ── Legal Basis ──────────────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "DASAR HUKUM", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
regulations = set()
for r in all_results:
reg = r.get("regulation", "")
if reg:
regulations.add(reg)
if not regulations:
regulations = {
"PerBPOM No. 13 Tahun 2019 tentang Batas Maksimal Cemaran Mikroba dalam Pangan Olahan",
"Peraturan BPOM Nomor 9 Tahun 2022 tentang Persyaratan Cemaran Logam Berat dalam Pangan Olahan",
"Peraturan BPOM Nomor 22 Tahun 2021 tentang Tata Cara Penerbitan Izin CPPOB",
}
for i, reg in enumerate(sorted(regulations), 1):
safe_reg = reg.encode("latin-1", errors="replace").decode("latin-1")
pdf.cell(0, 5, f"{i}. {safe_reg}", new_x="LMARGIN", new_y="NEXT")
# ── Save PDF ─────────────────────────────────────────────────────────
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
pdf.output(output_path)
logger.info(f"βœ… PDF report saved: {output_path}")
return output_path
# ─── Markdown Report ─────────────────────────────────────────────────────────
def generate_markdown_report(
extracted_data: dict,
category: str,
compliance_result: dict,
narration: str = "",
) -> str:
"""
Generate a Markdown-formatted compliance report (for Gradio display).
"""
violations = compliance_result.get("violations", [])
passed = compliance_result.get("passed", [])
missing = compliance_result.get("missing", [])
overall = compliance_result.get("overall_status", "N/A")
total = len(violations) + len(passed) + len(missing)
status_icon = "❌ TIDAK MEMENUHI" if overall == "FAIL" else "βœ… MEMENUHI"
md = f"""# LAPORAN COMPLIANCE BPOM
## Informasi Produk
| Field | Value |
|---|---|
| Nama Produk | {extracted_data.get('nama_produk', 'N/A')} |
| Perusahaan | {extracted_data.get('perusahaan', 'N/A')} |
| Tanggal Uji | {extracted_data.get('tanggal_uji', 'N/A')} |
| Kategori | {category} |
| **Status** | **{status_icon}** |
## Ringkasan Eksekutif
Dari **{total}** parameter yang diperiksa: **{len(passed)}** PASS, **{len(violations)}** FAIL, **{len(missing)}** MISSING.
## Detail Hasil Uji
| Parameter | Nilai | Batas | Status | Pasal |
|---|---|---|---|---|
"""
for r in passed:
md += f"| {r.get('param', '')} | {r.get('found', '')} | {r.get('threshold_max', r.get('required', ''))} | βœ… PASS | {r.get('pasal', '')} |\n"
for r in violations:
md += f"| **{r.get('param', '')}** | **{r.get('found', '')}** | **{r.get('threshold_max', r.get('required', ''))}** | ❌ FAIL | **{r.get('pasal', '')}** |\n"
for r in missing:
md += f"| {r.get('param', '')} | {r.get('found', '')} | - | ⚠️ MISSING | {r.get('pasal', '')} |\n"
if violations:
md += "\n## Temuan Ketidaksesuaian\n\n"
for i, v in enumerate(violations, 1):
md += f"{i}. **{v.get('param', '')}**: {v.get('message', '')}\n\n"
if narration:
md += f"\n## Analisis dan Rekomendasi\n\n{narration}\n"
md += f"\n---\n*Laporan dihasilkan pada {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n"
return md
# ─── Standalone Test ─────────────────────────────────────────────────────────
def main():
"""Test report generation with sample data."""
print("=" * 60)
print("REPORT GENERATOR TEST")
print("=" * 60)
sample_data = {
"nama_produk": "Vita-X Suplemen Vitamin C",
"perusahaan": "PT Maju Sehat Indonesia",
"tanggal_uji": "2024-03-15",
}
sample_result = {
"overall_status": "FAIL",
"violations": [
{
"param": "ALT",
"status": "FAIL",
"found": 2500000.0,
"threshold_max": 100000.0,
"unit": "CFU/g",
"pasal": "Lampiran I Tabel 1",
"regulation": "PerBPOM No. 13 Tahun 2019",
"message": "ALT = 2500000.0 CFU/g MELEBIHI batas max 100000.0 CFU/g",
},
{
"param": "Timbal_Pb",
"status": "FAIL",
"found": 3.5,
"threshold_max": 2.0,
"unit": "mg/kg",
"pasal": "Lampiran Tabel 1",
"regulation": "PerBPOM No. 9 Tahun 2022",
"message": "Timbal_Pb = 3.5 mg/kg MELEBIHI batas max 2.0 mg/kg",
},
],
"passed": [
{"param": "E_coli", "status": "PASS", "found": "negatif", "threshold_max": None, "required": "negatif", "unit": "/g", "pasal": "Lampiran I Tabel 1", "regulation": "PerBPOM No. 13 Tahun 2019"},
{"param": "Salmonella", "status": "PASS", "found": "negatif", "threshold_max": None, "required": "negatif", "unit": "/25g", "pasal": "Lampiran I Tabel 1", "regulation": "PerBPOM No. 13 Tahun 2019"},
{"param": "Kadmium_Cd", "status": "PASS", "found": 0.8, "threshold_max": 1.0, "unit": "mg/kg", "pasal": "Lampiran Tabel 1", "regulation": "PerBPOM No. 9 Tahun 2022"},
],
"missing": [],
}
narration = "Ditemukan 2 pelanggaran pada produk suplemen. ALT melebihi batas 25x lipat. Timbal melebihi batas 1.75x."
# Test PDF generation
output_pdf = "test_laporan_compliance.pdf"
pdf_path = generate_pdf_report(sample_data, "SUPLEMEN", sample_result, narration, output_pdf)
print(f"\nβœ… PDF generated: {pdf_path}")
# Test Markdown generation
md_report = generate_markdown_report(sample_data, "SUPLEMEN", sample_result, narration)
print(f"\nπŸ“ Markdown Report Preview:\n")
print(md_report[:500])
# Clean up test file
if os.path.exists(output_pdf):
print(f"\nβœ… PDF file exists ({os.path.getsize(output_pdf)} bytes)")
print("\nβœ… Report generator test complete!")
if __name__ == "__main__":
main()