Pertemuan 13 - CRUD Transaksi

Pertemuan 13 - CRUD Transaksi

Menyimpan transaksi penjualan dengan pola master-detail, memotong stok otomatis, dan membatalkan seluruh proses bila gagal.

Tujuan Praktikum

Setelah menyelesaikan praktikum ini, mahasiswa mampu:

  1. Menjelaskan pola master–detail dan mengapa penyimpanannya berbeda dari CRUD biasa.
  2. Menyimpan satu transaksi ke dua tabel sekaligus (penjualan dan detail_penjualan).
  3. Memotong stok produk secara otomatis saat transaksi tersimpan.
  4. Menerapkan rollback agar transaksi yang gagal tidak menyisakan data setengah jadi.
  5. Menampilkan riwayat nota dan isinya menggunakan JOIN, serta menghapus nota beserta pengembalian stok.

Persiapan

Kebutuhan praktikum:

text
- 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:

bash
venv\Scripts\activate
macOS / Linux: gunakan source venv/bin/activate.

Buat file baru repository/transaksi_repository.py. Struktur project kini:

text
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

Alur menyimpan transaksi

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.

python
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:

python
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:

python
    # ---------- 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:

  1. Periksa stok semua barang DULU (langkah 1), sebelum menyimpan apa pun. Lebih baik menolak di awal daripada membatalkan di tengah jalan.
  2. cursor.lastrowid memberi id_penjualan dari nota yang baru dibuat — inilah penghubung master ke detail.
  3. Harga diambil dari database, bukan dari input pengguna. Ini mencegah manipulasi harga.
  4. finally menjamin koneksi tertutup, baik berhasil maupun gagal.

Uji:

python
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:

text
Nota tersimpan dengan id: 1

Periksa stok — Laptop 10 → 9, Mouse 50 → 48:

python
from repository.produk_repository import ProdukRepository

for p in ProdukRepository().ambil_semua():
    print(p)

Output:

text
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.

python
    # ---------- 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:

python
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:

text
====================================================
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:

  1. Baris detail harus dihapus lebih dulu, baru notanya (aturan Foreign Key: anak dulu, induk kemudian).
  2. Stok harus dikembalikan — barang yang batal terjual kembali ke gudang.
python
    # ---------- 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:

python
print("Hapus nota 1:", trx_repo.hapus(1))

for p in ProdukRepository().ambil_semua():
    print(p)

Output:

text
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:

python
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:

  1. Transaksi 1 Laptop + 2 Mouse menghasilkan total Rp 8.800.000.
  2. Setelah transaksi, stok Laptop 10 → 9 dan Mouse 50 → 48.
  3. cetak_nota() menampilkan kedua barang beserta subtotalnya.
  4. Uji rollback: membeli barang melebihi stok (mis. 999 Laptop) ditolak dengan pesan jelas, tidak ada nota tersimpan, dan stok tidak berubah.
  5. Uji hapus: membatalkan nota mengembalikan stok ke jumlah semula, dan baris di detail_penjualan ikut terhapus.

Verifikasi cepat:

python
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:

  1. Tambahkan validasi qty: jumlah beli harus lebih besar dari 0. Bila pengguna memasukkan 0 atau negatif, tolak sebelum masuk keranjang.
  2. Tambahkan menu "Transaksi per pelanggan" — menampilkan seluruh nota milik satu pelanggan beserta total belanjanya. (Petunjuk: tambahkan method di repository dengan WHERE id_pelanggan = ?.)
  3. Tambahkan menu "Laporan penjualan" yang menampilkan: jumlah nota, total seluruh penjualan, dan rata-rata nilai nota. (Petunjuk: gunakan SQL COUNT(), SUM(), AVG().)
  4. Tambahkan menu "Produk terlaris" — menampilkan produk dengan total qty terjual terbanyak. (Petunjuk: GROUP BY pada tabel detail_penjualan, gabungkan dengan JOIN ke produk.)
  5. 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.