Bagian 3 berakhir dengan pengakuan yang jujur: buku besar mencatat 70 unit, kolom stok masih 45, dan keduanya tidak lagi saling menjelaskan. Celah itu bukan hal baru dalam pengembangan perangkat lunak — dua penulisan yang harus terjadi bersama-sama adalah masalah klasik yang telah punya jawaban baku sejak puluhan tahun: transaksi database. Pada bagian ini kita membangun InventoryService, lapisan service yang membungkus “catat movement + perbarui stok” dalam satu beginTransaction()/commit(), dengan rollBack() yang memastikan kegagalan di tengah jalan tidak meninggalkan setengah pekerjaan. Setelah service berdiri, kita lengkapi gerbang paling depan dengan Validator bergaya error bag sehingga data produk yang buruk tertolak dengan pesan manusiawi sebelum menyentuh database sama sekali.

Dua Penulisan yang Harus Atomik #

Mari kita sebut namanya: masukkanStok() dan keluarkanStok() masing-masing melakukan dua penulisan — INSERT ke stock_movements dan UPDATE ke products.stok_sekarang. Tanpa transaksi, ada jendela waktu di antara keduanya, dan segala macam kegagalan bisa masuk lewat jendela itu: koneksi terputus, proses terbunuh, disk penuh, atau exception dari layanan eksternal seperti payment gateway. Skenario terburuknya sudah kita lihat di Bagian 3: movement tercatat tetapi stok tidak berubah, atau sebaliknya.

Transaksi database mengubah janji menjadi: semua penulisan dalam satu blok terjadi semua, atau tidak ada sama sekali (atomicity). PDO menyediakan API-nya langsung di objek koneksi — beginTransaction() menandai awal blok, commit() mengukuhkan seluruh penulisan, dan rollBack() membatalkan seluruhnya seolah tidak pernah terjadi. Pembahasan lengkap pola ini ada pada chapter Transactions ; di sini kita memakainya untuk invarian paling berharga proyek ini: kolom stok hanya boleh berubah jika movement yang sesuai ikut tercatat.

Class InventoryService #

Berikut isi lengkap src/InventoryService.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:

php
<?php

declare(strict_types=1);

/**
 * Lapisan service yang menjaga invarian inti inventaris:
 * kolom products.stok_sekarang hanya boleh berubah jika satu movement
 * yang sesuai ikut dicatat di stock_movements - keduanya dalam SATU
 * transaksi database, sehingga kegagalan di tengah jalan tidak
 * meninggalkan penulisan setengah jadi.
 *
 * Method di class ini adalah satu-satunya jalur resmi perubahan stok.
 * SQL UPDATE untuk kolom stok sengaja hidup di sini, bukan di
 * ProductRepository, agar tidak ada jalur lain yang bisa menyentuh
 * kolom tersebut tanpa melewati buku besar.
 */
final class InventoryService
{
    public function __construct(
        private PDO $pdo,
        private StockMovementRepository $movements,
    ) {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Tambah stok produk: catat movement masuk lalu naikkan stok,
     * keduanya atomik dalam satu transaksi.
     *
     * @param string $keterangan alasan perubahan, mis. nomor pembelian
     * @param string|null $dibuatPada jam beku untuk demo/test; null
     *        berarti database mengisi waktu server
     * @throws InvalidArgumentException bila jumlah tidak positif atau
     *         produk tidak ditemukan
     */
    public function masukkanStok(
        int $productId,
        int $jumlah,
        string $keterangan,
        ?string $dibuatPada = null,
    ): void {
        $this->assertJumlahPositif($jumlah);

        $this->pdo->beginTransaction();
        try {
            $this->movements->catat($productId, 'masuk', $jumlah, $keterangan, $dibuatPada);
            $this->ubahStok($productId, $jumlah);
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Kurangi stok produk: catat movement keluar lalu turunkan stok,
     * keduanya atomik dalam satu transaksi.
     *
     * Guard stok tetap dijaga catat() sebelum penulisan, sehingga
     * transaksi ini hanya berjalan untuk operasi yang masuk akal.
     *
     * @throws InvalidArgumentException bila jumlah tidak positif atau
     *         produk tidak ditemukan
     * @throws StokTidakCukupException bila stok tersedia lebih kecil
     *         dari jumlah yang diminta
     */
    public function keluarkanStok(
        int $productId,
        int $jumlah,
        string $keterangan,
        ?string $dibuatPada = null,
    ): void {
        $this->assertJumlahPositif($jumlah);

        $this->pdo->beginTransaction();
        try {
            $this->movements->catat($productId, 'keluar', $jumlah, $keterangan, $dibuatPada);
            $this->ubahStok($productId, -$jumlah);
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Ubah kolom stok_sekarang sebesar delta (bisa negatif).
     *
     * Constraint CHECK (stok_sekarang >= 0) di schema menjadi benteng
     * terakhir: jika karena suatu hal delta membuat stok negatif,
     * UPDATE ini gagal dan transaksi dibatalkan seluruhnya.
     */
    private function ubahStok(int $productId, int $delta): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE products
                SET stok_sekarang = stok_sekarang + ?
              WHERE id = ?'
        );
        $stmt->execute([$delta, $productId]);
    }

    private function assertJumlahPositif(int $jumlah): void
    {
        if ($jumlah <= 0) {
            throw new InvalidArgumentException(
                "Jumlah harus bilangan bulat positif, diberikan: $jumlah."
            );
        }
    }
}

Struktur kedua method publiknya identik dan itulah intinya:

  1. Validasi murah dulu (assertJumlahPositif) sebelum transaksi dibuka — tidak ada gunanya membuka transaksi untuk input yang jelas-jelas salah.
  2. beginTransaction() menandai awal blok atomik; mulai titik ini, penulisan milik transaksi dan belum permanen.
  3. catat() menjalankan validasi dan guard stok dari Bagian 3, lalu menulis movement. Bila guard melempar StokTidakCukupException, alur langsung melompat ke catch — belum ada yang tertulis, dan rollback mengukuhkannya.
  4. ubahStok() menaikkan atau menurunkan kolom stok_sekarang sebesar delta. Constraint CHECK (stok_sekarang >= 0) dari Bagian 1 menjadi benteng terakhir: bila UPDATE ini gagal, exception-nya juga mengalir ke catch dan seluruh transaksi batal — movement yang tadi dicatat ikut terhapus.
  5. commit() mengukuhkan keduanya menjadi permanen sekaligus; catch + rollBack() + throw $e membatalkan semuanya lalu meneruskan exception ke pemanggil, karena kegagalan bukan untuk ditelan.

SQL UPDATE untuk kolom stok sengaja hidup di class ini, bukan di ProductRepository. Alasannya arsitektural: siapa pun yang memegang method ubahStok() publik bisa memalsukan stok tanpa meninggalkan catatan. Dengan menjadikannya method privat milik satu-satunya class yang boleh mengubah stok, jalur pemalsuan itu ditutup — invarian “stok berubah ⟺ movement tercatat” dimiliki oleh satu tempat saja.

Walkthrough Masuk dan Keluar Stok #

Alur bahagia terlebih dahulu: BUKU-006 yang berstok 35 menerima pembelian 40 unit, lalu menjual 15 unit. Kedua operasi berjalan lewat service, dan di akhir kita rekonsiliasi kedua tabel:

php
<?php
require __DIR__ . '/src/InventoryService.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.php';
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.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);
$moves = new StockMovementRepository($pdo);
$service = new InventoryService($pdo, $moves);

function cetakProduk(array $produk): void
{
    printf("  %s (%s) - stok %d\n", $produk['nama'], $produk['sku'], $produk['stok_sekarang']);
}

echo "== Sebelum ==\n";
cetakProduk($products->cariSku('BUKU-006'));

echo "\n== masukkanStok(6, 40, 'Pembelian PO-2601-010') ==\n";
$service->masukkanStok(6, 40, 'Pembelian PO-2601-010', '2026-01-20 09:00:00');
cetakProduk($products->cariSku('BUKU-006'));

echo "\n== keluarkanStok(6, 15, 'Penjualan INV-2601-201') ==\n";
$service->keluarkanStok(6, 15, 'Penjualan INV-2601-201', '2026-01-20 15:30:00');
cetakProduk($products->cariSku('BUKU-006'));

echo "\nRiwayat BUKU-006:\n";
foreach ($moves->riwayatByProduct(6) as $m) {
    printf("  #%-2d %-6s %3d  %s\n", $m['id'], $m['jenis'], $m['jumlah'], $m['keterangan']);
}

echo "\nRekonsiliasi:\n";
$totals = $moves->totalsByJenis(6);
echo "  totalsByJenis  : masuk={$totals['masuk']}, keluar={$totals['keluar']}\n";
echo '  stokMenurutLedger: ' . $moves->stokMenurutLedger(6) . "\n";
$buku = $products->cariSku('BUKU-006');
echo "  stok_sekarang    : {$buku['stok_sekarang']}\n";
echo "  Kedua tabel berubah bersamaan dan saling cocok\n";

Output yang Dihasilkan:

text
== Sebelum ==
  Buku Tulis 58 Halaman (BUKU-006) - stok 35

== masukkanStok(6, 40, 'Pembelian PO-2601-010') ==
  Buku Tulis 58 Halaman (BUKU-006) - stok 75

== keluarkanStok(6, 15, 'Penjualan INV-2601-201') ==
  Buku Tulis 58 Halaman (BUKU-006) - stok 60

Riwayat BUKU-006:
  #9  masuk   35  Pembelian awal PO-2601-004
  #10 masuk   40  Pembelian PO-2601-010
  #11 keluar  15  Penjualan INV-2601-201

Rekonsiliasi:
  totalsByJenis  : masuk=75, keluar=15
  stokMenurutLedger: 60
  stok_sekarang    : 60
  Kedua tabel berubah bersamaan dan saling cocok

Stok bergerak 35 → 75 → 60, dan buku besar mengikutinya: movement #10 (masuk 40) serta #11 (keluar 15) tampil di riwayat. Bagian rekonsiliasi adalah dendang cerita bagian ini — stokMenurutLedger() dan stok_sekarang sama-sama menunjuk angka 60. Divergensi yang kita biarkan terjadi di Bagian 3 kini mustahil terjadi lewat jalur resmi, karena kedua penulisan hidup dalam satu transaksi: masukkanStok() dan keluarkanStok() tidak punya cara untuk berhasil setengah.

Rollback Saat Kegagalan #

Janji “semua atau tidak sama sekali” baru berarti bila kita bisa membuktikan sisi “tidak sama sekali”. Tiga skenario berikut melakukannya bertingkat: penolakan guard, kegagalan di tengah proses tanpa transaksi, dan kegagalan yang sama dengan transaksi:

php
<?php
require __DIR__ . '/src/InventoryService.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.php';
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.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);
$moves = new StockMovementRepository($pdo);
$service = new InventoryService($pdo, $moves);

echo "== Skenario 1: permintaan melebihi stok ==\n";
try {
    $service->keluarkanStok(6, 500, 'Penjualan INV-2601-999', '2026-01-21 10:00:00');
    echo "Berhasil - seharusnya tidak pernah terjadi\n";
} catch (StokTidakCukupException $e) {
    echo "Ditolak: {$e->getMessage()}\n";
}
$buku = $products->cariSku('BUKU-006');
$jumlah = count($moves->riwayatByProduct(6));
echo "Jejak yang tertinggal: stok {$buku['stok_sekarang']}, movement $jumlah baris - tidak ada perubahan\n";

echo "\n== Skenario 2: kegagalan di tengah proses, TANPA transaksi ==\n";
try {
    $moves->catat(6, 'keluar', 10, 'Pesanan #P-777 tanpa transaksi', '2026-01-21 10:05:00');
    // Simulasi kegagalan tepat setelah penulisan pertama. Pada aplikasi
    // nyata: gateway pembayaran timeout, proses terbunuh, disk penuh.
    throw new RuntimeException('SIMULASI: gateway pembayaran gagal merespons');
} catch (RuntimeException $e) {
    echo "Proses gagal: {$e->getMessage()}\n";
}
$buku = $products->cariSku('BUKU-006');
$ledger = $moves->stokMenurutLedger(6);
echo "Keadaan database: stok {$buku['stok_sekarang']}, ledger $ledger - TIDAK konsisten\n";

echo "\n== Skenario 3: kegagalan yang sama, DENGAN transaksi ==\n";
$pdo->beginTransaction();
try {
    $moves->catat(6, 'keluar', 10, 'Pesanan #P-778 dengan transaksi', '2026-01-21 10:10:00');
    throw new RuntimeException('SIMULASI: gateway pembayaran gagal merespons');
} catch (RuntimeException $e) {
    $pdo->rollBack();
    echo "Proses gagal: {$e->getMessage()}\n";
    echo "Transaksi dibatalkan - movement setengah jadi ikut terhapus\n";
}
$buku = $products->cariSku('BUKU-006');
$ledger = $moves->stokMenurutLedger(6);
echo "Keadaan database: stok {$buku['stok_sekarang']}, ledger $ledger - persis seperti sebelum skenario 3\n";
echo "Catatan: selisih dari skenario 2 tidak sembuh sendiri - kerusakan data bersifat permanen\n";

Output yang Dihasilkan:

text
== Skenario 1: permintaan melebihi stok ==
Ditolak: Stok Buku Tulis 58 Halaman (BUKU-006) tidak cukup: tersisa 35, diminta 500.
Jejak yang tertinggal: stok 35, movement 1 baris - tidak ada perubahan

== Skenario 2: kegagalan di tengah proses, TANPA transaksi ==
Proses gagal: SIMULASI: gateway pembayaran gagal merespons
Keadaan database: stok 35, ledger 25 - TIDAK konsisten

== Skenario 3: kegagalan yang sama, DENGAN transaksi ==
Proses gagal: SIMULASI: gateway pembayaran gagal merespons
Transaksi dibatalkan - movement setengah jadi ikut terhapus
Keadaan database: stok 35, ledger 25 - persis seperti sebelum skenario 3
Catatan: selisih dari skenario 2 tidak sembuh sendiri - kerusakan data bersifat permanen

Bacalah ketiga skenario itu sebagai satu cerita. Skenario 1 menunjukkan jalur resmi yang aman: guard di catat() melempar StokTidakCukupException sebelum penulisan, keluarkanStok() me-rollback, dan jejak yang tertinggal nol. Skenario 2 adalah cara yang salah: kita memanggil catat() secara telanjang, movement keluar 10 tertulis, lalu kegagalan simulasi datang — dan database ditinggalkan dalam keadaan stok 35, ledger 25, tidak konsisten. Skenario 3 mengulang kegagalan yang persis sama tetapi di dalam beginTransaction(); setelah rollBack(), keadaan kembali ke stok 35, ledger 25 — persis seperti sebelum skenario 3 dimulai, artinya movement setengah jadi tadi benar-benar terhapus.

Baris terakhir output adalah pelajaran yang paling mahal: selisih dari skenario 2 tetap ada. Rollback pada skenario 3 hanya membatalkan pekerjaan skenario 3 sendiri; kerusakan yang sudah terjadi sebelumnya tidak sembuh sendiri. Integritas data bukan sesuatu yang diperbaiki belakangan — ia harus dijaga sejak penulisan pertama, dan itulah mengapa seluruh perubahan stok wajib melewati service ini.

Class Validator #

Dengan service berdiri, satu celah pengalaman masih tersisa: data produk yang buruk. ProductRepository::simpan() akan dengan senang hati menyimpan SKU kopi-008 huruf kecil atau harga 0 — constraint database hanya menolak sebagian, dan pesannya berbahasa mesin. Validator memeriksa empat kolom produk sebelum menyentuh database dan mengumpulkan seluruh galatnya dalam satu kantong (error bag):

php
<?php

declare(strict_types=1);

/**
 * Validasi data produk sebelum disimpan ke database.
 *
 * Pola yang dipakai: error bag - seluruh kolom diperiksa sekali jalan
 * dan hasilnya berupa array asosiatif kolom => pesan. Array kosong
 * berarti data valid dan siap diteruskan ke ProductRepository::simpan()
 * atau perbarui(). Constraint database (UNIQUE sku, foreign key
 * kategori) tetap berdiri sebagai baris pertahanan terakhir.
 */
final class Validator
{
    public function __construct(
        private CategoryRepository $categories,
        private ProductRepository $products,
    ) {
    }

    /**
     * Validasi satu rangkaian data produk.
     *
     * @param array<string, mixed> $data elemen sku, nama, kategori_id,
     *        dan harga_satuan
     * @param int|null $abaikanId id produk yang sedang diperbarui;
     *        SKU milik produk tersebut tidak dihitung duplikat
     * @return array<string, string> daftar pesan galat per kolom
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $galat = [];

        $sku = trim((string) ($data['sku'] ?? ''));
        $nama = trim((string) ($data['nama'] ?? ''));
        $kategoriId = $data['kategori_id'] ?? null;
        $harga = $data['harga_satuan'] ?? null;

        if ($sku === '') {
            $galat['sku'] = 'SKU wajib diisi.';
        } elseif (!preg_match('/^[A-Z0-9]+(-[A-Z0-9]+)*$/', $sku)
            || mb_strlen($sku) < 3 || mb_strlen($sku) > 20) {
            $galat['sku'] = 'SKU hanya boleh huruf besar, angka, dan tanda hubung, panjang 3-20 karakter.';
        } elseif ($this->skuTerpakai($sku, $abaikanId)) {
            $galat['sku'] = "SKU $sku sudah dipakai produk lain.";
        }

        if ($nama === '') {
            $galat['nama'] = 'Nama produk wajib diisi.';
        } elseif (mb_strlen($nama) < 3) {
            $galat['nama'] = 'Nama produk minimal 3 karakter.';
        }

        if ($kategoriId === null || $kategoriId === '') {
            $galat['kategori_id'] = 'Kategori wajib dipilih.';
        } elseif (!ctype_digit((string) $kategoriId)) {
            $galat['kategori_id'] = 'Kategori harus berupa angka.';
        } elseif ($this->categories->cariId((int) $kategoriId) === null) {
            $galat['kategori_id'] = 'Kategori tidak ditemukan.';
        }

        $hargaBulat = filter_var($harga, FILTER_VALIDATE_INT, [
            'options' => ['min_range' => 1],
        ]);
        if ($harga === null || $harga === '') {
            $galat['harga_satuan'] = 'Harga satuan wajib diisi.';
        } elseif ($hargaBulat === false) {
            $galat['harga_satuan'] = 'Harga satuan harus bilangan bulat lebih dari 0.';
        }

        return $galat;
    }

    /**
     * Apakah SKU sudah dimiliki produk lain (bukan $abaikanId)?
     */
    private function skuTerpakai(string $sku, ?int $abaikanId): bool
    {
        $existing = $this->products->cariSku($sku);

        return $existing !== null && (int) $existing['id'] !== $abaikanId;
    }
}

Rangkuman aturan dan alasannya:

KolomAturanAlasan
skuwajib; huruf besar/angka/tanda hubung; 3-20 karakter; belum dipakai produk lainformat konsisten untuk label gudang; regex menolak spasi dan huruf kecil sejak awal
namawajib; minimal 3 karakternama satu huruf hampir pasti salah ketik
kategori_idwajib; angka; kategori benar-benar adapertanyaan “kategori ada atau tidak” hanya bisa dijawab database lewat cariId()
harga_satuanwajib; bilangan bulat > 0filter_var() dengan FILTER_VALIDATE_INT + min_range menolak nol, negatif, dan desimal sekaligus

Dua detail implementasi layak digarisbawahi. Pemeriksaan SKU berjenjang lewat elseif: format diperiksa dulu, dan cek duplikat ke database hanya berjalan bila formatnya sah — tidak ada gunanya menanyakan kopi-008 ke database ketika formatnya sudah jelas melanggar. Parameter $abaikanId menjawab kasus pembaruan: produk yang sedang disunting berhak mempertahankan SKU-nya sendiri tanpa dituduh duplikat, persis pola yang dipakai validator pada proyek Aplikasi Kontak.

Walkthrough Validasi #

Empat kasus sekaligus: data valid yang langsung tersimpan, empat kolom bermasalah yang dilaporkan dalam satu putaran, SKU duplikat, dan pembaruan yang memakai SKU miliknya sendiri:

php
<?php
require __DIR__ . '/src/Validator.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/CategoryRepository.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'));

$categories = new CategoryRepository($pdo);
$products = new ProductRepository($pdo);
$validator = new Validator($categories, $products);

echo "== Data valid ==\n";
$dataBaru = [
    'sku' => 'SPID-007',
    'nama' => 'Spidol Permanen Hitam',
    'kategori_id' => 4,
    'harga_satuan' => 7500,
];
$galat = $validator->validate($dataBaru);
echo $galat === [] ? "Semua kolom valid\n" : "Masih ada galat\n";

$idBaru = $products->simpan(
    $dataBaru['sku'],
    $dataBaru['nama'],
    (int) $dataBaru['kategori_id'],
    (int) $dataBaru['harga_satuan'],
);
echo "Tersimpan sebagai produk #$idBaru\n";

echo "\n== Empat kolom bermasalah sekaligus ==\n";
$galat = $validator->validate([
    'sku' => 'kopi-008',
    'nama' => '  ',
    'kategori_id' => 99,
    'harga_satuan' => 0,
]);
foreach ($galat as $kolom => $pesan) {
    echo "$kolom: $pesan\n";
}

echo "\n== SKU duplikat ==\n";
$galat = $validator->validate([
    'sku' => 'KOPI-001',
    'nama' => 'Kopi Duplikat',
    'kategori_id' => 1,
    'harga_satuan' => 2000,
]);
foreach ($galat as $kolom => $pesan) {
    echo "$kolom: $pesan\n";
}

echo "\n== Pembaruan produk #1 memakai SKU-nya sendiri ==\n";
$galat = $validator->validate([
    'sku' => 'KOPI-001',
    'nama' => 'Kopi Hitam Sachet 25g',
    'kategori_id' => 1,
    'harga_satuan' => 2750,
], 1);
echo $galat === [] ? "Pembaruan produk #1 valid\n" : "Pembaruan ditolak\n";

Output yang Dihasilkan:

text
== Data valid ==
Semua kolom valid
Tersimpan sebagai produk #7

== Empat kolom bermasalah sekaligus ==
sku: SKU hanya boleh huruf besar, angka, dan tanda hubung, panjang 3-20 karakter.
nama: Nama produk wajib diisi.
kategori_id: Kategori tidak ditemukan.
harga_satuan: Harga satuan harus bilangan bulat lebih dari 0.

== SKU duplikat ==
sku: SKU KOPI-001 sudah dipakai produk lain.

== Pembaruan produk #1 memakai SKU-nya sendiri ==
Pembaruan produk #1 valid

Pada blok pertama, array kosong menjadi tanda lunas dan produk SPID-007 tersimpan pada kategori Peralatan Kantor yang sejak Bagian 1 menunggu penghuni. Blok kedua memperlihatkan kekuatan error bag: keempat kolom salah, dan keempatnya dilaporkan dalam satu panggilan — trim() membuat nama yang hanya berisi spasi terbaca kosong, regex menolak huruf kecil pada SKU, cariId(99) mengembalikan null untuk kategori asing, dan filter_var() menolak harga nol. Blok ketiga adalah cek duplikat yang dijawab cariSku() dari Bagian 2. Blok terakhir membuktikan $abaikanId bekerja: KOPI-001 memang sudah terpakai, tetapi terpakai oleh produk #1 — produk yang sedang diperbarui — sehingga pembaruan sah. Alur penyimpanan resmi proyek kini berlapis tiga: validate() di depan, simpan() di tengah, dan catch 23000 beserta seluruh constraint schema sebagai baris terakhir.

Cara Menjalankan Contoh #

Bagian ini membutuhkan berkas dari ketiga bagian sebelumnya. Susun foldernya sebagai berikut:

text
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
    └── uji-transaksi.php

Simpan salah satu walkthrough di atas sebagai uji-transaksi.php di dalam folder bagian-4-transactions-dan-validation, lalu jalankan:

bash
php uji-transaksi.php

Urutan require bebas karena PHP baru memeriksa tipe constructor saat objek dibuat; yang penting InventoryService.php dan StockMovementRepository.php sama-sama termuat sebelum new InventoryService(...) dipanggil. Seluruh contoh tetap memakai :memory: — tidak ada berkas database yang tertinggal, dan tiap eksekusi dimulai dari schema yang segar.

Apa yang Berubah #

Arsitektur proyek kini punya tiga lapis yang lengkap:

  • Berkas src/InventoryService.php baru dengan masukkanStok() dan keluarkanStok() — satu-satunya jalur resmi perubahan stok, masing-masing membungkus movement dan pembaruan stok dalam satu transaksi PDO dengan pola beginTransaction() → try → commit() → catch → rollBack() → throw.
  • Berkas src/Validator.php baru dengan satu method publik validate() yang mengembalikan kantong galat per kolom, cek duplikat SKU lewat cariSku(), dan $abaikanId untuk kasus pembaruan.
  • ProductRepository dan StockMovementRepository tidak berubah satu baris pun — service menyusunnya sebagai konsumen, bukan memodifikasinya; arah ketergantungan yang bersih membuat lapisan baru murah ditambahkan.
  • Bukti nyata terdokumentasi: alur bahagia merekonsiliasi sempurna (stok 60 = ledger 60), kegagalan guard tidak meninggalkan jejak, dan rollback menghapus movement setengah jadi.
  • Constraint CHECK dan UNIQUE dari Bagian 1 kini berdiri sebagai baris pertahanan ketiga setelah Validator dan pre-check — tiga lapis pertahanan yang masing-masing punya alasan hidup.

Ringkasan #

InventoryService menutup celah yang ditinggalkan Bagian 3: pencatatan movement dan pembaruan stok kini atomik, dibuktikan lewat alur bahagia yang merekonsiliasi sempurna dan dua skenario kegagalan yang memperlihatkan bedanya database dengan dan tanpa transaksi. Validator menambahkan gerbang paling depan dengan pesan berbahasa manusia yang lengkap dalam satu putaran. Data master, buku besar, transaksi, dan validasi kini berdiri utuh — sistem inventaris ini sudah bisa dipercaya menjaga integritasnya sendiri. Yang belum ada adalah cara melihatnya secara keseluruhan: laporan nilai persediaan, produk menipis, dan pencarian. Bagian 5 menyusun lapisan baca tersebut lewat ReportService.

Referensi #

Berkas Sumber

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

src/InventoryService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan service yang menjaga invarian inti inventaris:
 * kolom products.stok_sekarang hanya boleh berubah jika satu movement
 * yang sesuai ikut dicatat di stock_movements - keduanya dalam SATU
 * transaksi database, sehingga kegagalan di tengah jalan tidak
 * meninggalkan penulisan setengah jadi.
 *
 * Method di class ini adalah satu-satunya jalur resmi perubahan stok.
 * SQL UPDATE untuk kolom stok sengaja hidup di sini, bukan di
 * ProductRepository, agar tidak ada jalur lain yang bisa menyentuh
 * kolom tersebut tanpa melewati buku besar.
 */
final class InventoryService
{
    public function __construct(
        private PDO $pdo,
        private StockMovementRepository $movements,
    ) {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Tambah stok produk: catat movement masuk lalu naikkan stok,
     * keduanya atomik dalam satu transaksi.
     *
     * @param string $keterangan alasan perubahan, mis. nomor pembelian
     * @param string|null $dibuatPada jam beku untuk demo/test; null
     *        berarti database mengisi waktu server
     * @throws InvalidArgumentException bila jumlah tidak positif atau
     *         produk tidak ditemukan
     */
    public function masukkanStok(
        int $productId,
        int $jumlah,
        string $keterangan,
        ?string $dibuatPada = null,
    ): void {
        $this->assertJumlahPositif($jumlah);

        $this->pdo->beginTransaction();
        try {
            $this->movements->catat($productId, 'masuk', $jumlah, $keterangan, $dibuatPada);
            $this->ubahStok($productId, $jumlah);
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Kurangi stok produk: catat movement keluar lalu turunkan stok,
     * keduanya atomik dalam satu transaksi.
     *
     * Guard stok tetap dijaga catat() sebelum penulisan, sehingga
     * transaksi ini hanya berjalan untuk operasi yang masuk akal.
     *
     * @throws InvalidArgumentException bila jumlah tidak positif atau
     *         produk tidak ditemukan
     * @throws StokTidakCukupException bila stok tersedia lebih kecil
     *         dari jumlah yang diminta
     */
    public function keluarkanStok(
        int $productId,
        int $jumlah,
        string $keterangan,
        ?string $dibuatPada = null,
    ): void {
        $this->assertJumlahPositif($jumlah);

        $this->pdo->beginTransaction();
        try {
            $this->movements->catat($productId, 'keluar', $jumlah, $keterangan, $dibuatPada);
            $this->ubahStok($productId, -$jumlah);
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Ubah kolom stok_sekarang sebesar delta (bisa negatif).
     *
     * Constraint CHECK (stok_sekarang >= 0) di schema menjadi benteng
     * terakhir: jika karena suatu hal delta membuat stok negatif,
     * UPDATE ini gagal dan transaksi dibatalkan seluruhnya.
     */
    private function ubahStok(int $productId, int $delta): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE products
                SET stok_sekarang = stok_sekarang + ?
              WHERE id = ?'
        );
        $stmt->execute([$delta, $productId]);
    }

    private function assertJumlahPositif(int $jumlah): void
    {
        if ($jumlah <= 0) {
            throw new InvalidArgumentException(
                "Jumlah harus bilangan bulat positif, diberikan: $jumlah."
            );
        }
    }
}

src/Validator.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Validasi data produk sebelum disimpan ke database.
 *
 * Pola yang dipakai: error bag - seluruh kolom diperiksa sekali jalan
 * dan hasilnya berupa array asosiatif kolom => pesan. Array kosong
 * berarti data valid dan siap diteruskan ke ProductRepository::simpan()
 * atau perbarui(). Constraint database (UNIQUE sku, foreign key
 * kategori) tetap berdiri sebagai baris pertahanan terakhir.
 */
final class Validator
{
    public function __construct(
        private CategoryRepository $categories,
        private ProductRepository $products,
    ) {
    }

    /**
     * Validasi satu rangkaian data produk.
     *
     * @param array<string, mixed> $data elemen sku, nama, kategori_id,
     *        dan harga_satuan
     * @param int|null $abaikanId id produk yang sedang diperbarui;
     *        SKU milik produk tersebut tidak dihitung duplikat
     * @return array<string, string> daftar pesan galat per kolom
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $galat = [];

        $sku = trim((string) ($data['sku'] ?? ''));
        $nama = trim((string) ($data['nama'] ?? ''));
        $kategoriId = $data['kategori_id'] ?? null;
        $harga = $data['harga_satuan'] ?? null;

        if ($sku === '') {
            $galat['sku'] = 'SKU wajib diisi.';
        } elseif (!preg_match('/^[A-Z0-9]+(-[A-Z0-9]+)*$/', $sku)
            || mb_strlen($sku) < 3 || mb_strlen($sku) > 20) {
            $galat['sku'] = 'SKU hanya boleh huruf besar, angka, dan tanda hubung, panjang 3-20 karakter.';
        } elseif ($this->skuTerpakai($sku, $abaikanId)) {
            $galat['sku'] = "SKU $sku sudah dipakai produk lain.";
        }

        if ($nama === '') {
            $galat['nama'] = 'Nama produk wajib diisi.';
        } elseif (mb_strlen($nama) < 3) {
            $galat['nama'] = 'Nama produk minimal 3 karakter.';
        }

        if ($kategoriId === null || $kategoriId === '') {
            $galat['kategori_id'] = 'Kategori wajib dipilih.';
        } elseif (!ctype_digit((string) $kategoriId)) {
            $galat['kategori_id'] = 'Kategori harus berupa angka.';
        } elseif ($this->categories->cariId((int) $kategoriId) === null) {
            $galat['kategori_id'] = 'Kategori tidak ditemukan.';
        }

        $hargaBulat = filter_var($harga, FILTER_VALIDATE_INT, [
            'options' => ['min_range' => 1],
        ]);
        if ($harga === null || $harga === '') {
            $galat['harga_satuan'] = 'Harga satuan wajib diisi.';
        } elseif ($hargaBulat === false) {
            $galat['harga_satuan'] = 'Harga satuan harus bilangan bulat lebih dari 0.';
        }

        return $galat;
    }

    /**
     * Apakah SKU sudah dimiliki produk lain (bukan $abaikanId)?
     */
    private function skuTerpakai(string $sku, ?int $abaikanId): bool
    {
        $existing = $this->products->cariSku($sku);

        return $existing !== null && (int) $existing['id'] !== $abaikanId;
    }
}