Bab 15 — Otomasi Dokumen Kantor: Dari Data Menjadi Laporan Excel dan PDF
Seri B3 — Program Kecil yang Berguna
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.

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;.activeadalah lembar pertamanya.lembar.append([...])menambah satu baris di bawah baris terakhir.Font(bold=True)danPatternFill(...)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, selD6.
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 ExcelDaftar 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.
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.ExcelWritermenulis beberapa lembar dalam satu berkas;read_excel(sheet_name=None)membaca semua lembar sekaligus sebagai dict.- Kolom
sehatkembali bertipebool, 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:
pypdfmengambil teks, tetapi struktur tabelnya hilang: tiap sel menjadi satu baris (22 baris, dan sel-sel tampil berurutan,Kandang,Jenis,Umur (bulan), …).pdfplumbermengenali 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).
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 (jumlahnyabanyak, 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.xlsxdanlaporan.pdfterbentuk, dan email berlampiran keduanya siap, belum dikirim. Kami juga membukalaporan.xlsxdengan 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
- Ubah
01_excel_tulis.py: tambahkan kolom “Hasil per 100 hari (kg)” yang diisi Python (bukan rumus), yaituhasil_kg / umur_hari * 100, dibulatkan dua desimal. - Pakai
pandasuntuk membacaikan.csvhasil Bab 14 dan menulisikan.xlsxdengan satu lembar bernama “Ikan”. Buka denganread_exceldan cetak rata-ratabobot_rata_g. - Ubah
11_proyek_laporan.pyagar laporan PDF-nya juga memuat tabel ringkasan per plot hutan (jumlah pohon dan rata-rata diameter). - Tantangan: buat skrip yang menggabungkan
laporan.pdfdari proyek dengan satu PDF sampul buatan Anda sendiri (satu halaman judul) menjadilaporan_lengkap.pdf, lalu periksa denganpypdfbahwa halaman pertama adalah sampul.
Ringkasan Bab
- Excel:
openpyxl(lembar, gaya, rumus, grafik) danpandas(tabel); openpyxl tidak menghitung rumus. - Word:
python-docxuntuk judul, paragraf, dan tabel. - PDF:
reportlabmembuat;pypdfmembaca teks, menggabung, dan memisah;pdfplumbermengambil tabel dari PDF buatan mesin. - Email:
email.message.EmailMessagemenyusun 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.