Tujuan Praktikum
Setelah menyelesaikan praktikum ini, mahasiswa mampu:
- Menjelaskan pola master–detail dan mengapa penyimpanannya berbeda dari CRUD biasa.
-
Menyimpan satu transaksi ke
dua tabel sekaligus (
penjualandandetail_penjualan). - Memotong stok produk secara otomatis saat transaksi tersimpan.
- Menerapkan rollback agar transaksi yang gagal tidak menyisakan data setengah jadi.
-
Menampilkan riwayat nota dan isinya menggunakan
JOIN, serta menghapus nota beserta pengembalian stok.
Persiapan
Kebutuhan praktikum:
- VS Code
- Project toko-elektronik
- Database database/toko.db dengan 4 tabel (dari Pertemuan 10)
- models/ dan repository/produk_repository.py (dari Pertemuan 11 & 12)
- Virtual environment aktif (venv)
Buka project dan aktifkan venv:
venv\Scripts\activate
macOS / Linux: gunakan
source venv/bin/activate.
Buat file baru repository/transaksi_repository.py.
Struktur project kini:
toko-elektronik/
├── database/toko.db
├── models/
├── repository/
│ ├── produk_repository.py
│ └── transaksi_repository.py <- baru
└── main.py
Konsep Singkat
Mengapa Transaksi Lebih Sulit dari Produk?
Pada Pertemuan 12, CRUD Produk sederhana: satu produk = satu baris di satu tabel.
Transaksi berbeda. Satu nota penjualan berisi banyak barang, sehingga satu transaksi menyentuh dua tabel sekaligus:
| Tabel | Isi | Contoh |
|---|---|---|
penjualan (master) |
Informasi nota | Nota #1, 2025-03-01, Budi, total 8.800.000 |
detail_penjualan (detail)
|
Daftar barang di nota itu | Laptop x1, Mouse x2 |
Selain itu, menyimpan transaksi juga harus memotong stok produk. Jadi ada tiga tabel yang terpengaruh sekali jalan.
Alur Penyimpanan
Perhatikan urutannya wajib: nota (master) harus
disimpan lebih dulu agar mendapat
id_penjualan. Barulah baris detail dapat menunjuk ke
nota tersebut lewat id_penjualan itu.
Konsep Baru: Rollback
Bayangkan keranjang berisi 2 barang. Barang pertama berhasil disimpan dan stoknya dipotong, tetapi barang kedua stoknya tidak cukup. Apa yang terjadi?
Tanpa penanganan khusus: nota sudah terlanjur dibuat, stok barang pertama sudah terlanjur berkurang — padahal transaksinya gagal. Data menjadi kacau.
Solusinya adalah rollback: bila ada satu saja yang gagal, semua perubahan dibatalkan seolah tidak pernah terjadi.
try:
# ... serangkaian perubahan ...
conn.commit() # semua berhasil -> simpan
except Exception:
conn.rollback() # ada yang gagal -> batalkan SEMUA
raise
Prinsipnya: satu transaksi = semua berhasil, atau semua batal. Tidak boleh setengah jadi. Inilah sebabnya database begitu penting dalam aplikasi keuangan.
Langkah 1 - Kerangka Repository
Buka repository/transaksi_repository.py:
import sqlite3
DB_PATH = "database/toko.db"
class TransaksiRepository:
"""Mengurus tabel penjualan (master) dan detail_penjualan (detail)."""
def _koneksi(self):
return sqlite3.connect(DB_PATH)
Langkah 2 - Menyimpan Transaksi (Create)
Inilah method terpenting di pertemuan ini. Tambahkan ke dalam class:
# ---------- CREATE ----------
def simpan(self, tanggal, id_pelanggan, keranjang):
"""
keranjang: list berisi pasangan (id_produk, qty)
Menyimpan nota + detail + memotong stok.
Bila ada satu saja yang gagal, SEMUA dibatalkan.
"""
conn = self._koneksi()
cursor = conn.cursor()
try:
# 1. periksa stok SEMUA barang lebih dulu
for id_produk, qty in keranjang:
cursor.execute(
"SELECT nama_produk, harga, stok FROM produk WHERE id_produk = ?",
(id_produk,),
)
baris = cursor.fetchone()
if baris is None:
raise ValueError(f"Produk id {id_produk} tidak ditemukan")
nama, harga, stok = baris
if qty > stok:
raise ValueError(f"Stok {nama} tidak cukup (tersisa {stok}, diminta {qty})")
# 2. buat nota (MASTER)
cursor.execute(
"INSERT INTO penjualan (tanggal, id_pelanggan, total) VALUES (?, ?, 0)",
(tanggal, id_pelanggan),
)
id_penjualan = cursor.lastrowid # id nota yang baru dibuat
# 3. simpan tiap barang (DETAIL) + potong stok
total = 0
for id_produk, qty in keranjang:
cursor.execute("SELECT harga FROM produk WHERE id_produk = ?", (id_produk,))
harga = cursor.fetchone()[0]
subtotal = harga * qty
total += subtotal
cursor.execute(
"INSERT INTO detail_penjualan (id_penjualan, id_produk, qty, subtotal) "
"VALUES (?, ?, ?, ?)",
(id_penjualan, id_produk, qty, subtotal),
)
cursor.execute(
"UPDATE produk SET stok = stok - ? WHERE id_produk = ?", (qty, id_produk)
)
# 4. simpan total ke nota
cursor.execute(
"UPDATE penjualan SET total = ? WHERE id_penjualan = ?", (total, id_penjualan)
)
conn.commit()
return id_penjualan
except Exception:
conn.rollback() # batalkan SEMUA perubahan
raise
finally:
conn.close() # koneksi selalu ditutup
Empat hal penting:
- Periksa stok semua barang DULU (langkah 1), sebelum menyimpan apa pun. Lebih baik menolak di awal daripada membatalkan di tengah jalan.
-
cursor.lastrowidmemberiid_penjualandari nota yang baru dibuat — inilah penghubung master ke detail. - Harga diambil dari database, bukan dari input pengguna. Ini mencegah manipulasi harga.
-
finallymenjamin koneksi tertutup, baik berhasil maupun gagal.
Uji:
from repository.transaksi_repository import TransaksiRepository
trx_repo = TransaksiRepository()
# Budi (id 1) membeli 1 Laptop (id 1) dan 2 Mouse (id 3)
id_nota = trx_repo.simpan("2025-03-01", 1, [(1, 1), (3, 2)])
print("Nota tersimpan dengan id:", id_nota)
Output:
Nota tersimpan dengan id: 1
Periksa stok — Laptop 10 → 9, Mouse 50 → 48:
from repository.produk_repository import ProdukRepository
for p in ProdukRepository().ambil_semua():
print(p)
Output:
Laptop (Elektronik) - Rp 8,500,000 | stok: 9
Monitor (Elektronik) - Rp 2,000,000 | stok: 15
Mouse (Aksesoris) - Rp 150,000 | stok: 48
Keyboard (Aksesoris) - Rp 250,000 | stok: 40
Langkah 3 - Membaca Nota (Read)
Menampilkan riwayat memerlukan JOIN, karena nama
pelanggan ada di tabel lain.
# ---------- READ ----------
def ambil_semua(self):
"""Daftar semua nota (tanpa detail isinya)."""
conn = self._koneksi()
cursor = conn.cursor()
cursor.execute("""
SELECT p.id_penjualan, p.tanggal, c.nama, p.total
FROM penjualan p
JOIN pelanggan c ON p.id_pelanggan = c.id_pelanggan
ORDER BY p.id_penjualan DESC
""")
hasil = cursor.fetchall()
conn.close()
return hasil
def ambil_detail(self, id_penjualan):
"""Satu nota beserta daftar barangnya."""
conn = self._koneksi()
cursor = conn.cursor()
# informasi nota
cursor.execute("""
SELECT p.id_penjualan, p.tanggal, c.nama, p.total
FROM penjualan p
JOIN pelanggan c ON p.id_pelanggan = c.id_pelanggan
WHERE p.id_penjualan = ?
""", (id_penjualan,))
nota = cursor.fetchone()
if nota is None:
conn.close()
return None, []
# daftar barang di nota tersebut
cursor.execute("""
SELECT pr.nama_produk, d.qty, pr.harga, d.subtotal
FROM detail_penjualan d
JOIN produk pr ON d.id_produk = pr.id_produk
WHERE d.id_penjualan = ?
""", (id_penjualan,))
items = cursor.fetchall()
conn.close()
return nota, items
ambil_detail() mengembalikan
dua hal sekaligus: informasi nota dan daftar
barangnya. Persis seperti bentuk sebuah struk belanja.
Langkah 4 - Mencetak Nota
Buat fungsi di main.py untuk menampilkan nota dengan
rapi:
from repository.transaksi_repository import TransaksiRepository
trx_repo = TransaksiRepository()
def cetak_nota(id_penjualan):
nota, items = trx_repo.ambil_detail(id_penjualan)
if nota is None:
print("Nota tidak ditemukan.")
return
print("\n" + "=" * 52)
print(f"NOTA PENJUALAN #{nota[0]}")
print(f"Tanggal : {nota[1]}")
print(f"Pelanggan : {nota[2]}")
print("-" * 52)
print(f"{'PRODUK':<14}{'QTY':>5}{'HARGA':>14}{'SUBTOTAL':>17}")
print("-" * 52)
for nama, qty, harga, subtotal in items:
print(f"{nama:<14}{qty:>5}{harga:>14,}{subtotal:>17,}")
print("-" * 52)
print(f"{'TOTAL':<19}{nota[3]:>32,}")
print("=" * 52)
cetak_nota(1)
Output:
====================================================
NOTA PENJUALAN #1
Tanggal : 2025-03-01
Pelanggan : Budi Santoso
----------------------------------------------------
PRODUK QTY HARGA SUBTOTAL
----------------------------------------------------
Laptop 1 8,500,000 8,500,000
Mouse 2 150,000 300,000
----------------------------------------------------
TOTAL 8,800,000
====================================================
Langkah 5 - Membatalkan Nota (Delete)
Menghapus nota lebih rumit daripada menghapus produk, karena:
- Baris detail harus dihapus lebih dulu, baru notanya (aturan Foreign Key: anak dulu, induk kemudian).
- Stok harus dikembalikan — barang yang batal terjual kembali ke gudang.
# ---------- DELETE ----------
def hapus(self, id_penjualan):
"""Menghapus nota + detailnya, sekaligus MENGEMBALIKAN stok."""
conn = self._koneksi()
cursor = conn.cursor()
try:
# ambil isi nota dulu (untuk tahu stok apa yang harus dikembalikan)
cursor.execute(
"SELECT id_produk, qty FROM detail_penjualan WHERE id_penjualan = ?",
(id_penjualan,),
)
items = cursor.fetchall()
if not items:
return False # nota tidak ada
# 1. kembalikan stok
for id_produk, qty in items:
cursor.execute(
"UPDATE produk SET stok = stok + ? WHERE id_produk = ?", (qty, id_produk)
)
# 2. hapus DETAIL dulu, baru MASTER
cursor.execute("DELETE FROM detail_penjualan WHERE id_penjualan = ?", (id_penjualan,))
cursor.execute("DELETE FROM penjualan WHERE id_penjualan = ?", (id_penjualan,))
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
Uji:
print("Hapus nota 1:", trx_repo.hapus(1))
for p in ProdukRepository().ambil_semua():
print(p)
Output:
Hapus nota 1: True
Laptop (Elektronik) - Rp 8,500,000 | stok: 10
Monitor (Elektronik) - Rp 2,000,000 | stok: 15
Mouse (Aksesoris) - Rp 150,000 | stok: 50
Keyboard (Aksesoris) - Rp 250,000 | stok: 40
Stok kembali seperti semula (Laptop 10, Mouse 50). Inilah yang dimaksud "membatalkan transaksi".
Catatan: operasi Update pada nota sengaja tidak dibuat. Di dunia nyata, nota yang salah biasanya dibatalkan lalu dibuat ulang, bukan diedit — agar riwayatnya tetap jujur.
Langkah 6 - Aplikasi Transaksi
Rangkai menjadi menu di main.py:
from repository.produk_repository import ProdukRepository
from repository.transaksi_repository import TransaksiRepository
prod_repo = ProdukRepository()
trx_repo = TransaksiRepository()
def transaksi_baru():
print("\n--- Transaksi Baru ---")
# tampilkan produk yang tersedia
for p in prod_repo.ambil_semua():
print(f" {p.id_produk}. {p.nama_produk:<12} Rp {p.harga:>10,} (stok {p.stok})")
id_pelanggan = int(input("\nID pelanggan: "))
tanggal = input("Tanggal (YYYY-MM-DD): ")
keranjang = []
while True:
id_produk = input("ID produk (kosongkan bila selesai): ")
if not id_produk:
break
qty = int(input("Jumlah: "))
keranjang.append((int(id_produk), qty))
print(f" -> ditambahkan. Isi keranjang: {len(keranjang)} jenis barang")
if not keranjang:
print("Keranjang kosong, transaksi dibatalkan.")
return
try:
id_nota = trx_repo.simpan(tanggal, id_pelanggan, keranjang)
print(f"\nTransaksi berhasil! Nota #{id_nota}")
cetak_nota(id_nota)
except ValueError as e:
print(f"\nTransaksi GAGAL: {e}")
print("Tidak ada data yang tersimpan.")
def riwayat_transaksi():
daftar = trx_repo.ambil_semua()
if not daftar:
print("\nBelum ada transaksi.")
return
print("\n" + "=" * 56)
print(f"{'NOTA':<7}{'TANGGAL':<14}{'PELANGGAN':<18}{'TOTAL':>17}")
print("=" * 56)
for id_jual, tanggal, nama, total in daftar:
print(f"#{id_jual:<6}{tanggal:<14}{nama:<18}{total:>17,}")
print("=" * 56)
def menu():
while True:
print("\n" + "=" * 30)
print(" APLIKASI TRANSAKSI")
print("=" * 30)
print("1. Transaksi baru")
print("2. Riwayat transaksi")
print("3. Lihat detail nota")
print("4. Batalkan nota")
print("0. Keluar")
pilihan = input("Pilih menu: ")
if pilihan == "1":
transaksi_baru()
elif pilihan == "2":
riwayat_transaksi()
elif pilihan == "3":
cetak_nota(int(input("ID nota: ")))
elif pilihan == "4":
id_nota = int(input("ID nota yang dibatalkan: "))
if input("Yakin? Stok akan dikembalikan (y/n): ").lower() == "y":
if trx_repo.hapus(id_nota):
print("Nota dibatalkan, stok dikembalikan.")
else:
print("Nota tidak ditemukan.")
elif pilihan == "0":
break
else:
print("Pilihan tidak dikenal.")
if __name__ == "__main__":
menu()
Perhatikan try ... except ValueError saat menyimpan.
Bila stok tidak cukup, pengguna mendapat pesan yang jelas dan
tidak ada data yang tersimpan — berkat rollback.
Referensi Fungsi Penting
Method TransaksiRepository
| Method | Fungsi | Mengembalikan |
|---|---|---|
simpan(tanggal, id_pelanggan, keranjang) |
Simpan nota + detail + potong stok | id_penjualan |
ambil_semua() |
Daftar semua nota | List tuple (id, tanggal, nama, total) |
ambil_detail(id) |
Nota + daftar barangnya | (nota, items) |
hapus(id) |
Batalkan nota + kembalikan stok | True / False |
Pola Transaksi Database
| Perintah | Fungsi |
|---|---|
conn.commit() |
Simpan permanen semua perubahan |
conn.rollback() |
Batalkan semua perubahan sejak koneksi dibuka |
try ... except ... finally |
Menjamin rollback saat gagal, dan koneksi selalu ditutup |
cursor.lastrowid |
Id baris yang baru dibuat (penghubung master → detail) |
Urutan Wajib
| Operasi | Urutan |
|---|---|
| Simpan |
Master dulu (penjualan) → baru Detail
(detail_penjualan)
|
| Hapus |
Detail dulu (detail_penjualan) → baru Master
(penjualan)
|
Aturan mudah diingat: menyimpan dari atas, menghapus dari bawah.
Pengujian
Pastikan hasil praktikum sesuai berikut:
- Transaksi 1 Laptop + 2 Mouse menghasilkan total Rp 8.800.000.
- Setelah transaksi, stok Laptop 10 → 9 dan Mouse 50 → 48.
-
cetak_nota()menampilkan kedua barang beserta subtotalnya. - Uji rollback: membeli barang melebihi stok (mis. 999 Laptop) ditolak dengan pesan jelas, tidak ada nota tersimpan, dan stok tidak berubah.
-
Uji hapus: membatalkan nota mengembalikan stok
ke jumlah semula, dan baris di
detail_penjualanikut terhapus.
Verifikasi cepat:
from repository.produk_repository import ProdukRepository
from repository.transaksi_repository import TransaksiRepository
prod_repo = ProdukRepository()
trx_repo = TransaksiRepository()
stok_awal = prod_repo.ambil_by_id(1).stok
# transaksi berhasil
id_nota = trx_repo.simpan("2025-03-01", 1, [(1, 1), (3, 2)])
nota, items = trx_repo.ambil_detail(id_nota)
assert nota[3] == 8800000 # total benar
assert len(items) == 2 # dua jenis barang
assert prod_repo.ambil_by_id(1).stok == stok_awal - 1 # stok terpotong
# uji rollback
jumlah_nota = len(trx_repo.ambil_semua())
try:
trx_repo.simpan("2025-03-02", 1, [(1, 99999)])
except ValueError:
pass
assert len(trx_repo.ambil_semua()) == jumlah_nota # tidak ada nota baru
assert prod_repo.ambil_by_id(1).stok == stok_awal - 1 # stok tidak berubah
# uji hapus
trx_repo.hapus(id_nota)
assert prod_repo.ambil_by_id(1).stok == stok_awal # stok kembali
print("Semua pengujian transaksi lolos.")
Troubleshooting
| Masalah | Penyebab | Solusi |
|---|---|---|
FOREIGN KEY constraint failed |
Detail disimpan sebelum master ada |
Simpan penjualan dulu, ambil
lastrowid, baru detail
|
| Nota tersimpan tetapi detail kosong | id_penjualan tidak dipakai di detail |
Pastikan memakai cursor.lastrowid |
| Stok jadi minus | Stok tidak diperiksa sebelum menyimpan | Periksa stok semua barang di awal (Langkah 2) |
| Nota gagal tetapi stok terlanjur berkurang | Tidak ada rollback |
Bungkus dengan try ... except: conn.rollback()
|
| Total nota bernilai 0 | Lupa UPDATE penjualan SET total |
Jalankan langkah 4 pada method simpan() |
| Nota terhapus tetapi detail tertinggal | Urutan hapus terbalik |
Hapus detail_penjualan dulu, baru
penjualan
|
| Stok tidak kembali setelah nota dibatalkan | Lupa UPDATE produk SET stok = stok + ? |
Kembalikan stok sebelum menghapus detail |
Tugas Praktikum
Kembangkan aplikasi transaksi:
- Tambahkan validasi qty: jumlah beli harus lebih besar dari 0. Bila pengguna memasukkan 0 atau negatif, tolak sebelum masuk keranjang.
-
Tambahkan menu
"Transaksi per pelanggan" —
menampilkan seluruh nota milik satu pelanggan beserta total
belanjanya. (Petunjuk: tambahkan method di repository dengan
WHERE id_pelanggan = ?.) -
Tambahkan menu
"Laporan penjualan" yang menampilkan:
jumlah nota, total seluruh penjualan, dan rata-rata nilai nota.
(Petunjuk: gunakan SQL
COUNT(),SUM(),AVG().) -
Tambahkan menu "Produk terlaris" —
menampilkan produk dengan total qty terjual terbanyak.
(Petunjuk:
GROUP BYpada tabeldetail_penjualan, gabungkan denganJOINkeproduk.) - Tantangan: buat satu transaksi berisi 3 jenis barang, dengan salah satunya sengaja melebihi stok. Buktikan lewat tangkapan layar bahwa tidak ada data yang tersimpan dan stok tidak berubah sama sekali.
Kumpulkan seluruh file .py beserta tangkapan layar
hasil menjalankan tiap menu (format PDF).
Kesimpulan
Pada pertemuan ini mahasiswa menghadapi kasus yang lebih menantang
dari CRUD biasa: menyimpan
satu transaksi ke dua tabel sekaligus dengan pola
master–detail, sambil memotong stok produk.
Mahasiswa mempelajari bahwa urutan penyimpanan tidak boleh
sembarangan — master lebih dulu agar
id_penjualan bisa dipakai oleh baris detail — dan
sebaliknya saat menghapus. Konsep terpenting yang diperkenalkan
adalah rollback: bila satu langkah gagal, seluruh
perubahan dibatalkan sehingga data tidak pernah tertinggal dalam
keadaan setengah jadi. Dengan ini, "mesin" aplikasi
penjualan sudah lengkap dan berfungsi penuh di terminal. Pada
pertemuan berikutnya, mesin yang sama akan diberi
wajah baru berupa halaman web menggunakan
Flask.