Aplikasi inventaris yang hanya bisa menampilkan seluruh tabel akan menyusahkan begitu jumlah produk melewati satu layar. Pada bagian ini kita menambahkan kemampuan membaca data secara selektif: mencari produk berdasarkan potongan nama, menyaring satu kategori, serta tiga laporan — stok menipis, nilai inventaris per kategori, dan produk bernilai terbesar. database.py tidak berubah pada bagian ini; seluruh query baru sengaja kita tulis di main.py dulu agar fokus tetap pada SQL-nya, lalu Bagian 6 memindahkan semuanya ke lapisan data sekaligus menjadikannya mudah diuji.

Pencarian dengan LIKE yang Aman #

Operator LIKE memakai dua karakter wildcard: % cocok dengan deretan karakter apa pun, dan _ cocok dengan tepat satu karakter. Masalahnya, pengguna toko sungguhan mengetik nama seperti Coklat Batang 80% — dan wildcard di dalam kata kunci pencarian akan mengubah makna query. Solusinya ada dua bagian yang bekerja bersama:

python
BATAS_STOK_MENIPIS = 5
JUMLAH_TOP_PRODUK = 5


def escape_like(keyword: str) -> str:
    """Amankan karakter wildcard LIKE (persen dan garis bawah)."""
    return (
        keyword.replace("\\", "\\\\")
        .replace("%", "\\%")
        .replace("_", "\\_")
    )


def jalankan_kueri(sql: str,
                  params: tuple = ()) -> list[sqlite3.Row]:
    """Jalankan statement SELECT dan kembalikan seluruh baris hasilnya."""
    conn = database.koneksi()
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()


def cari_produk(keyword: str) -> list[sqlite3.Row]:
    pola = f"%{escape_like(keyword)}%"
    sql = "SELECT * FROM produk WHERE nama LIKE ? ESCAPE '\\'"
    return jalankan_kueri(sql, (pola,))

Mari kita bedah mekanismenya:

  1. escape_like(): setiap karakter wildcard milik pengguna diberi awalan backslash — % menjadi \%, _ menjadi \_ — sehingga SQLite memperlakukannya sebagai karakter biasa. Backslash milik pengguna sendiri di-escape paling awal (\ menjadi \\) agar tidak berbenturan dengan penanda escape yang baru saja kita pakai; urutan penggantian ini penting.
  2. pola = f"%{escape_like(keyword)}%": wildcard % yang kita tambahkan sendiri tetap berfungsi sebagai “apa pun di kiri dan kanan”, sehingga pencarian celup menemukan Teh Celup.
  3. LIKE ? ESCAPE '\\': clause ESCAPE memberi tahu SQLite bahwa backslash adalah karakter escape pola. Dalam string Python, '\\' adalah satu karakter backslash.
  4. jalankan_kueri(): helper kecil yang membuka koneksi lewat database.koneksi(), menjalankan SELECT dengan parameter, lalu menutup koneksi — seluruh query laporan pada bagian ini memakainya.

Filter Kategori dan Laporan Stok Menipis #

Filter kategori memakai perbandingan biasa = ?, sedangkan laporan stok menipis memakai perbandingan < ? dengan batas sebagai parameter:

python
def filter_kategori(kategori: str) -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE kategori = ? ORDER BY nama"
    return jalankan_kueri(sql, (kategori,))


def laporan_stok_menipis() -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE stok < ? ORDER BY stok, nama"
    return jalankan_kueri(sql, (BATAS_STOK_MENIPIS,))

Urutan ORDER BY stok, nama membuat produk paling kritis tampil paling atas; kolom nama menjadi pemecah kedudukan agar urutan laporan stabil dan dapat direproduksi.

Nilai Inventaris dan Top Produk #

Dua laporan terakhir memakai aggregate function. Nilai inventaris menghitung harga * stok per baris lalu menjumlahkannya per kategori:

python
def nilai_inventaris_per_kategori() -> list[sqlite3.Row]:
    sql = """
        SELECT kategori,
               COUNT(*) AS jumlah_produk,
               SUM(harga * stok) AS nilai_total
        FROM produk
        GROUP BY kategori
        ORDER BY nilai_total DESC
    """
    return jalankan_kueri(sql)


def top_produk(jumlah: int = JUMLAH_TOP_PRODUK) -> list[sqlite3.Row]:
    sql = """
        SELECT id, nama, kategori, harga, stok,
               harga * stok AS nilai
        FROM produk
        ORDER BY nilai DESC, nama
        LIMIT ?
    """
    return jalankan_kueri(sql, (jumlah,))
  1. GROUP BY kategori: mengelompokkan baris produk per kategori sehingga COUNT(*) dan SUM(...) dihitung per kelompok, bukan untuk seluruh tabel.
  2. SUM(harga * stok) AS nilai_total: nilai sebuah produk adalah harga dikali jumlah stok; alias nilai_total membuat kolom hasil mudah diakses lewat row["nilai_total"].
  3. ORDER BY nilai_total DESC: kategori bernilai terbesar tampil lebih dulu.
  4. LIMIT ?: jumlah baris top produk dikirim sebagai parameter, sama seperti nilai lainnya — bahkan konstanta pun bisa menjadi parameter bila nilainya ingin mudah diubah.

main.py Lengkap #

Berikut isi lengkap main.py setelah semua menu baru tersambung. Bagian tengah (menu 1 sampai 6) tidak berubah dari Bagian 4, jadi cukup perhatikan fungsi-fungsi baru di bagian atas serta menu 7 sampai 11:

python
"""Aplikasi Inventaris SQLite - antarmuka pengguna."""

import sqlite3

import database

BATAS_STOK_MENIPIS = 5
JUMLAH_TOP_PRODUK = 5


def escape_like(keyword: str) -> str:
    """Amankan karakter wildcard LIKE (persen dan garis bawah)."""
    return (
        keyword.replace("\\", "\\\\")
        .replace("%", "\\%")
        .replace("_", "\\_")
    )


def jalankan_kueri(sql: str,
                  params: tuple = ()) -> list[sqlite3.Row]:
    """Jalankan statement SELECT dan kembalikan seluruh baris hasilnya."""
    conn = database.koneksi()
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()


def cari_produk(keyword: str) -> list[sqlite3.Row]:
    pola = f"%{escape_like(keyword)}%"
    sql = "SELECT * FROM produk WHERE nama LIKE ? ESCAPE '\\' ORDER BY nama"
    return jalankan_kueri(sql, (pola,))


def filter_kategori(kategori: str) -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE kategori = ? ORDER BY nama"
    return jalankan_kueri(sql, (kategori,))


def laporan_stok_menipis() -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE stok < ? ORDER BY stok, nama"
    return jalankan_kueri(sql, (BATAS_STOK_MENIPIS,))


def nilai_inventaris_per_kategori() -> list[sqlite3.Row]:
    sql = """
        SELECT kategori,
               COUNT(*) AS jumlah_produk,
               SUM(harga * stok) AS nilai_total
        FROM produk
        GROUP BY kategori
        ORDER BY nilai_total DESC
    """
    return jalankan_kueri(sql)


def top_produk(jumlah: int = JUMLAH_TOP_PRODUK) -> list[sqlite3.Row]:
    sql = """
        SELECT id, nama, kategori, harga, stok,
               harga * stok AS nilai
        FROM produk
        ORDER BY nilai DESC, nama
        LIMIT ?
    """
    return jalankan_kueri(sql, (jumlah,))


def format_rupiah(nilai: int) -> str:
    """Format angka menjadi teks rupiah, contoh 15000 menjadi Rp15.000."""
    return "Rp" + f"{nilai:,}".replace(",", ".")


def cetak_tabel(rows: list) -> None:
    """Cetak daftar produk sebagai tabel rapi."""
    print(f"{'ID':<3} {'Nama':<18} {'Kategori':<10} {'Harga':>10} {'Stok':>4}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 10} {'-' * 4}")
    for row in rows:
        print(
            f"{row['id']:<3} {row['nama']:<18} {row['kategori']:<10} "
            f"{format_rupiah(row['harga']):>10} {row['stok']:>4}"
        )


def baca_angka(prompt: str) -> int:
    """Minta masukan angka bulat, ulangi sampai masukan sah."""
    while True:
        teks = input(prompt).strip()
        try:
            return int(teks)
        except ValueError:
            print("Masukkan angka bulat, misalnya 15000.")


def menu_tambah() -> None:
    nama = input("Nama produk: ").strip()
    kategori = input("Kategori: ").strip()
    harga = baca_angka("Harga (Rp): ")
    stok = baca_angka("Stok awal: ")

    try:
        database.validasi_produk(nama, kategori, harga, stok)
    except ValueError as galat:
        print(f"Gagal: {galat}")
        return

    try:
        id_baru = database.tambah_produk(nama, kategori, harga, stok)
    except sqlite3.IntegrityError:
        print(f"Produk dengan nama '{nama}' sudah terdaftar.")
        return

    print(f"Produk '{nama}' tersimpan dengan id {id_baru}.")


def menu_lihat() -> None:
    rows = database.lihat_produk()
    if not rows:
        print("Belum ada produk.")
        return
    cetak_tabel(rows)


def menu_cari() -> None:
    keyword = input("Kata kunci nama: ").strip()
    if not keyword:
        print("Kata kunci tidak boleh kosong.")
        return
    hasil = cari_produk(keyword)
    if not hasil:
        print(f"Tidak ada produk yang mengandung '{keyword}'.")
        return
    print(f"Hasil pencarian untuk '{keyword}':")
    cetak_tabel(hasil)


def menu_filter() -> None:
    kategori = input("Kategori: ").strip()
    if not kategori:
        print("Kategori tidak boleh kosong.")
        return
    hasil = filter_kategori(kategori)
    if not hasil:
        print(f"Tidak ada produk pada kategori '{kategori}'.")
        return
    print(f"Produk pada kategori '{kategori}':")
    cetak_tabel(hasil)


def menu_ubah_harga() -> None:
    produk_id = baca_angka("ID produk: ")
    harga_baru = baca_angka("Harga baru (Rp): ")
    if database.perbarui_harga(produk_id, harga_baru):
        print("Harga berhasil diperbarui.")
    else:
        print(f"Tidak ada produk dengan id {produk_id}.")


def menu_sesuaikan_stok() -> None:
    produk_id = baca_angka("ID produk: ")
    perubahan = baca_angka("Perubahan stok (boleh negatif): ")
    keterangan = input("Keterangan: ").strip() or "penyesuaian manual"

    try:
        database.sesuaikan_stok(produk_id, perubahan, keterangan)
    except ValueError as galat:
        print(f"Gagal: {galat}")
        return
    except sqlite3.IntegrityError:
        print("Gagal: penyesuaian membuat stok negatif. Transaksi dibatalkan.")
        return

    print("Stok berhasil disesuaikan.")


def menu_hapus() -> None:
    produk_id = baca_angka("ID produk: ")
    if database.hapus_produk(produk_id):
        print("Produk berhasil dihapus.")
    else:
        print(f"Tidak ada produk dengan id {produk_id}.")


def menu_riwayat() -> None:
    produk_id = baca_angka("ID produk: ")
    produk = database.ambil_produk(produk_id)
    if produk is None:
        print(f"Tidak ada produk dengan id {produk_id}.")
        return

    rows = database.lihat_riwayat(produk_id)
    if not rows:
        print(f"Belum ada riwayat untuk '{produk['nama']}'.")
        return

    print(f"Riwayat untuk '{produk['nama']}':")
    print(f"{'Waktu':<21} {'Perubahan':>9}  Keterangan")
    for row in rows:
        print(f"{row['waktu']:<21} {row['perubahan']:>+9}  {row['keterangan']}")


def menu_stok_menipis() -> None:
    hasil = laporan_stok_menipis()
    if not hasil:
        print(f"Tidak ada produk dengan stok di bawah {BATAS_STOK_MENIPIS}.")
        return
    print(f"Produk dengan stok di bawah {BATAS_STOK_MENIPIS}:")
    print(f"{'ID':<3} {'Nama':<18} {'Kategori':<10} {'Stok':>4}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 4}")
    for row in hasil:
        print(f"{row['id']:<3} {row['nama']:<18} {row['kategori']:<10} {row['stok']:>4}")


def menu_nilai_inventaris() -> None:
    hasil = nilai_inventaris_per_kategori()
    if not hasil:
        print("Belum ada produk untuk dihitung.")
        return
    print(f"{'Kategori':<12} {'Jumlah':>7} {'Nilai Total':>13}")
    print(f"{'-' * 12} {'-' * 7} {'-' * 13}")
    total_nilai = 0
    total_produk = 0
    for row in hasil:
        print(
            f"{row['kategori']:<12} {row['jumlah_produk']:>7} "
            f"{format_rupiah(row['nilai_total']):>13}"
        )
        total_nilai += row["nilai_total"]
        total_produk += row["jumlah_produk"]
    print(f"{'-' * 12} {'-' * 7} {'-' * 13}")
    print(f"{'TOTAL':<12} {total_produk:>7} {format_rupiah(total_nilai):>13}")


def menu_top_produk() -> None:
    hasil = top_produk()
    if not hasil:
        print("Belum ada produk untuk dihitung.")
        return
    print(f"Top {JUMLAH_TOP_PRODUK} produk berdasarkan nilai stok:")
    print(f"{'#':<3} {'Nama':<18} {'Harga':>10} {'Stok':>4} {'Nilai':>12}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 4} {'-' * 12}")
    for peringkat, row in enumerate(hasil, start=1):
        print(
            f"{peringkat:<3} {row['nama']:<18} "
            f"{format_rupiah(row['harga']):>10} {row['stok']:>4} "
            f"{format_rupiah(row['nilai']):>12}"
        )


def tampilkan_menu() -> None:
    print()
    print("=== Aplikasi Inventaris ===")
    print("1. Tambah produk")
    print("2. Lihat semua produk")
    print("3. Ubah harga")
    print("4. Sesuaikan stok (+/-)")
    print("5. Hapus produk")
    print("6. Riwayat produk")
    print("7. Cari produk")
    print("8. Filter per kategori")
    print("9. Laporan stok menipis")
    print("10. Nilai inventaris per kategori")
    print("11. Top produk")
    print("0. Keluar")


def main() -> None:
    database.inisialisasi()
    print("Database siap. Selamat datang!")

    while True:
        tampilkan_menu()
        try:
            pilihan = input("Pilih menu: ").strip()
        except EOFError:
            print()
            break

        if pilihan == "0":
            print("Sampai jumpa!")
            break

        match pilihan:
            case "1":
                menu_tambah()
            case "2":
                menu_lihat()
            case "3":
                menu_ubah_harga()
            case "4":
                menu_sesuaikan_stok()
            case "5":
                menu_hapus()
            case "6":
                menu_riwayat()
            case "7":
                menu_cari()
            case "8":
                menu_filter()
            case "9":
                menu_stok_menipis()
            case "10":
                menu_nilai_inventaris()
            case "11":
                menu_top_produk()
            case _:
                print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()

Perhatikan pola menu laporan: query mengembalikan list, list kosong ditangani dengan pesan khusus, dan baris total pada menu_nilai_inventaris() dijumlahkan di Python — bukan lewat query tambahan WITH ROLLUP yang tidak didukung SQLite — sehingga kode tetap sederhana.

Mencari dan Membaca Laporan #

Kita mulai dari database bersih, menambahkan produk yang namanya mengandung karakter %, lalu mencari dengan kata kunci kopi. Perintah printf menulis % sebagai %% karena % adalah penentu format milik printf itu sendiri:

bash
printf '1\nCoklat Batang 80%%\nMakanan\n12000\n10\n7\nkopi\n0\n' | python3 main.py

Output yang Dihasilkan:

text
Database siap. Selamat datang!

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Nama produk: Kategori: Harga (Rp): Stok awal: Produk 'Coklat Batang 80%' tersimpan dengan id 7.

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Kata kunci nama: Hasil pencarian untuk 'kopi':
ID  Nama               Kategori        Harga Stok
--- ------------------ ---------- ---------- ----
1   Kopi Tubruk        Minuman      Rp15.000   24

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Sampai jumpa!

Kata kunci kopi menemukan Kopi Tubruk tanpa memedulikan huruf besar. Sekarang ujian sebenarnya bagi escape_like() — mencari 80% yang seharusnya hanya menemukan produk mengandung karakter % literal, kata kunci tanpa hasil, dan filter kategori:

bash
printf '7\n80%%\n7\nz\n8\nMinuman\n0\n' | python3 main.py

Output yang Dihasilkan:

text
Database siap. Selamat datang!

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Kata kunci nama: Hasil pencarian untuk '80%':
ID  Nama               Kategori        Harga Stok
--- ------------------ ---------- ---------- ----
7   Coklat Batang 80%  Makanan      Rp12.000   10

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Kata kunci nama: Tidak ada produk yang mengandung 'z'.

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Kategori: Produk pada kategori 'Minuman':
ID  Nama               Kategori        Harga Stok
--- ------------------ ---------- ---------- ----
1   Kopi Tubruk        Minuman      Rp15.000   24
2   Teh Celup          Minuman       Rp8.000    3

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Sampai jumpa!

Tanpa escape, pencarian 80% akan diartikan “apa pun yang mengandung 80 diikuti karakter apa saja” dan ikut mencocokkan nama-nama lain. Laporan stok menipis menampilkan tiga produk kritis dari data awal, urut dari yang paling mendesak:

bash
printf '9\n0\n' | python3 main.py

Output yang Dihasilkan:

text
Database siap. Selamat datang!

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Produk dengan stok di bawah 5:
ID  Nama               Kategori   Stok
--- ------------------ ---------- ----
4   Keripik Singkong   Makanan       2
2   Teh Celup          Minuman       3
6   Pulpen Gel         Alat Tulis    4

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Sampai jumpa!

Terakhir, dua laporan agregat sekaligus — nilai per kategori beserta totalnya, lalu lima produk bernilai terbesar:

bash
printf '10\n11\n0\n' | python3 main.py

Output yang Dihasilkan:

text
Database siap. Selamat datang!

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Kategori      Jumlah   Nilai Total
------------ ------- -------------
Minuman            2     Rp384.000
Makanan            3     Rp320.000
Alat Tulis         2     Rp214.000
------------ ------- -------------
TOTAL              7     Rp918.000

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Top 5 produk berdasarkan nilai stok:
#   Nama                    Harga Stok        Nilai
--- ------------------ ---------- ---- ------------
1   Kopi Tubruk          Rp15.000   24    Rp360.000
2   Buku Tulis            Rp5.000   40    Rp200.000
3   Roti Tawar           Rp12.000   15    Rp180.000
4   Coklat Batang 80%    Rp12.000   10    Rp120.000
5   Teh Celup             Rp8.000    3     Rp24.000

=== Aplikasi Inventaris ===
1. Tambah produk
2. Lihat semua produk
3. Ubah harga
4. Sesuaikan stok (+/-)
5. Hapus produk
6. Riwayat produk
7. Cari produk
8. Filter per kategori
9. Laporan stok menipis
10. Nilai inventaris per kategori
11. Top produk
0. Keluar
Pilih menu: Sampai jumpa!

Angka pada laporan bisa diperiksa manual: Minuman bernilai 24 × Rp15.000 + 3 × Rp8.000 = Rp384.000, sesuai kolom Nilai Total. Berkas main.py hasil bagian ini tersedia pada berkas sumber di bawah halaman. Aplikasi kini lengkap secara fitur, tetapi belum ada satu pun test otomatis yang membuktikannya — Bagian 6 menutup proyek dengan refactor dan rangkaian test pytest.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

main.py

Unduh berkas
python
"""Aplikasi Inventaris SQLite - antarmuka pengguna."""

import sqlite3

import database

BATAS_STOK_MENIPIS = 5
JUMLAH_TOP_PRODUK = 5


def escape_like(keyword: str) -> str:
    """Amankan karakter wildcard LIKE (persen dan garis bawah)."""
    return (
        keyword.replace("\\", "\\\\")
        .replace("%", "\\%")
        .replace("_", "\\_")
    )


def jalankan_kueri(sql: str,
                  params: tuple = ()) -> list[sqlite3.Row]:
    """Jalankan statement SELECT dan kembalikan seluruh baris hasilnya."""
    conn = database.koneksi()
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()


def cari_produk(keyword: str) -> list[sqlite3.Row]:
    pola = f"%{escape_like(keyword)}%"
    sql = "SELECT * FROM produk WHERE nama LIKE ? ESCAPE '\\' ORDER BY nama"
    return jalankan_kueri(sql, (pola,))


def filter_kategori(kategori: str) -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE kategori = ? ORDER BY nama"
    return jalankan_kueri(sql, (kategori,))


def laporan_stok_menipis() -> list[sqlite3.Row]:
    sql = "SELECT * FROM produk WHERE stok < ? ORDER BY stok, nama"
    return jalankan_kueri(sql, (BATAS_STOK_MENIPIS,))


def nilai_inventaris_per_kategori() -> list[sqlite3.Row]:
    sql = """
        SELECT kategori,
               COUNT(*) AS jumlah_produk,
               SUM(harga * stok) AS nilai_total
        FROM produk
        GROUP BY kategori
        ORDER BY nilai_total DESC
    """
    return jalankan_kueri(sql)


def top_produk(jumlah: int = JUMLAH_TOP_PRODUK) -> list[sqlite3.Row]:
    sql = """
        SELECT id, nama, kategori, harga, stok,
               harga * stok AS nilai
        FROM produk
        ORDER BY nilai DESC, nama
        LIMIT ?
    """
    return jalankan_kueri(sql, (jumlah,))


def format_rupiah(nilai: int) -> str:
    """Format angka menjadi teks rupiah, contoh 15000 menjadi Rp15.000."""
    return "Rp" + f"{nilai:,}".replace(",", ".")


def cetak_tabel(rows: list) -> None:
    """Cetak daftar produk sebagai tabel rapi."""
    print(f"{'ID':<3} {'Nama':<18} {'Kategori':<10} {'Harga':>10} {'Stok':>4}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 10} {'-' * 4}")
    for row in rows:
        print(
            f"{row['id']:<3} {row['nama']:<18} {row['kategori']:<10} "
            f"{format_rupiah(row['harga']):>10} {row['stok']:>4}"
        )


def baca_angka(prompt: str) -> int:
    """Minta masukan angka bulat, ulangi sampai masukan sah."""
    while True:
        teks = input(prompt).strip()
        try:
            return int(teks)
        except ValueError:
            print("Masukkan angka bulat, misalnya 15000.")


def menu_tambah() -> None:
    nama = input("Nama produk: ").strip()
    kategori = input("Kategori: ").strip()
    harga = baca_angka("Harga (Rp): ")
    stok = baca_angka("Stok awal: ")

    try:
        database.validasi_produk(nama, kategori, harga, stok)
    except ValueError as galat:
        print(f"Gagal: {galat}")
        return

    try:
        id_baru = database.tambah_produk(nama, kategori, harga, stok)
    except sqlite3.IntegrityError:
        print(f"Produk dengan nama '{nama}' sudah terdaftar.")
        return

    print(f"Produk '{nama}' tersimpan dengan id {id_baru}.")


def menu_lihat() -> None:
    rows = database.lihat_produk()
    if not rows:
        print("Belum ada produk.")
        return
    cetak_tabel(rows)


def menu_cari() -> None:
    keyword = input("Kata kunci nama: ").strip()
    if not keyword:
        print("Kata kunci tidak boleh kosong.")
        return
    hasil = cari_produk(keyword)
    if not hasil:
        print(f"Tidak ada produk yang mengandung '{keyword}'.")
        return
    print(f"Hasil pencarian untuk '{keyword}':")
    cetak_tabel(hasil)


def menu_filter() -> None:
    kategori = input("Kategori: ").strip()
    if not kategori:
        print("Kategori tidak boleh kosong.")
        return
    hasil = filter_kategori(kategori)
    if not hasil:
        print(f"Tidak ada produk pada kategori '{kategori}'.")
        return
    print(f"Produk pada kategori '{kategori}':")
    cetak_tabel(hasil)


def menu_ubah_harga() -> None:
    produk_id = baca_angka("ID produk: ")
    harga_baru = baca_angka("Harga baru (Rp): ")
    if database.perbarui_harga(produk_id, harga_baru):
        print("Harga berhasil diperbarui.")
    else:
        print(f"Tidak ada produk dengan id {produk_id}.")


def menu_sesuaikan_stok() -> None:
    produk_id = baca_angka("ID produk: ")
    perubahan = baca_angka("Perubahan stok (boleh negatif): ")
    keterangan = input("Keterangan: ").strip() or "penyesuaian manual"

    try:
        database.sesuaikan_stok(produk_id, perubahan, keterangan)
    except ValueError as galat:
        print(f"Gagal: {galat}")
        return
    except sqlite3.IntegrityError:
        print("Gagal: penyesuaian membuat stok negatif. Transaksi dibatalkan.")
        return

    print("Stok berhasil disesuaikan.")


def menu_hapus() -> None:
    produk_id = baca_angka("ID produk: ")
    if database.hapus_produk(produk_id):
        print("Produk berhasil dihapus.")
    else:
        print(f"Tidak ada produk dengan id {produk_id}.")


def menu_riwayat() -> None:
    produk_id = baca_angka("ID produk: ")
    produk = database.ambil_produk(produk_id)
    if produk is None:
        print(f"Tidak ada produk dengan id {produk_id}.")
        return

    rows = database.lihat_riwayat(produk_id)
    if not rows:
        print(f"Belum ada riwayat untuk '{produk['nama']}'.")
        return

    print(f"Riwayat untuk '{produk['nama']}':")
    print(f"{'Waktu':<21} {'Perubahan':>9}  Keterangan")
    for row in rows:
        print(f"{row['waktu']:<21} {row['perubahan']:>+9}  {row['keterangan']}")


def menu_stok_menipis() -> None:
    hasil = laporan_stok_menipis()
    if not hasil:
        print(f"Tidak ada produk dengan stok di bawah {BATAS_STOK_MENIPIS}.")
        return
    print(f"Produk dengan stok di bawah {BATAS_STOK_MENIPIS}:")
    print(f"{'ID':<3} {'Nama':<18} {'Kategori':<10} {'Stok':>4}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 4}")
    for row in hasil:
        print(f"{row['id']:<3} {row['nama']:<18} {row['kategori']:<10} {row['stok']:>4}")


def menu_nilai_inventaris() -> None:
    hasil = nilai_inventaris_per_kategori()
    if not hasil:
        print("Belum ada produk untuk dihitung.")
        return
    print(f"{'Kategori':<12} {'Jumlah':>7} {'Nilai Total':>13}")
    print(f"{'-' * 12} {'-' * 7} {'-' * 13}")
    total_nilai = 0
    total_produk = 0
    for row in hasil:
        print(
            f"{row['kategori']:<12} {row['jumlah_produk']:>7} "
            f"{format_rupiah(row['nilai_total']):>13}"
        )
        total_nilai += row["nilai_total"]
        total_produk += row["jumlah_produk"]
    print(f"{'-' * 12} {'-' * 7} {'-' * 13}")
    print(f"{'TOTAL':<12} {total_produk:>7} {format_rupiah(total_nilai):>13}")


def menu_top_produk() -> None:
    hasil = top_produk()
    if not hasil:
        print("Belum ada produk untuk dihitung.")
        return
    print(f"Top {JUMLAH_TOP_PRODUK} produk berdasarkan nilai stok:")
    print(f"{'#':<3} {'Nama':<18} {'Harga':>10} {'Stok':>4} {'Nilai':>12}")
    print(f"{'---':<3} {'-' * 18} {'-' * 10} {'-' * 4} {'-' * 12}")
    for peringkat, row in enumerate(hasil, start=1):
        print(
            f"{peringkat:<3} {row['nama']:<18} "
            f"{format_rupiah(row['harga']):>10} {row['stok']:>4} "
            f"{format_rupiah(row['nilai']):>12}"
        )


def tampilkan_menu() -> None:
    print()
    print("=== Aplikasi Inventaris ===")
    print("1. Tambah produk")
    print("2. Lihat semua produk")
    print("3. Ubah harga")
    print("4. Sesuaikan stok (+/-)")
    print("5. Hapus produk")
    print("6. Riwayat produk")
    print("7. Cari produk")
    print("8. Filter per kategori")
    print("9. Laporan stok menipis")
    print("10. Nilai inventaris per kategori")
    print("11. Top produk")
    print("0. Keluar")


def main() -> None:
    database.inisialisasi()
    print("Database siap. Selamat datang!")

    while True:
        tampilkan_menu()
        try:
            pilihan = input("Pilih menu: ").strip()
        except EOFError:
            print()
            break

        if pilihan == "0":
            print("Sampai jumpa!")
            break

        match pilihan:
            case "1":
                menu_tambah()
            case "2":
                menu_lihat()
            case "3":
                menu_ubah_harga()
            case "4":
                menu_sesuaikan_stok()
            case "5":
                menu_hapus()
            case "6":
                menu_riwayat()
            case "7":
                menu_cari()
            case "8":
                menu_filter()
            case "9":
                menu_stok_menipis()
            case "10":
                menu_nilai_inventaris()
            case "11":
                menu_top_produk()
            case _:
                print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()