Bab 15 — Otomasi Dokumen Kantor: Dari Data Menjadi Laporan Excel dan PDF

Seri B3 — Program Kecil yang Berguna

CatatanDi bab ini, Anda akan:
  • Membuat dan membaca berkas Excel dengan openpyxl dan pandas
  • Membuat dan membaca berkas Word dengan python-docx
  • Membuat PDF dengan reportlab, membaca dan menggabungkannya dengan pypdf, dan mengambil tabelnya dengan pdfplumber
  • Menyusun objek email dengan lampiran, dan mengujinya ke server SMTP lokal tiruan (tanpa mengirim email sungguhan)
  • Menyelesaikan proyek Seri B3: rekap data lapangan menjadi laporan Excel dan PDF

Semua skrip bab ini ada di folder kode/bab15/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip). Berkas hasil ditulis ke subfolder _tmp/.

Setiap akhir pekan, seseorang di kantor lapangan menyalin angka dari tabel ke Excel, memformatnya, menjadikannya PDF, lalu menempelkannya ke email. Pekerjaan itu membosankan, mudah salah, dan memakan waktu yang sama setiap minggu. Python bisa melakukannya dalam hitungan detik. Itulah otomasi dokumen kantor: program yang membuat dokumen, bukan manusia yang mengetik ulang.

Alur proyek bab ini: CSV lapangan dimuat dengan tahan salah, diringkas, lalu keluar sebagai Excel, PDF, dan lampiran email.

Memasang pustaka

Bab ini memakai pustaka di luar pustaka standar (Bab 11). Perintah pemasangannya (diketik di terminal) hanya contoh dan tidak dijalankan saat buku ini dibuat, karena memasang paket mengubah komputer pembaca:

python -m pip install openpyxl pandas python-docx pypdf reportlab pdfplumber aiosmtpd
Pustaka Kegunaan di bab ini
openpyxl membaca dan menulis berkas Excel .xlsx (lembar, gaya, rumus, grafik)
pandas mengolah tabel, lalu membaca dan menulis Excel dengan satu baris
python-docx membuat dan membaca berkas Word .docx
reportlab membuat PDF dari nol
pypdf membaca teks PDF, serta menggabung dan memisah halaman
pdfplumber mengambil tabel dari PDF
aiosmtpd server SMTP tiruan untuk menguji pengiriman email secara lokal

Semuanya kami jalankan di komputer penulis (Python 3.14) sebelum ditulis di sini; keluaran di buku adalah hasil asli. Semua pustaka ini tidak membutuhkan Microsoft Office; berkas yang dihasilkan bisa dibuka di Excel atau LibreOffice.

Excel dengan openpyxl

01_excel_tulis.py
# Bab 15 - Excel dengan openpyxl: membuat buku kerja, judul tebal, angka, dan rumus
from pathlib import Path

from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill

from data_inti import tanaman

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

buku = Workbook()
lembar = buku.active
lembar.title = "Panen"

lembar.append(["Komoditas", "Blok", "Umur (hari)", "Hasil (kg)"])      # baris judul
for t in tanaman:
    lembar.append([t["komoditas"], t["blok"], t["umur_hari"], t["hasil_kg"]])

for sel in lembar[1]:                                                  # gaya baris judul
    sel.font = Font(bold=True)
    sel.fill = PatternFill("solid", start_color="D7ECCF")
lembar.column_dimensions["A"].width = 14

akhir = lembar.max_row
lembar[f"C{akhir + 1}"] = "Total"
lembar[f"D{akhir + 1}"] = f"=SUM(D2:D{akhir})"                         # rumus Excel sebagai teks
lembar[f"D{akhir + 1}"].number_format = "#,##0.0"

buku.save(tmp / "panen.xlsx")
print("Baris terisi:", lembar.max_row, "| kolom:", lembar.max_column)
print("Isi sel D6  :", lembar["D6"].value)
Baris terisi: 6 | kolom: 4
Isi sel D6  : =SUM(D2:D5)
  • Workbook() membuat buku kerja baru; .active adalah lembar pertamanya.
  • lembar.append([...]) menambah satu baris di bawah baris terakhir.
  • Font(bold=True) dan PatternFill(...) memberi gaya pada baris judul.
  • Rumus Excel ditulis sebagai teks yang diawali =. Baris data ada di 2 sampai 5, jadi total ditaruh di baris 6, sel D6.

Pada kolom D6, Python hanya menyimpan teks rumusnya. Siapa yang menghitung? Excel atau LibreOffice, saat berkas dibuka. Kami membuktikannya dengan mengubah berkas itu ke CSV lewat LibreOffice: baris terakhirnya berisi total 1030 (sama dengan hasil Bab 11).

02_excel_baca.py
# Bab 15 - Membaca Excel: nilai rumus hanya ada bila Excel pernah menghitungnya
from pathlib import Path

from openpyxl import Workbook, load_workbook

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "baca.xlsx"

buku = Workbook()
ws = buku.active
ws.append(["blok", "hasil_kg"])
for baris in [["A", 245.5], ["B", 310.0], ["C", 198.5]]:
    ws.append(baris)
ws["B5"] = "=SUM(B2:B4)"
buku.save(berkas)

dibuka = load_workbook(berkas)                        # bawaan: rumus dibaca sebagai teks
print("Daftar lembar:", dibuka.sheetnames)
for baris in dibuka.active.iter_rows(min_row=2, values_only=True):
    print(baris)

print("Rumus     :", dibuka.active["B5"].value)
nilai = load_workbook(berkas, data_only=True)         # minta HASIL hitungan terakhir
print("Hasilnya  :", nilai.active["B5"].value)         # None: file ini belum pernah dibuka Excel
Daftar lembar: ['Sheet']
('A', 245.5)
('B', 310)
('C', 198.5)
(None, '=SUM(B2:B4)')
Rumus     : =SUM(B2:B4)
Hasilnya  : None

Bila dibuka biasa, sel B5 berisi teks rumusnya. Bila dibuka dengan data_only=True, ia memberi hasil hitungan terakhir yang disimpan Excel; dokumentasi openpyxl menyatakan “nilai yang tersimpan saat Excel terakhir membaca lembar”. Karena berkas ini baru dibuat oleh Python dan belum pernah dibuka di Excel, hasilnya None. Hal kecil lain yang perlu Anda tahu: 310.0 dibaca kembali sebagai 310 (bilangan bulat), sebab Excel tidak membedakan keduanya.

PeringatanAWAS

openpyxl tidak menghitung rumus. Bila yang Anda perlukan adalah angkanya (untuk dipakai program lain atau dimasukkan ke PDF), hitung di Python dan tulis angkanya. Tulis rumus hanya bila pembaca di Excel perlu melihat atau mengubah rumusnya.

Grafik di dalam Excel

03_excel_grafik.py
# Bab 15 - Lembar rekap dengan diagram batang bawaan Excel (openpyxl.chart)
import zipfile
from pathlib import Path

from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference

from data_inti import tanaman, ikan, ternak

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "rekap_grafik.xlsx"

total_panen = sum(t["hasil_kg"] for t in tanaman)
total_ikan = sum(i["jumlah"] * i["bobot_rata_g"] / 1000 for i in ikan)
total_ternak = sum(t["bobot_kg"] for t in ternak)

buku = Workbook()
ws = buku.active
ws.title = "Rekap"
ws.append(["Sektor", "Total (kg)"])
ws.append(["Panen kebun dan sawah", total_panen])
ws.append(["Biomassa ikan", round(total_ikan, 1)])
ws.append(["Bobot ternak", total_ternak])

grafik = BarChart()
grafik.title = "Total per sektor"
grafik.y_axis.scaling.min = 0                     # sumbu mulai dari nol agar jujur
grafik.add_data(Reference(ws, min_col=2, min_row=1, max_row=4), titles_from_data=True)
grafik.set_categories(Reference(ws, min_col=1, min_row=2, max_row=4))
ws.add_chart(grafik, "D2")
buku.save(berkas)

with zipfile.ZipFile(berkas) as z:                    # .xlsx sebenarnya zip berisi XML
    print("Berkas grafik di dalam xlsx:", [n for n in z.namelist() if "chart" in n and n.endswith(".xml")])
print("Jumlah grafik di lembar:", len(ws._charts))
print("Isi tabel:", [tuple(b) for b in ws.iter_rows(min_row=2, values_only=True)])
Berkas grafik di dalam xlsx: ['xl/charts/chart1.xml']
Jumlah grafik di lembar: 1
Isi tabel: [('Panen kebun dan sawah', 1030.0), ('Biomassa ikan', 1095.4), ('Bobot ternak', 1190.0)]

Grafik batang dibuat dari BarChart dan dua Reference (rentang data dan rentang label). scaling.min = 0 memaksa sumbu mulai dari nol: tanpa itu, Excel bisa memilih sumbu mulai dari angka 950 sehingga batang tampak berbeda jauh padahal selisihnya kecil. Berkas .xlsx sebenarnya zip berisi XML; keluaran menunjukkan xl/charts/chart1.xml ada di dalamnya.

Excel dengan pandas

Untuk tabel, pandas lebih ringkas. Satu DataFrame adalah tabel di memori:

04_pandas_excel.py
# Bab 15 - pandas: CSV ke ringkasan ke Excel (beberapa lembar) dan kembali
from pathlib import Path

import pandas as pd

from data_inti import pohon, ikan

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

df_pohon = pd.DataFrame(pohon)
df_ikan = pd.DataFrame(ikan)
df_ikan["biomassa_kg"] = df_ikan["jumlah"] * df_ikan["bobot_rata_g"] / 1000

rekap_plot = df_pohon.groupby("plot")["diameter_cm"].agg(["count", "mean"]).round(2)
print(rekap_plot.to_string())

berkas = tmp / "pandas_rekap.xlsx"
with pd.ExcelWriter(berkas, engine="openpyxl") as penulis:       # satu berkas, banyak lembar
    df_pohon.to_excel(penulis, sheet_name="Pohon", index=False)
    df_ikan.to_excel(penulis, sheet_name="Ikan", index=False)
    rekap_plot.to_excel(penulis, sheet_name="Rekap plot")

kembali = pd.read_excel(berkas, sheet_name=None)                 # None = semua lembar sekaligus
print("Lembar:", list(kembali))
print("Biomassa total ikan:", kembali["Ikan"]["biomassa_kg"].sum(), "kg")
print("Tipe kolom sehat   :", kembali["Pohon"]["sehat"].dtype)
      count   mean
plot              
A         2  38.85
B         2  39.75
C         2  38.40
Lembar: ['Pohon', 'Ikan', 'Rekap plot']
Biomassa total ikan: 1095.4 kg
Tipe kolom sehat   : bool
  • DataFrame(pohon) mengubah list dict menjadi tabel.
  • groupby("plot") mengelompokkan per plot, agg(["count", "mean"]) menghitung jumlah dan rata-rata. Plot A: 2 pohon, rata-rata 38,85 cm.
  • pd.ExcelWriter menulis beberapa lembar dalam satu berkas; read_excel(sheet_name=None) membaca semua lembar sekaligus sebagai dict.
  • Kolom sehat kembali bertipe bool, dan biomassa total 1095,4 kg (432,4 + 375,0 + 288,0). (Pandas dibahas penuh di Seri I2.)

Word dengan python-docx

05_word_docx.py
# Bab 15 - Word dengan python-docx: judul, paragraf, tabel, lalu dibaca kembali
from pathlib import Path

from docx import Document

from data_inti import ikan

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "laporan_kolam.docx"

dok = Document()
dok.add_heading("Laporan Kolam (data contoh)", level=1)
dok.add_paragraph("Rekap biomassa tiga kolam pada awal Oktober. Angka fiktif untuk latihan.")

tabel = dok.add_table(rows=1, cols=3)
tabel.style = "Table Grid"
for sel, judul in zip(tabel.rows[0].cells, ["Kolam", "Jenis", "Biomassa (kg)"]):
    sel.text = judul
for i in ikan:
    baris = tabel.add_row().cells
    baris[0].text = i["kolam"]
    baris[1].text = i["jenis"]
    baris[2].text = f"{i['jumlah'] * i['bobot_rata_g'] / 1000:.1f}"
dok.save(berkas)

dibaca = Document(berkas)                             # buka lagi dan periksa
print("Paragraf:", [p.text for p in dibaca.paragraphs])
print("Jumlah tabel:", len(dibaca.tables), "| baris:", len(dibaca.tables[0].rows))
for baris in dibaca.tables[0].rows:
    print([sel.text for sel in baris.cells])
Paragraf: ['Laporan Kolam (data contoh)', 'Rekap biomassa tiga kolam pada awal Oktober. Angka fiktif untuk latihan.']
Jumlah tabel: 1 | baris: 4
['Kolam', 'Jenis', 'Biomassa (kg)']
['A', 'lele', '432.4']
['B', 'nila', '375.0']
['C', 'lele', '288.0']

Document() membuat dokumen kosong; add_heading, add_paragraph, dan add_table menambah isinya. Setelah disimpan, kita membukanya lagi dan membaca paragraf serta sel tabelnya: itulah buktinya berkas tidak kosong. Gaya "Table Grid" (tabel bergaris) ada di templat bawaan python-docx; kami mengujinya langsung.

PDF: membuat dengan reportlab

PDF dirancang untuk dibaca, bukan disunting. Untuk membuatnya dari Python, reportlab menyediakan “platypus”: Anda menyusun daftar elemen (judul, paragraf, tabel), lalu build. Pembuat dasarnya ada di modul laporan_pdf.py (lihat folder kode/bab15/).

06_pdf_buat.py
# Bab 15 - PDF dengan reportlab: membuat laporan satu halaman
from pathlib import Path

from laporan_pdf import buat_pdf
from data_inti import ternak

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "ternak.pdf"

tabel = [["Kandang", "Jenis", "Umur (bulan)", "Bobot (kg)"]]
for t in ternak:
    tabel.append([t["kandang"], t["jenis"], str(t["umur_bulan"]), f"{t['bobot_kg']:.1f}"])
buat_pdf(berkas, "Laporan Ternak", tabel, "Bobot ternak per kandang (angka contoh).")

print("PDF dibuat:", berkas.name, "| awal berkas:", berkas.read_bytes()[:8])
PDF dibuat: ternak.pdf | awal berkas: b'%PDF-1.4'

Awal berkas %PDF-1.4 adalah tanda pengenal format PDF.

PDF: membaca dan mengambil tabel

07_pdf_baca.py
# Bab 15 - Membaca PDF: teks dengan pypdf, tabel dengan pdfplumber
from pathlib import Path

import pdfplumber
from pypdf import PdfReader

from laporan_pdf import buat_pdf
from data_inti import ternak

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "ternak_baca.pdf"
tabel = [["Kandang", "Jenis", "Umur (bulan)", "Bobot (kg)"]]
tabel += [[t["kandang"], t["jenis"], str(t["umur_bulan"]), f"{t['bobot_kg']:.1f}"] for t in ternak]
buat_pdf(berkas, "Laporan Ternak", tabel, "Bobot ternak per kandang (angka contoh).")

pembaca = PdfReader(berkas)
print("Jumlah halaman:", len(pembaca.pages))
print("Judul metadata:", pembaca.metadata.title)
print("--- teks (pypdf) ---")
baris_teks = pembaca.pages[0].extract_text().splitlines()
print(len(baris_teks), "baris; 7 pertama:", baris_teks[:7])

print("--- tabel (pdfplumber) ---")
with pdfplumber.open(berkas) as pdf:
    for baris in pdf.pages[0].extract_tables()[0]:
        print(baris)
Jumlah halaman: 1
Judul metadata: Laporan Ternak
--- teks (pypdf) ---
22 baris; 7 pertama: ['Laporan Ternak', 'Bobot ternak per kandang (angka contoh).', 'Kandang', 'Jenis', 'Umur (bulan)', 'Bobot (kg)', 'K1']
--- tabel (pdfplumber) ---
['Kandang', 'Jenis', 'Umur (bulan)', 'Bobot (kg)']
['K1', 'sapi', '36', '350.0']
['K1', 'sapi', '30', '412.5']
['K2', 'sapi', '42', '389.0']
['K3', 'kambing', '14', '38.5']

Dua alat, dua hasil berbeda:

  • pypdf mengambil teks, tetapi struktur tabelnya hilang: tiap sel menjadi satu baris (22 baris, dan sel-sel tampil berurutan, Kandang, Jenis, Umur (bulan), …).
  • pdfplumber mengenali garis tabel dan mengembalikan tiap baris sebagai list sel: lima baris rapi.

Catatan jujur dari dokumentasi pdfplumber: ia bekerja paling baik pada PDF buatan mesin (seperti PDF yang kita buat), bukan PDF hasil pindai (foto kertas). PDF pindai butuh OCR, yang tidak dibahas di buku ini. Untuk PDF tanpa garis tabel, hasil extract_tables() bisa kurang tepat; selalu periksa.

PDF: menggabung dan memisah

08_pdf_gabung.py
# Bab 15 - Menggabung dan memisah PDF dengan pypdf
from pathlib import Path

from pypdf import PdfReader, PdfWriter

from laporan_pdf import buat_pdf

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

buat_pdf(tmp / "bagian_hutan.pdf", "Bagian Hutan", [["Plot", "Pohon"], ["A", "2"], ["B", "2"]])
buat_pdf(tmp / "bagian_kolam.pdf", "Bagian Kolam", [["Kolam", "kg"], ["A", "432.4"], ["B", "375.0"]])

penulis = PdfWriter()
for nama in ["bagian_hutan.pdf", "bagian_kolam.pdf"]:
    penulis.append(str(tmp / nama))                  # tambahkan semua halaman berkas itu
penulis.add_metadata({"/Title": "Laporan Gabungan"})
with open(tmp / "gabungan.pdf", "wb") as f:
    penulis.write(f)

gabungan = PdfReader(tmp / "gabungan.pdf")
print("Halaman gabungan:", len(gabungan.pages))
for no, halaman in enumerate(gabungan.pages, start=1):
    print(f"  halaman {no}:", halaman.extract_text().splitlines()[0])

satu = PdfWriter()                                   # memisah: ambil halaman ke-2 saja
satu.add_page(gabungan.pages[1])
with open(tmp / "halaman2.pdf", "wb") as f:
    satu.write(f)
print("Halaman hasil pisah:", len(PdfReader(tmp / "halaman2.pdf").pages))
Halaman gabungan: 2
  halaman 1: Bagian Hutan
  halaman 2: Bagian Kolam
Halaman hasil pisah: 1

PdfWriter.append() menambahkan seluruh halaman berkas lain; dua PDF satu halaman menjadi satu PDF dua halaman. Memisah dilakukan dengan memilih halaman (pages[1] adalah halaman kedua, karena penomoran dari 0) dan menulisnya sebagai berkas baru.

Email: membuat pesan

Python punya modul email.message untuk menyusun surel, termasuk lampiran:

09_email_objek.py
# Bab 15 - Membuat objek email dengan email.message (TIDAK dikirim ke mana pun)
from email.message import EmailMessage
from pathlib import Path

from laporan_pdf import buat_pdf

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
lampiran = tmp / "rekap.pdf"
buat_pdf(lampiran, "Rekap Mingguan", [["Sektor", "Total"], ["Panen (kg)", "1030.0"]])

pesan = EmailMessage()
pesan["Subject"] = "Rekap data lapangan mingguan"
pesan["From"] = "pelaporan@contoh.example"          # alamat fiktif (domain .example dicadangkan)
pesan["To"] = "kepala-unit@contoh.example"
pesan.set_content("Selamat pagi,\n\nTerlampir rekap data lapangan minggu ini.\n\nSalam,\nTim Lapangan")
pesan.add_attachment(lampiran.read_bytes(), maintype="application", subtype="pdf",
                     filename="rekap_mingguan.pdf")

print("Subjek    :", pesan["Subject"])
print("Dari/Ke   :", pesan["From"], "->", pesan["To"])
print("Multipart?", pesan.is_multipart())
print("Isi teks  :", pesan.get_body(preferencelist=("plain",)).get_content().splitlines()[0])
for lamp in pesan.iter_attachments():
    print("Lampiran  :", lamp.get_filename(), lamp.get_content_type())
print("Surel ini hanya objek di memori; belum ada yang dikirim.")
Subjek    : Rekap data lapangan mingguan
Dari/Ke   : pelaporan@contoh.example -> kepala-unit@contoh.example
Multipart? True
Isi teks  : Selamat pagi,
Lampiran  : rekap_mingguan.pdf application/pdf
Surel ini hanya objek di memori; belum ada yang dikirim.

Pesan ini adalah objek di memori; belum ada yang dikirim. Alamatnya fiktif, memakai domain .example yang memang dicadangkan untuk contoh. Pesan ber-lampiran otomatis menjadi multipart (True).

PeringatanAWAS

Buku ini tidak mengirim email sungguhan dan kami minta Anda berhati-hati: email otomatis yang salah alamat atau berulang tak terkendali bisa membocorkan data atau membanjiri kotak surat orang. Selalu uji dulu ke server tiruan, dan jangan menulis kata sandi di kode (ambil dari variabel lingkungan).

Email: uji ke server lokal

Cara aman menguji pengiriman: jalankan server SMTP tiruan di komputer sendiri, dan kirim ke sana. Kami memakai aiosmtpd; dokumentasinya menyatakan Controller menjalankan server SMTP di thread terpisah sehingga cocok untuk menyiapkan server dalam pengujian.

10_email_smtp_lokal.py
# Bab 15 - Mengirim email ke server SMTP LOKAL tiruan (hanya di komputer ini, tidak ke internet)
import smtplib
import socket
from email.message import EmailMessage

from aiosmtpd.controller import Controller


class Penampung:
    """Penangan server tiruan: menyimpan setiap surat yang masuk."""
    def __init__(self):
        self.surat = []

    async def handle_DATA(self, server, session, envelope):
        self.surat.append(envelope)
        return "250 Pesan diterima (server tiruan)"


def port_bebas():
    """Meminta sistem operasi memilih satu port kosong di komputer ini."""
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


penampung = Penampung()
port = port_bebas()
server = Controller(penampung, hostname="127.0.0.1", port=port)   # hanya menerima dari komputer ini
server.start()
try:
    pesan = EmailMessage()
    pesan["Subject"] = "Uji kirim lokal"
    pesan["From"] = "pelaporan@contoh.example"
    pesan["To"] = "kepala-unit@contoh.example"
    pesan.set_content("Ini hanya uji ke server tiruan di komputer sendiri.")

    with smtplib.SMTP("127.0.0.1", port) as klien:
        klien.send_message(pesan)
finally:
    server.stop()

masuk = penampung.surat[0]
print("Surat diterima server tiruan:", len(penampung.surat))
print("Pengirim :", masuk.mail_from)
print("Penerima :", masuk.rcpt_tos)
print("Ada subjek uji?", b"Subject: Uji kirim lokal" in masuk.content)
Surat diterima server tiruan: 1
Pengirim : pelaporan@contoh.example
Penerima : ['kepala-unit@contoh.example']
Ada subjek uji? True

Server hanya mendengarkan di 127.0.0.1 (alamat “komputer ini sendiri”), jadi suratnya tidak pernah keluar ke internet. Server tiruan menerima satu surat, dan pengirim, penerima, dan subjek cocok dengan yang kita kirim. Untuk mengirim sungguhan, Anda memakai smtplib.SMTP_SSL dengan alamat server, nama pengguna, dan kata sandi dari penyedia surel Anda. Bentuknya seperti ini (tidak dijalankan di sini karena akan mengirim email sungguhan):

import os, smtplib, ssl
with smtplib.SMTP_SSL(os.environ["SMTP_HOST"], 465, context=ssl.create_default_context()) as s:
    s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])   # nilainya: [disamarkan]
    s.send_message(pesan)

Proyek Bersambung B3: rekap lapangan menjadi laporan

Kini seluruh Seri B3 bekerja bersama. Skrip berikut: (1) menyimpan data lima sektor ke CSV, dan sengaja menambah satu baris rusak pada data ikan, (2) memuatnya kembali dengan skema (Bab 14), (3) menghitung rekap per sektor, (4) membuat laporan Excel (lembar Rekap + grafik + satu lembar per tabel), (5) membuat laporan PDF, dan (6) menyusun email dengan dua lampiran (tidak dikirim).

11_proyek_laporan.py
# Bab 15 - Proyek B3: dari CSV lapangan menjadi laporan Excel dan PDF, plus surel (tidak dikirim)
import logging
import sys
from email.message import EmailMessage
from pathlib import Path

from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.styles import Font, PatternFill

from alat_data import simpan_csv, muat_csv
from data_inti import pohon, tanaman, ikan, ternak
from laporan_pdf import buat_pdf

logging.basicConfig(stream=sys.stdout, level=logging.WARNING, format="%(levelname)s: %(message)s")
kerja = Path(__file__).parent / "_tmp" / "proyek"
kerja.mkdir(parents=True, exist_ok=True)

# 1. Simpan data mentah ke CSV; petugas menambah satu baris rusak di data ikan
tabel = {"pohon": pohon, "tanaman": tanaman, "ikan": ikan, "ternak": ternak}
for nama, baris in tabel.items():
    simpan_csv(kerja / f"{nama}.csv", baris)
with open(kerja / "ikan.csv", "a", newline="", encoding="utf-8") as f:
    f.write("D,nila,banyak,350.0,7.4\n")

# 2. Muat kembali dengan skema (baris rusak ditolak)
bool_teks = lambda s: {"True": True, "False": False}[s]
skema = {
    "pohon": {"jenis": str, "diameter_cm": float, "tinggi_m": float, "sehat": bool_teks, "plot": str},
    "tanaman": {"komoditas": str, "blok": str, "umur_hari": int, "hasil_kg": float},
    "ikan": {"kolam": str, "jenis": str, "jumlah": int, "bobot_rata_g": float, "ph_air": float},
    "ternak": {"kandang": str, "jenis": str, "umur_bulan": int, "bobot_kg": float},
}
data, ditolak = {}, 0
for nama in tabel:
    data[nama], tolak = muat_csv(kerja / f"{nama}.csv", skema[nama])
    ditolak += tolak

# 3. Hitung rekap per sektor
biomassa = sum(i["jumlah"] * i["bobot_rata_g"] / 1000 for i in data["ikan"])
rekap = [
    ["Sektor", "Catatan", "Ukuran", "Nilai"],
    ["Hutan", len(data["pohon"]), "Rata-rata diameter (cm)",
     round(sum(p["diameter_cm"] for p in data["pohon"]) / len(data["pohon"]), 1)],
    ["Hutan", len(data["pohon"]), "Pohon sehat", sum(p["sehat"] for p in data["pohon"])],
    ["Kebun dan sawah", len(data["tanaman"]), "Total panen (kg)", sum(t["hasil_kg"] for t in data["tanaman"])],
    ["Perikanan", len(data["ikan"]), "Biomassa (kg)", round(biomassa, 1)],
    ["Peternakan", len(data["ternak"]), "Total bobot (kg)", sum(t["bobot_kg"] for t in data["ternak"])],
]

# 4. Laporan Excel: lembar Rekap + grafik + satu lembar per tabel
buku = Workbook()
ws = buku.active
ws.title = "Rekap"
for baris in rekap:
    ws.append(baris)
ws.append([])
ws.append([f"Baris data ditolak karena rusak: {ditolak}"])
for sel in ws[1]:
    sel.font = Font(bold=True)
    sel.fill = PatternFill("solid", start_color="D7ECCF")
ws.column_dimensions["A"].width = 18
ws.column_dimensions["C"].width = 26
grafik = BarChart()
grafik.title = "Nilai per ukuran (kg)"
grafik.y_axis.scaling.min = 0
grafik.add_data(Reference(ws, min_col=4, min_row=4, max_row=6), titles_from_data=False)
grafik.set_categories(Reference(ws, min_col=3, min_row=4, max_row=6))
ws.add_chart(grafik, "F2")
for nama, baris in data.items():
    lembar = buku.create_sheet(nama.capitalize())
    lembar.append(list(baris[0]))
    for b in baris:
        lembar.append(list(b.values()))
buku.save(kerja / "laporan.xlsx")

# 5. Laporan PDF
isi_pdf = [rekap[0]] + [[s, str(n), u, str(v)] for s, n, u, v in rekap[1:]]
buat_pdf(kerja / "laporan.pdf", "Rekap Inventarisasi Lapangan", isi_pdf,
         f"Angka contoh. Baris data ditolak karena rusak: {ditolak}.")

# 6. Surel dengan dua lampiran (hanya dibuat, tidak dikirim)
pesan = EmailMessage()
pesan["Subject"] = "Rekap inventarisasi lapangan"
pesan["From"] = "pelaporan@contoh.example"
pesan["To"] = "kepala-unit@contoh.example"
pesan.set_content("Terlampir rekap inventarisasi dalam Excel dan PDF.")
pesan.add_attachment((kerja / "laporan.xlsx").read_bytes(), maintype="application",
                     subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename="laporan.xlsx")
pesan.add_attachment((kerja / "laporan.pdf").read_bytes(), maintype="application",
                     subtype="pdf", filename="laporan.pdf")

print(f"Baris ditolak: {ditolak}")
for baris in rekap[1:]:
    print(f"{baris[0]:16} {baris[1]:>2}  {baris[2]:24} {baris[3]}")
print("Berkas laporan:", sorted(p.name for p in kerja.iterdir() if p.suffix in (".xlsx", ".pdf")))
print("Lampiran surel:", [a.get_filename() for a in pesan.iter_attachments()], "(belum dikirim)")
WARNING: baris 5 di ikan.csv ditolak (ValueError)
Baris ditolak: 1
Hutan             6  Rata-rata diameter (cm)  39.0
Hutan             6  Pohon sehat              5
Kebun dan sawah   4  Total panen (kg)         1030.0
Perikanan         3  Biomassa (kg)            1095.4
Peternakan        4  Total bobot (kg)         1190.0
Berkas laporan: ['laporan.pdf', 'laporan.xlsx']
Lampiran surel: ['laporan.xlsx', 'laporan.pdf'] (belum dikirim)

Telusuri hasilnya:

  • Baris rusak D,nila,banyak,... ditolak (jumlahnya banyak, bukan angka), dan laporan mencatat baris ditolak: 1. Sisa tiga kolam tetap diolah: biomassa 1095,4 kg.
  • Rekap per sektor cocok dengan Bab 11: panen 1030,0 kg, bobot ternak 1190,0 kg, rata-rata diameter 39,0 cm; pohon sehat 5 dari 6.
  • Berkas laporan.xlsx dan laporan.pdf terbentuk, dan email berlampiran keduanya siap, belum dikirim. Kami juga membuka laporan.xlsx dengan LibreOffice (diubah ke PDF) untuk memastikan berkasnya sah dan grafiknya muncul.

Sebuah program otomatis tidak boleh dipercaya begitu saja. Skrip berikutnya membaca kembali laporan yang dihasilkan dan membandingkan Excel dengan PDF:

12_proyek_periksa.py
# Bab 15 - Proyek B3: memeriksa laporan yang dihasilkan dengan membacanya kembali (bukti, bukan asumsi)
import subprocess
import sys
from pathlib import Path

import pdfplumber
from openpyxl import load_workbook
from pypdf import PdfReader

folder = Path(__file__).parent
kerja = folder / "_tmp" / "proyek"
subprocess.run([sys.executable, str(folder / "11_proyek_laporan.py")], capture_output=True, check=True)

buku = load_workbook(kerja / "laporan.xlsx")
print("Lembar Excel:", buku.sheetnames)
rekap = list(buku["Rekap"].iter_rows(min_row=2, max_row=6, values_only=True))
total_excel = {baris[2]: baris[3] for baris in rekap}
print("Excel  - total panen :", total_excel["Total panen (kg)"])
print("Excel  - biomassa    :", total_excel["Biomassa (kg)"])
print("Excel  - baris ikan  :", buku["Ikan"].max_row - 1, "(tanpa baris judul)")

pembaca = PdfReader(kerja / "laporan.pdf")
print("PDF    - halaman     :", len(pembaca.pages))
with pdfplumber.open(kerja / "laporan.pdf") as pdf:
    tabel = pdf.pages[0].extract_tables()[0]
total_pdf = {baris[2]: baris[3] for baris in tabel[1:]}
print("PDF    - total panen :", total_pdf["Total panen (kg)"])
print("PDF    - biomassa    :", total_pdf["Biomassa (kg)"])

# Excel menyimpan 1030.0 sebagai 1030, jadi bandingkan sebagai angka, bukan teks
cocok = all(float(total_excel[k]) == float(total_pdf[k]) for k in ("Total panen (kg)", "Biomassa (kg)"))
print("Excel dan PDF konsisten:", cocok)
Lembar Excel: ['Rekap', 'Pohon', 'Tanaman', 'Ikan', 'Ternak']
Excel  - total panen : 1030
Excel  - biomassa    : 1095.4
Excel  - baris ikan  : 3 (tanpa baris judul)
PDF    - halaman     : 1
PDF    - total panen : 1030.0
PDF    - biomassa    : 1095.4
Excel dan PDF konsisten: True

Excel memiliki lima lembar, tabel ikan berisi 3 baris data, dan angka total di Excel dan PDF sama. Perhatikan satu pelajaran kecil: Excel menyimpan total panen sebagai 1030, sedangkan PDF menulis teks 1030.0. Membandingkan keduanya sebagai teks akan melaporkan “berbeda”, padahal angkanya sama; kami membandingkannya sebagai angka (float). Pemeriksaan otomatis seperti ini membuat Anda berani menjadwalkan laporan berjalan sendiri.

Latihan

  1. Ubah 01_excel_tulis.py: tambahkan kolom “Hasil per 100 hari (kg)” yang diisi Python (bukan rumus), yaitu hasil_kg / umur_hari * 100, dibulatkan dua desimal.
  2. Pakai pandas untuk membaca ikan.csv hasil Bab 14 dan menulis ikan.xlsx dengan satu lembar bernama “Ikan”. Buka dengan read_excel dan cetak rata-rata bobot_rata_g.
  3. Ubah 11_proyek_laporan.py agar laporan PDF-nya juga memuat tabel ringkasan per plot hutan (jumlah pohon dan rata-rata diameter).
  4. Tantangan: buat skrip yang menggabungkan laporan.pdf dari proyek dengan satu PDF sampul buatan Anda sendiri (satu halaman judul) menjadi laporan_lengkap.pdf, lalu periksa dengan pypdf bahwa halaman pertama adalah sampul.

Ringkasan Bab

  • Excel: openpyxl (lembar, gaya, rumus, grafik) dan pandas (tabel); openpyxl tidak menghitung rumus.
  • Word: python-docx untuk judul, paragraf, dan tabel.
  • PDF: reportlab membuat; pypdf membaca teks, menggabung, dan memisah; pdfplumber mengambil tabel dari PDF buatan mesin.
  • Email: email.message.EmailMessage menyusun pesan; uji ke server SMTP lokal tiruan; jangan menulis kata sandi di kode.
  • Laporan otomatis perlu diperiksa otomatis: baca kembali hasilnya dan bandingkan.
  • Proyek B3 lengkap: data lima sektor tersimpan, tahan input salah, dan menjadi laporan Excel/PDF.

Penutup Seri B3

Anda sudah menyelesaikan Seri B3. Program Anda kini bisa meminjam alat dari pustaka (Bab 11), tidak runtuh saat ada yang salah (Bab 12), menyimpan dan membaca berkas (Bab 13), memakai format standar CSV dan JSON (Bab 14), dan membuat laporan Excel dan PDF otomatis (Bab 15). Anda juga sudah punya satu proyek utuh: data hutan, kebun, pertanian, perikanan, dan peternakan yang tersimpan, tahan salah, dan menjadi laporan.

Selamat: Tingkat Basic (Jilid 1) tamat!

Dengan bab ini, Tingkat Basic selesai. Dalam 15 bab, Anda berangkat dari “apa itu komputer” sampai menulis program yang berguna di dunia kerja: variabel, keputusan, perulangan, struktur data, fungsi, modul, penanganan error, berkas, format data, dan otomasi dokumen. Itu fondasi yang sungguh-sungguh, dan banyak pekerjaan kantor sehari-hari sudah bisa Anda otomasi dengannya.

Tetapi lihat sendiri: program proyek kita makin besar, fungsinya makin banyak, dan mulai berantakan: data dan perkakas tercampur, ada salinan berkas data_inti.py di beberapa folder bab. Tingkat Intermediate dimulai dengan cara menata kode supaya tetap rapi dan aman diubah.

Selanjutnya, di Bab 16: OOP (pemrograman berorientasi objek): kelas, objek, pewarisan, dan cara menyusun pohon, kebun, kolam, dan kandang sebagai objek yang punya perilaku sendiri.