Bagian 4 ditutup dengan pengakuan bahwa sistem inventaris kita sudah bisa dipercaya menjaga integritasnya sendiri, tetapi belum punya cara melihatnya secara keseluruhan. Pemilik toko tidak membuka tabel stock_movements untuk membaca 9 baris riwayat — pemilik toko bertanya: produk apa yang harus segera dipesan ulang, berapa nilai uang barang yang terpasang di rak, dan seberapa ramai gudang minggu ini. Pada bagian ini kita membangun ReportService, lapisan baca yang menjawab pertanyaan-pertanyaan itu dengan tiga alat SQL yang belum pernah kita pakai serius di proyek ini: agregat (SUM, COUNT), penggabungan lintas tabel (JOIN), dan pengelompokan (GROUP BY). Ditutup dengan pencarian — query LIKE yang kelihatannya sederhana, tetapi menyimpan jebakan wildcard yang bisa membuat pencarian “50%” mengembalikan hampir seluruh katalog.
Lapisan Baca untuk Pertanyaan Lintas Tabel #
Repository yang kita bangun pada Bagian 2 dan 3 punya bentuk pertanyaan yang sempit: satu tabel, satu entity. ProductRepository::listByKategori() menanyakan tabel products; StockMovementRepository::riwayatByProduct() menanyakan stock_movements. Bentuk ini tepat untuk operasi, tetapi laporan hampir selalu melewati batas tabel: laporan stok menipis butuh nama kategori dari tabel lain, nilai persediaan mengalikan dua kolom dalam satu baris, dan laporan pergerakan melipat ratusan baris movement menjadi dua angka.
ReportService menampung pertanyaan-pertanyaan itu sebagai class tersendiri dengan satu disiplin tambahan: seluruh methodnya hanya menjalankan SELECT. Class ini tidak punya satu pun penulisan, sehingga laporan tidak mungkin merusak data yang dilaporkannya — prinsip separation of reads and writes yang sederhana tetapi berdaya. Bila kelak ada laporan baru (misalnya produk terlaris), method barunya masuk ke sini tanpa menyentuh repository maupun service yang sudah teruji.
Class ReportService #
Empat method publiknya mewakili empat pertanyaan pengguna akhir. Berikut isi lengkap src/ReportService.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Lapisan baca untuk laporan dan pencarian inventaris.
*
* Repository pada Bagian 2 dan 3 menjawab pertanyaan per tabel - produk
* apa saja di kategori ini, riwayat movement produk mana. ReportService
* menjawab pertanyaan lintas tabel yang ditanyakan pengguna akhir setiap
* hari: produk mana yang stoknya menipis, berapa nilai uang seluruh
* persediaan, seberapa sibuk pergerakan pada satu periode, dan bagaimana
* menemukan produk dari sepotong nama atau SKU.
*
* Seluruh method di class ini hanya menjalankan SELECT - tidak ada satu
* pun penulisan, sehingga laporan tidak pernah bisa merusak data yang
* dilaporkannya.
*/
final class ReportService
{
public function __construct(private PDO $pdo)
{
// Konvensi proyek: pragma per koneksi dinyalakan di constructor.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Produk dengan stok kurang dari atau sama dengan ambang,
* stok terkecil ditampilkan lebih dulu.
*
* Laporan klasik untuk tim pembelian: daftar inilah yang menentukan
* produk apa yang perlu dipesan ulang. Nama kategori ikut dibawa
* lewat JOIN karena laporan tanpa kategori sulit dibaca manusia.
*
* @return list<array<string, mixed>> elemen id, sku, nama,
* kategori, stok_sekarang
*/
public function stokMenipis(int $ambang): array
{
$stmt = $this->pdo->prepare(
'SELECT p.id, p.sku, p.nama, c.nama AS kategori, p.stok_sekarang
FROM products p
JOIN categories c ON c.id = p.kategori_id
WHERE p.stok_sekarang <= ?
ORDER BY p.stok_sekarang, p.nama'
);
$stmt->execute([$ambang]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Total nilai persediaan dalam rupiah: SUM(harga x stok).
*
* harga_satuan disimpan sebagai integer rupiah tanpa sen sejak
* Bagian 1, sehingga hasilnya juga integer - tidak ada pembulatan
* float yang bisa membuat dua laporan berbeda satu rupiah.
* COALESCE menjaga tabel kosong tetap menghasilkan 0, bukan NULL.
*/
public function nilaiPersediaan(): int
{
return (int) $this->pdo
->query('SELECT COALESCE(SUM(harga_satuan * stok_sekarang), 0)
FROM products')
->fetchColumn();
}
/**
* Ringkasan pergerakan stok pada satu periode, dikelompokkan per jenis.
*
* GROUP BY jenis melipat seluruh movement dalam periode menjadi dua
* baris (masuk, keluar). Array hasil diinisialisasi lebih dulu di PHP
* sehingga kedua kunci SELALU ada - periode sepi menghasilkan nol,
* bukan kunci yang hilang, dan pemanggil tidak perlu isset().
*
* Batas periode dibandingkan sebagai teks terhadap kolom dibuat_pada
* format 'YYYY-MM-DD HH:MM:SS'; keduanya inklusif.
*
* @return array{masuk: array{transaksi: int, unit: int},
* keluar: array{transaksi: int, unit: int}}
*/
public function pergerakanPeriode(string $mulai, string $sampai): array
{
$stmt = $this->pdo->prepare(
'SELECT jenis, COUNT(*) AS transaksi, SUM(jumlah) AS unit
FROM stock_movements
WHERE dibuat_pada >= ? AND dibuat_pada <= ?
GROUP BY jenis'
);
$stmt->execute([$mulai, $sampai]);
$hasil = [
'masuk' => ['transaksi' => 0, 'unit' => 0],
'keluar' => ['transaksi' => 0, 'unit' => 0],
];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$hasil[(string) $baris['jenis']] = [
'transaksi' => (int) $baris['transaksi'],
'unit' => (int) $baris['unit'],
];
}
return $hasil;
}
/**
* Cari produk dari sepotong nama atau SKU, opsional dibatasi kategori.
*
* Karakter wildcard LIKE (%, _) pada $q di-escape bersama karakter
* escape-nya sendiri, lalu pola dipakai dengan klausa ESCAPE '\'.
* Hasilnya, pencarian "50%" mencari empat karakter literal "50%",
* bukan pola "50 diikuti apa pun" - dan pencarian "KOPI_001" tidak
* lagi cocok dengan SKU "KOPI-001" karena garis bawah berarti benar
* -benar garis bawah.
*
* SQLite membandingkan LIKE tanpa peduli kapital untuk huruf ASCII,
* jadi "kopi" menemukan "Kopi Hitam Sachet 20g".
*
* @param string $q potongan nama atau SKU; string kosong berarti
* seluruh produk (berguna untuk browsing per kategori)
* @param int|null $kategoriId batasi hasil pada kategori ini;
* kategori tanpa produk menghasilkan array kosong
* @return list<array<string, mixed>> elemen id, sku, nama, kategori,
* harga_satuan, stok_sekarang, urut berdasarkan nama
*/
public function pencarian(string $q, ?int $kategoriId = null): array
{
$q = trim($q);
$pola = '%' . str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
$q
) . '%';
$sql = "SELECT p.id, p.sku, p.nama, c.nama AS kategori,
p.harga_satuan, p.stok_sekarang
FROM products p
JOIN categories c ON c.id = p.kategori_id
WHERE (p.nama LIKE ? ESCAPE '\\' OR p.sku LIKE ? ESCAPE '\\')";
$params = [$pola, $pola];
if ($kategoriId !== null) {
$sql .= ' AND p.kategori_id = ?';
$params[] = $kategoriId;
}
$stmt = $this->pdo->prepare($sql . ' ORDER BY p.nama');
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}Kontrak keempat method tersebut:
| Method | Pertanyaan yang dijawab | Bentuk hasil |
|---|---|---|
stokMenipis($ambang) | produk mana yang stoknya perlu diambil tindakan? | daftar produk + kategori, stok terkecil lebih dulu |
nilaiPersediaan() | berapa nilai uang seluruh stok? | satu integer rupiah |
pergerakanPeriode($mulai, $sampai) | seberapa sibuk gudang pada periode ini? | transaksi dan unit per jenis, masuk/keluar |
pencarian($q, $kategoriId) | produk mana yang cocok potongan nama/SKU ini? | daftar produk yang cocok |
Tiga keputusan desain di dalamnya layak dijelaskan. Pertama, nilaiPersediaan() mengembalikan integer, bukan float — harga_satuan memang disimpan sebagai rupiah utuh tanpa sen sejak Bagian 1, sehingga hasil kalinya juga utuh; laporan uang yang dihitung dengan float berisiko berbeda satu rupiah antar jalur. COALESCE(..., 0) menjaga tabel produk kosong tetap menghasilkan 0, bukan NULL yang berubah menjadi string kosong saat dicetak. Kedua, pergerakanPeriode() membandingkan dibuat_pada sebagai teks terhadap batas periode — format YYYY-MM-DD HH:MM:SS terurut secara leksikografis sama dengan urutan waktunya, jadi pembandingan string biasa sah. Ketiga, array hasil pergerakanPeriode() diinisialisasi dengan kedua kunci bernilai nol sebelum GROUP BY dijalankan; periode sepi menghasilkan nol yang rapi, bukan kunci yang hilang, dan kode pemanggil tidak perlu berjaga dengan isset().
Laporan Stok Menipis dan Nilai Persediaan #
Walkthrough pertama menguji dua laporan yang paling sering diminta. Ambang 50 dipakai terhadap data awal, lalu TEH-002 dijual 20 unit lewat InventoryService resmi untuk melihat laporan bergerak mengikuti data:
<?php
require __DIR__ . '/src/ReportService.php';
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-4-transactions-dan-validation/src/InventoryService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-database/schema.sql'));
$moves = new StockMovementRepository($pdo);
$service = new InventoryService($pdo, $moves);
$reports = new ReportService($pdo);
function rupiah(int $nilai): string
{
return 'Rp' . number_format($nilai, 0, ',', '.');
}
function cetakMenipis(array $baris): void
{
foreach ($baris as $p) {
printf(" %-9s %-24s %-15s sisa %3d\n", $p['sku'], $p['nama'], $p['kategori'], $p['stok_sekarang']);
}
}
echo "== stokMenipis(50) pada data awal ==\n";
cetakMenipis($reports->stokMenipis(50));
echo "\n== nilaiPersediaan() ==\n";
echo ' ' . rupiah($reports->nilaiPersediaan()) . "\n";
echo "\n== Penjualan besar: TEH-002 keluar 20 unit ==\n";
$service->keluarkanStok(2, 20, 'Penjualan INV-2601-310', '2026-01-22 14:10:00');
echo "== stokMenipis(50) setelahnya ==\n";
cetakMenipis($reports->stokMenipis(50));
echo "\n== nilaiPersediaan() setelahnya ==\n";
echo ' ' . rupiah($reports->nilaiPersediaan()) . "\n";
echo ' Turun 20 unit x Rp5.000 = ' . rupiah(20 * 5000) . "\n";Output yang Dihasilkan:
== stokMenipis(50) pada data awal ==
BUKU-006 Buku Tulis 58 Halaman Alat Tulis sisa 35
BISK-003 Biskuit Cokelat 120g Makanan Ringan sisa 45
== nilaiPersediaan() ==
Rp2.410.000
== Penjualan besar: TEH-002 keluar 20 unit ==
== stokMenipis(50) setelahnya ==
BUKU-006 Buku Tulis 58 Halaman Alat Tulis sisa 35
TEH-002 Teh Celup Melati Minuman sisa 40
BISK-003 Biskuit Cokelat 120g Makanan Ringan sisa 45
== nilaiPersediaan() setelahnya ==
Rp2.310.000
Turun 20 unit x Rp5.000 = Rp100.000Sebelum penjualan, hanya BUKU-006 (35) dan BISK-003 (45) yang berada di bawah ambang — ORDER BY p.stok_sekarang, p.nama menempatkan yang paling mendesak di atas. Nilai persediaan awal Rp2.410.000 bisa diperiksa dengan tangan: 100×2.500 + 60×5.000 + 45×8.500 + 80×9.000 + 200×3.000 + 35×4.500. Setelah 20 unit teh terjual lewat jalur resmi Bagian 4, dua laporan langsung mengikuti: TEH-002 masuk daftar menipis di posisi tengah, dan nilai persediaan turun tepat 20×Rp5.000. Perhatikan bahwa laporan tidak tahu movement mana yang mengubah stok — laporan hanya membaca kondisi terkini, dan itulah tugasnya.
Laporan Pergerakan per Periode #
pergerakanPeriode() menerima batas bawah dan batas atas sebagai string waktu, keduanya inklusif. Demo berikut memakai jam beku agar output deterministik — pada aplikasi nyata, nilai ini datang dari input pengguna atau rentang kalender:
<?php
require __DIR__ . '/src/ReportService.php';
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-4-transactions-dan-validation/src/InventoryService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-database/schema.sql'));
$moves = new StockMovementRepository($pdo);
$service = new InventoryService($pdo, $moves);
$reports = new ReportService($pdo);
function cetakPeriode(ReportService $reports, string $mulai, string $sampai): void
{
$p = $reports->pergerakanPeriode($mulai, $sampai);
printf(" masuk : %d transaksi, %d unit\n", $p['masuk']['transaksi'], $p['masuk']['unit']);
printf(" keluar: %d transaksi, %d unit\n", $p['keluar']['transaksi'], $p['keluar']['unit']);
}
echo "== Minggu pertama (5-9 Januari 2026) ==\n";
cetakPeriode($reports, '2026-01-05 00:00:00', '2026-01-09 23:59:59');
echo "\n== Periode sepi (Februari 2026) ==\n";
cetakPeriode($reports, '2026-02-01 00:00:00', '2026-02-28 23:59:59');
echo "\n== Aktivitas baru: pembelian & penjualan BUKU-006 ==\n";
$service->masukkanStok(6, 40, 'Pembelian PO-2601-020', '2026-01-20 09:00:00');
$service->keluarkanStok(6, 15, 'Penjualan INV-2601-201', '2026-01-20 15:30:00');
echo "== Periode 10-31 Januari (seed + aktivitas baru) ==\n";
cetakPeriode($reports, '2026-01-10 00:00:00', '2026-01-31 23:59:59');
echo "\n== Periode 20 Januari saja ==\n";
cetakPeriode($reports, '2026-01-20 00:00:00', '2026-01-20 23:59:59');Output yang Dihasilkan:
== Minggu pertama (5-9 Januari 2026) ==
masuk : 6 transaksi, 595 unit
keluar: 2 transaksi, 25 unit
== Periode sepi (Februari 2026) ==
masuk : 0 transaksi, 0 unit
keluar: 0 transaksi, 0 unit
== Aktivitas baru: pembelian & penjualan BUKU-006 ==
== Periode 10-31 Januari (seed + aktivitas baru) ==
masuk : 1 transaksi, 40 unit
keluar: 2 transaksi, 65 unit
== Periode 20 Januari saja ==
masuk : 1 transaksi, 40 unit
keluar: 1 transaksi, 15 unitMinggu pertama melipat delapan movement menjadi dua baris: enam pembelian total 595 unit, dua penjualan total 25 unit. Periode Februari memperlihatkan kontrak bentuk hasil — kedua kunci tetap ada bernilai nol, bukan array kosong. Periode 10-31 Januari menggabungkan sisa seed (movement ke-8, keluar 50) dengan dua aktivitas baru; perhatikan bahwa penjualan keluar 50 dari seed dan keluar 15 dari aktivitas baru dilebur menjadi satu baris keluar, 2 transaksi 65 unit — itulah pekerjaan GROUP BY jenis. Terakhir, periode satu hari memperlihatkan batas yang inklusif di kedua sisi: movement jam 09:00 dan 15:30 pada 20 Januari sama-sama ikut terhitung.
Pencarian yang Aman terhadap Wildcard #
Pencarian tampak seperti query paling sederhana di seluruh proyek: WHERE nama LIKE '%kopi%'. Masalahnya, karakter % dan _ pada input pengguna adalah wildcard — dan pengguna toko justru wajar mengetik “50%” saat mencari produk diskon. Demo berikut menyimpan produk bernama Promo Diskon 50% Kopi, lalu membandingkan pencarian aman dengan LIKE mentah:
<?php
require __DIR__ . '/src/ReportService.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-database/schema.sql'));
$products = new ProductRepository($pdo);
$reports = new ReportService($pdo);
function cetakHasil(array $baris): void
{
if ($baris === []) {
echo " (tidak ada hasil)\n";
return;
}
foreach ($baris as $p) {
printf(" %-9s %-26s %-15s Rp%d\n", $p['sku'], $p['nama'], $p['kategori'], $p['harga_satuan']);
}
}
echo "== pencarian('kopi'): kapital tidak penting ==\n";
cetakHasil($reports->pencarian('kopi'));
echo "\n== pencarian('TEH'): cocok di nama maupun SKU ==\n";
cetakHasil($reports->pencarian('TEH'));
echo "\n== pencarian('', kategori 1): browsing kategori Minuman ==\n";
cetakHasil($reports->pencarian('', 1));
echo "\n== Produk baru dengan '%' di nama ==\n";
$products->simpan('PROMO-010', 'Promo Diskon 50% Kopi', 1, 3000);
echo "== pencarian('50%'): persen berarti literal persen ==\n";
cetakHasil($reports->pencarian('50%'));
echo "\n== Jebakan '_' bila pola tidak di-escape ==\n";
$stmt = $pdo->query("SELECT sku FROM products WHERE sku LIKE '%KOPI_001%'");
$mentah = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo ' LIKE mentah %KOPI_001% : ' . implode(', ', $mentah) . "\n";
echo ' pencarian(KOPI_001) : ' . count($reports->pencarian('KOPI_001')) . " hasil\n";Output yang Dihasilkan:
== pencarian('kopi'): kapital tidak penting ==
KOPI-001 Kopi Hitam Sachet 20g Minuman Rp2500
== pencarian('TEH'): cocok di nama maupun SKU ==
TEH-002 Teh Celup Melati Minuman Rp5000
== pencarian('', kategori 1): browsing kategori Minuman ==
KOPI-001 Kopi Hitam Sachet 20g Minuman Rp2500
TEH-002 Teh Celup Melati Minuman Rp5000
== Produk baru dengan '%' di nama ==
== pencarian('50%'): persen berarti literal persen ==
PROMO-010 Promo Diskon 50% Kopi Minuman Rp3000
== Jebakan '_' bila pola tidak di-escape ==
LIKE mentah %KOPI_001% : KOPI-001
pencarian(KOPI_001) : 0 hasilEmpat blok pertama memperlihatkan perilaku dasar: kopi kecil menemukan Kopi Hitam Sachet 20g karena SQLite membandingkan LIKE tanpa peduli kapital; TEH menemukan Teh Celup lewat nama sekaligus SKU; pola kosong plus filter kategori menjadi mode browsing. Blok “50%” adalah inti bagian ini — persen diperlakukan sebagai karakter biasa dan hanya produk yang memuat 50% yang muncul. Blok terakhir memperlihatkan jebakan yang dihindari: LIKE mentah dengan pola %KOPI_001% salah menemukan SKU KOPI-001 karena _ berarti “satu karakter apa pun”, sedangkan pencarian('KOPI_001') yang di-escape mengembalikan nol hasil dengan benar.
Keselamatan ini lahir dari tiga baris di pencarian(). str_replace mengubah \, %, _ menjadi \\, \%, \_ sebelum pola dibungkus %...%, dan klausa ESCAPE '\' memberi tahu SQLite bahwa backslash adalah karakter escape:
Cara Menjalankan Contoh #
Bagian ini membutuhkan berkas bagian 1-4 sebagai tetangga. Susunan foldernya:
sistem-inventaris/
├── bagian-1-desain-database/
│ └── schema.sql
├── bagian-2-produk-dan-kategori/
│ └── src/
│ ├── CategoryRepository.php
│ └── ProductRepository.php
├── bagian-3-stock-movements/
│ └── src/
│ └── StockMovementRepository.php
├── bagian-4-transactions-dan-validation/
│ └── src/
│ ├── InventoryService.php
│ └── Validator.php
└── bagian-5-laporan-dan-search/
├── src/
│ └── ReportService.php
└── uji-laporan.phpSimpan salah satu walkthrough di atas sebagai uji-laporan.php di dalam folder bagian-5-laporan-dan-search, lalu jalankan:
php uji-laporan.phpReportService hanya membutuhkan koneksi PDO — ia tidak menerima repository mana pun karena seluruh query laporan didefinisikan di dalamnya. Seluruh contoh tetap memakai :memory:, jadi setiap eksekusi dimulai dari data awal yang sama dan tidak meninggalkan berkas database.
Apa yang Berubah #
Proyek mendapatkan matanya yang pertama:
- Berkas
src/ReportService.phpbaru dengan empat method baca:stokMenipis()(JOIN + filter ambang, urut stok menaik),nilaiPersediaan()(agregatSUMatas hasil kali kolom, integer rupiah),pergerakanPeriode()(GROUP BY jenisdengan bentuk hasil yang stabil), danpencarian()(LIKE dua kolom dengan escaping wildcard + filter kategori opsional). - Seluruh class sebelumnya tidak berubah satu baris pun —
ReportServicehanya menyusun query baru di atas schema dan koneksi yang sama; lapisan baca tidak perlu mengganggu lapisan tulis. - Keempat laporan terverifikasi pada data awal maupun data yang bergerak lewat
InventoryService: stok menipis bertambah TEH-002 setelah penjualan, nilai persediaan turun persis sebesar nilai yang terjual, periode sepi menghasilkan nol yang rapi, dan pencarian50%menemukan literal persen tanpa terpicu wildcard. - Invarian Bagian 4 tetap utuh oleh desain: laporan berjalan pada koneksi yang sama dengan
PRAGMA foreign_keys = ON, dan tidak ada satu pun method di class ini yang menulis.
Ringkasan #
ReportService menyelesaikan sisi konsumsi data proyek: stok menipis memberi daftar tindakan, nilai persediaan memberi angka untuk pembukuan, pergerakan per periode memberi denyut nadi gudang, dan pencarian memberi pintu masuk yang aman terhadap wildcard. Dengan ini, sistem inventaris berfungsi penuh dari tulis sampai baca. Namun ada satu perasaan yang mulai mengganggu: seluruh perilaku ini kita buktikan dengan menjalankan demo dan membaca outputnya dengan mata. Setiap perubahan kode di masa depakai berarti mengulang pembacaan itu — tugas yang mudah dilupakan. Bagian 6 menggantikan mata kita dengan PHPUnit: test otomatis yang membangun database segar per test, memaksa kegagalan transaksi, dan berteriak lewat exit code begitu satu perilaku dilanggar.
Referensi #
- SQLite Expression Syntax (LIKE dan ESCAPE) — sqlite.org
- PDO Prepared Statements — php.net
- Prepared Statements — Course PHP
- SQL Injection Prevention — Course PHP
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/ReportService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan baca untuk laporan dan pencarian inventaris.
*
* Repository pada Bagian 2 dan 3 menjawab pertanyaan per tabel - produk
* apa saja di kategori ini, riwayat movement produk mana. ReportService
* menjawab pertanyaan lintas tabel yang ditanyakan pengguna akhir setiap
* hari: produk mana yang stoknya menipis, berapa nilai uang seluruh
* persediaan, seberapa sibuk pergerakan pada satu periode, dan bagaimana
* menemukan produk dari sepotong nama atau SKU.
*
* Seluruh method di class ini hanya menjalankan SELECT - tidak ada satu
* pun penulisan, sehingga laporan tidak pernah bisa merusak data yang
* dilaporkannya.
*/
final class ReportService
{
public function __construct(private PDO $pdo)
{
// Konvensi proyek: pragma per koneksi dinyalakan di constructor.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Produk dengan stok kurang dari atau sama dengan ambang,
* stok terkecil ditampilkan lebih dulu.
*
* Laporan klasik untuk tim pembelian: daftar inilah yang menentukan
* produk apa yang perlu dipesan ulang. Nama kategori ikut dibawa
* lewat JOIN karena laporan tanpa kategori sulit dibaca manusia.
*
* @return list<array<string, mixed>> elemen id, sku, nama,
* kategori, stok_sekarang
*/
public function stokMenipis(int $ambang): array
{
$stmt = $this->pdo->prepare(
'SELECT p.id, p.sku, p.nama, c.nama AS kategori, p.stok_sekarang
FROM products p
JOIN categories c ON c.id = p.kategori_id
WHERE p.stok_sekarang <= ?
ORDER BY p.stok_sekarang, p.nama'
);
$stmt->execute([$ambang]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Total nilai persediaan dalam rupiah: SUM(harga x stok).
*
* harga_satuan disimpan sebagai integer rupiah tanpa sen sejak
* Bagian 1, sehingga hasilnya juga integer - tidak ada pembulatan
* float yang bisa membuat dua laporan berbeda satu rupiah.
* COALESCE menjaga tabel kosong tetap menghasilkan 0, bukan NULL.
*/
public function nilaiPersediaan(): int
{
return (int) $this->pdo
->query('SELECT COALESCE(SUM(harga_satuan * stok_sekarang), 0)
FROM products')
->fetchColumn();
}
/**
* Ringkasan pergerakan stok pada satu periode, dikelompokkan per jenis.
*
* GROUP BY jenis melipat seluruh movement dalam periode menjadi dua
* baris (masuk, keluar). Array hasil diinisialisasi lebih dulu di PHP
* sehingga kedua kunci SELALU ada - periode sepi menghasilkan nol,
* bukan kunci yang hilang, dan pemanggil tidak perlu isset().
*
* Batas periode dibandingkan sebagai teks terhadap kolom dibuat_pada
* format 'YYYY-MM-DD HH:MM:SS'; keduanya inklusif.
*
* @return array{masuk: array{transaksi: int, unit: int},
* keluar: array{transaksi: int, unit: int}}
*/
public function pergerakanPeriode(string $mulai, string $sampai): array
{
$stmt = $this->pdo->prepare(
'SELECT jenis, COUNT(*) AS transaksi, SUM(jumlah) AS unit
FROM stock_movements
WHERE dibuat_pada >= ? AND dibuat_pada <= ?
GROUP BY jenis'
);
$stmt->execute([$mulai, $sampai]);
$hasil = [
'masuk' => ['transaksi' => 0, 'unit' => 0],
'keluar' => ['transaksi' => 0, 'unit' => 0],
];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $baris) {
$hasil[(string) $baris['jenis']] = [
'transaksi' => (int) $baris['transaksi'],
'unit' => (int) $baris['unit'],
];
}
return $hasil;
}
/**
* Cari produk dari sepotong nama atau SKU, opsional dibatasi kategori.
*
* Karakter wildcard LIKE (%, _) pada $q di-escape bersama karakter
* escape-nya sendiri, lalu pola dipakai dengan klausa ESCAPE '\'.
* Hasilnya, pencarian "50%" mencari empat karakter literal "50%",
* bukan pola "50 diikuti apa pun" - dan pencarian "KOPI_001" tidak
* lagi cocok dengan SKU "KOPI-001" karena garis bawah berarti benar
* -benar garis bawah.
*
* SQLite membandingkan LIKE tanpa peduli kapital untuk huruf ASCII,
* jadi "kopi" menemukan "Kopi Hitam Sachet 20g".
*
* @param string $q potongan nama atau SKU; string kosong berarti
* seluruh produk (berguna untuk browsing per kategori)
* @param int|null $kategoriId batasi hasil pada kategori ini;
* kategori tanpa produk menghasilkan array kosong
* @return list<array<string, mixed>> elemen id, sku, nama, kategori,
* harga_satuan, stok_sekarang, urut berdasarkan nama
*/
public function pencarian(string $q, ?int $kategoriId = null): array
{
$q = trim($q);
$pola = '%' . str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
$q
) . '%';
$sql = "SELECT p.id, p.sku, p.nama, c.nama AS kategori,
p.harga_satuan, p.stok_sekarang
FROM products p
JOIN categories c ON c.id = p.kategori_id
WHERE (p.nama LIKE ? ESCAPE '\\' OR p.sku LIKE ? ESCAPE '\\')";
$params = [$pola, $pola];
if ($kategoriId !== null) {
$sql .= ' AND p.kategori_id = ?';
$params[] = $kategoriId;
}
$stmt = $this->pdo->prepare($sql . ' ORDER BY p.nama');
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}