Bagian 3 menutup dengan satu utang: order yang dibatalkan tidak mengembalikan stoknya, dan placeOrder() hanya menerima masukan yang sudah rapi. Pada bagian ini utang itu dilunasi dengan dua class baru. InventoryService menjadikan inventaris sebagai warga kelas satu — setiap perubahan stok, dari reservasi, restock, sampai pelepasan karena pembatalan, berjalan lewat satu pintu dan selalu meninggalkan baris jejak pada stock_movements. Validator menjadi gerbang sebelum transaksi: ia memeriksa customer, produk, qty, dan kecukupan stok sambil mengumpulkan seluruh galat dalam satu daftar, bukan berhenti di galat pertama. Bagian ini juga melatih komposisi transaksi: dua service berbagi satu PDO dan satu transaksi saat pembatalan melepaskan stok sekaligus mengubah status.

Setiap Perubahan Stok Meninggalkan Jejak #

Prinsip kerja InventoryService sederhana: kolom products.stok adalah angka ringkas yang boleh berubah, sedangkan stock_movements adalah kisah lengkapnya. Tidak ada operasi yang boleh mengubah yang pertama tanpa menulis baris pada yang kedua:

OperasiEfek ke stokBaris stock_movements
reserve() — reservasi orderberkurangkeluar + order_id
release() — pelepasanbertambahmasuk + order_id bila terkait order
tambahStok() — restockbertambahmasuk, order_id NULL

Dari jejak ini pertanyaan audit apa pun bisa dijawab: dari mana 12 keyboard di rak berasal, order mana yang mengambil dua, dan pembatalan mana yang mengembalikannya. Pada Bagian 6 nanti tabel yang sama menjadi sumber laporan. Perlu dicatat pula bahwa InventoryService sengaja tidak membuka transaksi sendiri — ia menulis di dalam transaksi pemanggilnya, sehingga perubahan stok dan perubahan order bisa terjadi atomik; inilah kebalikan pola OrderRepository pada Bagian 2 yang membuka transaksi sendiri saat dipakai sendirian.

Class InventoryService #

Berikut isi lengkap src/InventoryService.php:

php
<?php

declare(strict_types=1);

/**
 * Operasi inventaris berjejak audit: setiap perubahan stok selalu
 * disertai satu baris pada stock_movements.
 *
 * Class ini tidak membuka transaksinya sendiri; ia menulis di dalam
 * transaksi yang sedang aktif (mis. transaksi placeOrder atau transaksi
 * pembatalan order), sehingga perubahan stok dan order terjadi atomik.
 * Membutuhkan StokTidakCukupException dari bagian sebelumnya.
 */
final class InventoryService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Kurangi stok untuk sebuah order dan catat gerakan keluar.
     *
     * Pemeriksaan kecukupan dan pengurangan dilakukan satu perintah UPDATE
     * bersyarat (WHERE stok >= ?) sehingga atomik bahkan tanpa transaksi.
     */
    public function reserve(int $productId, int $qty, int $orderId, string $keterangan = ''): void
    {
        if ($qty < 1) {
            throw new InvalidArgumentException('Qty reservasi harus >= 1.');
        }

        $stmt = $this->pdo->prepare(
            'UPDATE products SET stok = stok - ? WHERE id = ? AND stok >= ?'
        );
        $stmt->execute([$qty, $productId, $qty]);
        if ($stmt->rowCount() !== 1) {
            $this->pastikanProdukAda($productId);
            throw new StokTidakCukupException("Stok produk #$productId tidak cukup untuk $qty unit.");
        }

        $this->catat('keluar', $productId, $qty, $orderId, $keterangan);
    }

    /**
     * Kembalikan stok yang sebelumnya direservasi dan catat gerakan masuk.
     */
    public function release(int $productId, int $qty, ?int $orderId = null, string $keterangan = ''): void
    {
        if ($qty < 1) {
            throw new InvalidArgumentException('Qty pelepasan harus >= 1.');
        }

        $stmt = $this->pdo->prepare('UPDATE products SET stok = stok + ? WHERE id = ?');
        $stmt->execute([$qty, $productId]);
        if ($stmt->rowCount() !== 1) {
            $this->pastikanProdukAda($productId);
        }

        $this->catat('masuk', $productId, $qty, $orderId, $keterangan);
    }

    /**
     * Terima barang baru ke rak (restock) dan catat gerakan masuk.
     */
    public function tambahStok(int $productId, int $qty, string $keterangan): void
    {
        $this->release($productId, $qty, null, $keterangan);
    }

    /**
     * Lepaskan seluruh item sebuah order: kembalikan stok tiap baris item.
     *
     * Dipanggil di dalam transaksi pembatalan order. Mengembalikan
     * jumlah baris item yang dilepaskan.
     */
    public function releaseForOrder(int $orderId): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT product_id, qty FROM order_items WHERE order_id = ? ORDER BY id'
        );
        $stmt->execute([$orderId]);
        $items = $stmt->fetchAll(PDO::FETCH_ASSOC);

        foreach ($items as $item) {
            $this->release(
                (int) $item['product_id'],
                (int) $item['qty'],
                $orderId,
                "pembatalan order #$orderId"
            );
        }

        return count($items);
    }

    /**
     * Stok terkini satu produk.
     */
    public function stokSaatIni(int $productId): int
    {
        $stmt = $this->pdo->prepare('SELECT stok FROM products WHERE id = ?');
        $stmt->execute([$productId]);
        $stok = $stmt->fetchColumn();
        if ($stok === false) {
            $this->pastikanProdukAda($productId);
        }

        return (int) $stok;
    }

    /**
     * Riwayat gerakan stok satu produk, urut dari yang paling lama.
     *
     * @return list<array<string, mixed>>
     */
    public function riwayatStok(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, jenis, qty, order_id, keterangan
               FROM stock_movements
              WHERE product_id = ?
              ORDER BY id'
        );
        $stmt->execute([$productId]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Catat satu baris gerakan stok.
     */
    private function catat(string $jenis, int $productId, int $qty, ?int $orderId, string $keterangan): void
    {
        $this->pdo->prepare(
            'INSERT INTO stock_movements (product_id, order_id, jenis, qty, keterangan)
             VALUES (?, ?, ?, ?, ?)'
        )->execute([$productId, $orderId, $jenis, $qty, $keterangan]);
    }

    /**
     * Lempar exception yang benar bila produk ternyata tidak ada.
     */
    private function pastikanProdukAda(int $productId): void
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM products WHERE id = ?');
        $stmt->execute([$productId]);
        if ((int) $stmt->fetchColumn() === 0) {
            throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
        }
    }
}

Tiga keputusan pada class tersebut perlu dibedah:

  1. reserve() memakai UPDATE bersyarat — WHERE stok >= ? menjadikan pemeriksaan kecukupan dan pengurangan satu perintah atomik. Bila rowCount() bernilai 0, method memanggil pastikanProdukAda() untuk membedakan dua sebab: produk tidak ada (InvalidArgumentException) atau stoknya kurang (StokTidakCukupException — class yang sama dengan Bagian 3, dipakai ulang agar pemanggil cukup mengenal satu jenis galat stok).
  2. tambahStok() adalah nama bisnis untuk release() — restock dan pelepasan sama-sama menambah stok dan mencatat masuk; satu implementasi melayani dua sebutan agar kode pemanggil terbaca sebagai bahasa bisnis, bukan bahasa SQL.
  3. releaseForOrder() membaca order_items, bukan menerima daftar item — sumber kebenaran barang yang direservasi adalah baris item ordernya sendiri, sehingga pelepasan tidak mungkin meleset dari yang pernah diambil.

Class Validator #

Validator memeriksa masukan order sebelum diproses. Berbeda dengan exception Bagian 3 yang gagal-cepat, validator mengumpulkan seluruh galat sekaligus — bentuk yang tepat untuk antarmuka pengguna yang ingin menampilkan semua kesalahan form dalam satu tayangan:

php
<?php

declare(strict_types=1);

/**
 * Validasi masukan order sebelum diproses: customer ada, produk ada,
 * qty positif, dan stok mencukupi.
 *
 * Berbeda dengan exception di OrderService yang gagal-cepat (fail-fast),
 * Validator mengumpulkan SELURUH galat dalam satu daftar string agar cocok
 * ditampilkan sekaligus di antarmuka pengguna.
 */
final class Validator
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Kembalikan daftar galat; daftar kosong berarti order layak diproses.
     *
     * @param list<array{0: int, 1: int}> $items setiap elemen [productId, qty]
     *
     * @return list<string>
     */
    public function validateOrder(int $customerId, array $items): array
    {
        $errors = [];

        if ($items === []) {
            return ['Order harus memiliki minimal satu item.'];
        }

        if (!$this->customerAda($customerId)) {
            $errors[] = "Customer #$customerId tidak ditemukan.";
        }

        $diminta = [];
        foreach ($items as $nomor => $item) {
            if (!is_array($item) || count($item) !== 2) {
                $errors[] = sprintf('Item #%d: format salah, harap [productId, qty].', $nomor + 1);
                continue;
            }
            [$productId, $qty] = $item;
            if (!is_int($productId) || $productId < 1) {
                $errors[] = sprintf('Item #%d: productId tidak valid.', $nomor + 1);
                continue;
            }
            if (!is_int($qty) || $qty < 1) {
                $errors[] = sprintf('Item #%d: qty harus bilangan bulat >= 1 (diterima %s).',
                    $nomor + 1, var_export($qty, true));
                continue;
            }
            if (!$this->produkAda($productId)) {
                $errors[] = sprintf('Item #%d: produk #%d tidak ditemukan.', $nomor + 1, $productId);
                continue;
            }
            $diminta[$productId] = ($diminta[$productId] ?? 0) + $qty;
        }

        $stmt = $this->pdo->prepare('SELECT sku, nama, stok FROM products WHERE id = ?');
        foreach ($diminta as $productId => $qty) {
            $stmt->execute([$productId]);
            $produk = $stmt->fetch(PDO::FETCH_ASSOC);
            if ($produk !== false && (int) $produk['stok'] < $qty) {
                $errors[] = sprintf('Stok %s (%s) tidak cukup: diminta %d, tersedia %d.',
                    $produk['nama'], $produk['sku'], $qty, $produk['stok']);
            }
        }

        return $errors;
    }

    private function customerAda(int $customerId): bool
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM customers WHERE id = ?');
        $stmt->execute([$customerId]);

        return (int) $stmt->fetchColumn() > 0;
    }

    private function produkAda(int $productId): bool
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM products WHERE id = ?');
        $stmt->execute([$productId]);

        return (int) $stmt->fetchColumn() > 0;
    }
}

Perhatikan dua hal pada method validateOrder(). Pertama, pemeriksaan berlapis per item dengan continue — item yang formatnya salah tidak ikut menghitung stok, tetapi item lain tetap diperiksa. Kedua, qty dari sumber tak terpercaya (misalnya hasil json_decode) diverifikasi dengan is_int(); nilai "2" berupa string akan tertangani sebagai galat, bukan dibiarkan lolos ke CHECK constraint dengan pesan yang sulit dipahami pengguna. Validator tidak menggantikan penjaga di service dan schema — ia hanyalah lapisan pertama yang paling ramah; pembahasan mendalam tentang strategi validasi ada pada chapter Sanitasi vs Validasi .

Migrasi Kecil untuk Database Lama #

schema.sql Bagian 1 sudah memuat stock_movements sejak awal sehingga database baru langsung lengkap. Namun database produksi yang dibuat sebelum tabel ini dirancang perlu dimigrasi, dan pola migrasinya layak dilatih: periksa apakah objek sudah ada di sqlite_master, buat bila belum, lalu pastikan migrasi aman dijalankan berulang:

php
<?php
// Simulasi database lama: dibuat sebelum tabel stock_movements ada.
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(<<<'SQL'
CREATE TABLE products (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    sku   TEXT    NOT NULL UNIQUE,
    nama  TEXT    NOT NULL,
    harga INTEGER NOT NULL CHECK (harga > 0),
    stok  INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0)
);
CREATE TABLE orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL,
    status      TEXT    NOT NULL DEFAULT 'pending',
    created_at  TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO products (sku, nama, harga, stok) VALUES
    ('KB-100', 'Keyboard Mekanik RGB', 75000000, 12);
SQL);

function migrasiStockMovements(PDO $pdo): void
{
    $ada = (int) $pdo->query(
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'stock_movements'"
    )->fetchColumn();
    if ($ada === 1) {
        echo "Tabel stock_movements sudah ada, tidak ada yang dilakukan.\n";
        return;
    }

    $pdo->exec(<<<'SQL'
    CREATE TABLE stock_movements (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        product_id INTEGER NOT NULL REFERENCES products(id),
        order_id   INTEGER REFERENCES orders(id),
        jenis      TEXT    NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
        qty        INTEGER NOT NULL CHECK (qty > 0),
        keterangan TEXT    NOT NULL DEFAULT '',
        created_at TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
    );
    CREATE INDEX idx_stock_movements_product ON stock_movements(product_id);
    SQL);
    echo "Tabel stock_movements dibuat oleh migrasi.\n";
}

migrasiStockMovements($pdo);
migrasiStockMovements($pdo);

$pdo->prepare(
    "INSERT INTO stock_movements (product_id, order_id, jenis, qty, keterangan)
     VALUES (1, NULL, 'masuk', 12, 'koreksi stok awal')"
)->execute();
foreach ($pdo->query('SELECT id, jenis, qty, keterangan FROM stock_movements ORDER BY id') as $m) {
    printf("  #%d %s qty %d (%s)\n", $m['id'], $m['jenis'], $m['qty'], $m['keterangan']);
}

Output yang Dihasilkan:

text
Tabel stock_movements dibuat oleh migrasi.
Tabel stock_movements sudah ada, tidak ada yang dilakukan.
  #1 masuk qty 12 (koreksi stok awal)

Pemanggilan kedua membuktikan sifat idempoten: migrasi yang sama boleh dijalankan ulang tanpa galat dan tanpa efek samping. Pola periksa-lalu-buat seperti ini adalah versi paling sederhana dari schema migration; framework modern membungkusnya dengan penomoran versi dan riwayat — materi lengkapnya ada pada chapter Schema Migrations .

Validator Menyaring Sebelum Diproses #

Sekarang giliran validator bekerja. Kita lemparkan masukan yang bermasalah di semua titik sekaligus — customer tak dikenal, produk hilang, qty nol, dan stok kurang — lalu pastikan tidak ada order yang tersimpan:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/InventoryService.php';
require __DIR__ . '/src/Validator.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-model-pelanggan-dan-produk/schema.sql'));

$validator = new Validator($pdo);

$errors = $validator->validateOrder(99, [
    [1, 2],
    [999, 1],
    [5, 10],
    [1, 0],
]);

echo "Validator menemukan ", count($errors), " galat:\n";
foreach ($errors as $i => $pesan) {
    printf("  %d. %s\n", $i + 1, $pesan);
}

$jumlah = (int) $pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
echo "Jumlah order tetap: $jumlah\n";

Output yang Dihasilkan:

text
Validator menemukan 4 galat:
  1. Customer #99 tidak ditemukan.
  2. Item #2: produk #999 tidak ditemukan.
  3. Item #4: qty harus bilangan bulat >= 1 (diterima 0).
  4. Stok USB-C Hub 7-in-1 (HB-500) tidak cukup: diminta 10, tersedia 3.
Jumlah order tetap: 0

Empat galat yang berbeda jenis terkumpul dalam satu daftar bernomor — bentuk yang tinggal dipetakan ke pesan form. Item [1, 2] yang sah tidak menghasilkan galat apa pun, dan karena validator hanya membaca (SELECT), tidak ada yang berubah di database. Alur pemakaian yang utuh: panggil validateOrder() lebih dulu, tampilkan daftarnya bila tidak kosong, dan hanya bila kosong lanjutkan ke placeOrder() yang tetap menjalankan penjagaannya sendiri.

Reservasi, Pembatalan, dan Pelepasan #

Inilah alur yang melunasi utang Bagian 3. Order yang sah ditempatkan lewat placeOrder() (reservasi terjadi di dalamnya), riwayat stok dicetak, lalu order dibatalkan — pelepasan stok dan perubahan status digabungkan dalam SATU transaksi di pemanggil. Terakhir, pembatalan kedua dicoba untuk membuktikan bahwa stok tidak dilepaskan dobel:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/InventoryService.php';
require __DIR__ . '/src/Validator.php';

function tampilkanRiwayat(InventoryService $inventory, int $productId): void
{
    foreach ($inventory->riwayatStok($productId) as $m) {
        printf("  #%-2d %-6s qty %-2d order %-4s %s\n",
            $m['id'], $m['jenis'], $m['qty'],
            $m['order_id'] === null ? '-' : '#' . $m['order_id'],
            $m['keterangan']);
    }
}

$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-model-pelanggan-dan-produk/schema.sql'));

$service = new OrderService($pdo, new OrderRepository($pdo));
$inventory = new InventoryService($pdo);

echo "== Order valid diproses ==\n";
$orderId = $service->placeOrder(1, [[1, 2]]);
printf("Order #%d: stok KB-100 kini %d\n", $orderId, $inventory->stokSaatIni(1));
echo "Riwayat KB-100:\n";
tampilkanRiwayat($inventory, 1);

echo "\n== Pembatalan dalam satu transaksi ==\n";
$pdo->beginTransaction();
try {
    $jumlah = $inventory->releaseForOrder($orderId);
    $service->ubahStatus($orderId, 'batal');
    $pdo->commit();
    echo "$jumlah baris item dilepaskan, status kini: batal\n";
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    echo "Dibatalkan: {$e->getMessage()}\n";
}
printf("Stok KB-100 kembali: %d\n", $inventory->stokSaatIni(1));
echo "Riwayat KB-100:\n";
tampilkanRiwayat($inventory, 1);

echo "\n== Pembatalan kedua ditolak, stok tidak dobel ==\n";
$pdo->beginTransaction();
try {
    $inventory->releaseForOrder($orderId);
    $service->ubahStatus($orderId, 'batal');
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    echo "Ditolak: {$e->getMessage()}\n";
}
printf("Stok KB-100 tetap: %d\n", $inventory->stokSaatIni(1));

Output yang Dihasilkan:

text
== Order valid diproses ==
Order #1: stok KB-100 kini 10
Riwayat KB-100:
  #1  masuk  qty 12 order -    stok awal
  #7  keluar qty 2  order #1   order #1

== Pembatalan dalam satu transaksi ==
1 baris item dilepaskan, status kini: batal
Stok KB-100 kembali: 12
Riwayat KB-100:
  #1  masuk  qty 12 order -    stok awal
  #7  keluar qty 2  order #1   order #1
  #8  masuk  qty 2  order #1   pembatalan order #1

== Pembatalan kedua ditolak, stok tidak dobel ==
Ditolak: Transisi status batal -> batal tidak diizinkan untuk order #1.
Stok KB-100 tetap: 12

Bacalah urutan kejadian pada output tersebut. Setelah placeOrder(), stok turun 12 ke 10 dengan satu baris keluar yang merujuk order #1. Transaksi pembatalan lalu melepaskan stok (baris masuk “pembatalan order #1”) dan mengubah status dalam satu tarikan — bila salah satunya gagal, keduanya batal. Bagian terakhir adalah pelajaran terpentingnya: pembatalan kedua ditolak mesin status (batal -> batal tidak diizinkan), exception menghentikan transaksi sebelum commit(), dan pelepasan stok yang sudah setengah jalan ikut ter-rollBack() — stok tetap 12, tidak dobol. Inilah cara mesin status mencegah operasi ganda tanpa perlu flag tambahan: status itu sendiri adalah penjaga idempotensi.

Restock dan Penjaga Stok Mandiri #

Dua skenario terakhir menutup operasi inventaris di luar order. Pertama, restock mingguan lewat tambahStok(). Kedua, reservasi mandiri untuk order yang dibuat lewat OrderRepository Bagian 2 — jalur yang menegaskan pembagian tugas: OrderRepository menulis order, InventoryService mengelola stok, dan OrderService Bagian 3 menyatukan keduanya:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/InventoryService.php';
require __DIR__ . '/src/Validator.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-model-pelanggan-dan-produk/schema.sql'));

$repo = new OrderRepository($pdo);
$inventory = new InventoryService($pdo);

echo "Stok HB-500 awal: ", $inventory->stokSaatIni(5), "\n";
$inventory->tambahStok(5, 10, 'restock mingguan');
echo "Setelah restock  : ", $inventory->stokSaatIni(5), "\n\n";

$orderId = $repo->buatOrder(1, [[5, 3]]);
echo "Order #$orderId via OrderRepository (Bagian 2): stok HB-500 masih ",
    $inventory->stokSaatIni(5), "\n";

$inventory->reserve(5, 3, $orderId, "order #$orderId");
echo "Setelah reserve 3 unit: stok HB-500 kini ", $inventory->stokSaatIni(5), "\n\n";

try {
    $inventory->reserve(5, 100, $orderId);
} catch (StokTidakCukupException $e) {
    echo "Reservasi 100 unit ditolak: {$e->getMessage()}\n";
}
echo "Stok HB-500 tetap: ", $inventory->stokSaatIni(5), "\n\n";

echo "Riwayat HB-500:\n";
foreach ($inventory->riwayatStok(5) as $m) {
    printf("  #%-2d %-6s qty %-2d %s\n", $m['id'], $m['jenis'], $m['qty'], $m['keterangan']);
}

Output yang Dihasilkan:

text
Stok HB-500 awal: 3
Setelah restock  : 13

Order #1 via OrderRepository (Bagian 2): stok HB-500 masih 13
Setelah reserve 3 unit: stok HB-500 kini 10

Reservasi 100 unit ditolak: Stok produk #5 tidak cukup untuk 100 unit.
Stok HB-500 tetap: 10

Riwayat HB-500:
  #5  masuk  qty 3  stok awal
  #7  masuk  qty 10 restock mingguan
  #8  keluar qty 3  order #1

Riwayat penutup merangkum kisah lengkap USB-C Hub: tiga unit benih, sepuluh unit restock, dan tiga unit keluar untuk order #1. Reservasi 100 unit ditolak penjaga UPDATE bersyarat tanpa mengubah apa pun. Dari tabel gerakan inilah laporan nilai persediaan dan audit perubahan stok kelak disusun di Bagian 6.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan berkas dari tiga bagian sebelumnya. Susun foldernya sebagai berikut:

text
sistem-pemesanan/
├── bagian-1-model-pelanggan-dan-produk/
│   └── schema.sql
├── bagian-2-order-dan-order-items/
│   └── src/
│       └── OrderRepository.php
├── bagian-3-order-processing/
│   └── src/
│       └── OrderService.php
└── bagian-4-transactions-dan-inventory/
    ├── src/
    │   ├── InventoryService.php
    │   └── Validator.php
    └── uji-inventaris.php

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

bash
php uji-inventaris.php

Snippet migrasi tidak membutuhkan berkas class mana pun — ia membangun skema lamanya sendiri di :memory:. Walkthrough lainnya memuat schema Bagian 1 dan class dari Bagian 2-4 lewat path relatif, jadi struktur folder antarbagian harus dipertahankan. Seluruh contoh memakai :memory: dan tidak meninggalkan berkas database.

Apa yang Berubah #

Dibandingkan Bagian 3 yang menempatkan order, proyek kini memiliki pengelola inventaris dan gerbang validasi:

  • Berkas src/InventoryService.php baru dengan enam operasi: reserve(), release(), tambahStok(), releaseForOrder(), stokSaatIni(), dan riwayatStok() — semuanya berjejak audit dan menulis di dalam transaksi pemanggil.
  • Berkas src/Validator.php baru dengan validateOrder() yang mengumpulkan seluruh galat (customer, produk, qty, stok) sebagai daftar string siap tampil.
  • Utang Bagian 3 lunas: pembatalan order kini melepaskan stok dan mengubah status dalam satu transaksi, dan pembatalan ganda tertolak tanpa efek samping — terbukti pada walkthrough.
  • Pola migrasi sederhana untuk database lama terdokumentasi dan teruji idempoten.

Ringkasan #

Inventaris kini berjalan sebagai satu pintu: setiap satuan barang yang masuk maupun keluar rak meninggalkan baris jejak, pelepasan stok membaca baris item order agar tidak mungkin meleset, dan transaksi pembatalan menyatukan gerakan stok dengan mesin status. Validator melengkapi rantai pertahanan sebagai lapisan pertama yang paling ramah pengguna, sebelum penjaga di OrderService dan constraint di schema. Fondasi data dan logika pemesanan kini utuh — yang belum ada adalah uangnya. Bagian 5 membangun PaymentService dengan idempotency agar satu order tidak pernah terbayar dua kali.

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);

/**
 * Operasi inventaris berjejak audit: setiap perubahan stok selalu
 * disertai satu baris pada stock_movements.
 *
 * Class ini tidak membuka transaksinya sendiri; ia menulis di dalam
 * transaksi yang sedang aktif (mis. transaksi placeOrder atau transaksi
 * pembatalan order), sehingga perubahan stok dan order terjadi atomik.
 * Membutuhkan StokTidakCukupException dari bagian sebelumnya.
 */
final class InventoryService
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Kurangi stok untuk sebuah order dan catat gerakan keluar.
     *
     * Pemeriksaan kecukupan dan pengurangan dilakukan satu perintah UPDATE
     * bersyarat (WHERE stok >= ?) sehingga atomik bahkan tanpa transaksi.
     */
    public function reserve(int $productId, int $qty, int $orderId, string $keterangan = ''): void
    {
        if ($qty < 1) {
            throw new InvalidArgumentException('Qty reservasi harus >= 1.');
        }

        $stmt = $this->pdo->prepare(
            'UPDATE products SET stok = stok - ? WHERE id = ? AND stok >= ?'
        );
        $stmt->execute([$qty, $productId, $qty]);
        if ($stmt->rowCount() !== 1) {
            $this->pastikanProdukAda($productId);
            throw new StokTidakCukupException("Stok produk #$productId tidak cukup untuk $qty unit.");
        }

        $this->catat('keluar', $productId, $qty, $orderId, $keterangan);
    }

    /**
     * Kembalikan stok yang sebelumnya direservasi dan catat gerakan masuk.
     */
    public function release(int $productId, int $qty, ?int $orderId = null, string $keterangan = ''): void
    {
        if ($qty < 1) {
            throw new InvalidArgumentException('Qty pelepasan harus >= 1.');
        }

        $stmt = $this->pdo->prepare('UPDATE products SET stok = stok + ? WHERE id = ?');
        $stmt->execute([$qty, $productId]);
        if ($stmt->rowCount() !== 1) {
            $this->pastikanProdukAda($productId);
        }

        $this->catat('masuk', $productId, $qty, $orderId, $keterangan);
    }

    /**
     * Terima barang baru ke rak (restock) dan catat gerakan masuk.
     */
    public function tambahStok(int $productId, int $qty, string $keterangan): void
    {
        $this->release($productId, $qty, null, $keterangan);
    }

    /**
     * Lepaskan seluruh item sebuah order: kembalikan stok tiap baris item.
     *
     * Dipanggil di dalam transaksi pembatalan order. Mengembalikan
     * jumlah baris item yang dilepaskan.
     */
    public function releaseForOrder(int $orderId): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT product_id, qty FROM order_items WHERE order_id = ? ORDER BY id'
        );
        $stmt->execute([$orderId]);
        $items = $stmt->fetchAll(PDO::FETCH_ASSOC);

        foreach ($items as $item) {
            $this->release(
                (int) $item['product_id'],
                (int) $item['qty'],
                $orderId,
                "pembatalan order #$orderId"
            );
        }

        return count($items);
    }

    /**
     * Stok terkini satu produk.
     */
    public function stokSaatIni(int $productId): int
    {
        $stmt = $this->pdo->prepare('SELECT stok FROM products WHERE id = ?');
        $stmt->execute([$productId]);
        $stok = $stmt->fetchColumn();
        if ($stok === false) {
            $this->pastikanProdukAda($productId);
        }

        return (int) $stok;
    }

    /**
     * Riwayat gerakan stok satu produk, urut dari yang paling lama.
     *
     * @return list<array<string, mixed>>
     */
    public function riwayatStok(int $productId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, jenis, qty, order_id, keterangan
               FROM stock_movements
              WHERE product_id = ?
              ORDER BY id'
        );
        $stmt->execute([$productId]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Catat satu baris gerakan stok.
     */
    private function catat(string $jenis, int $productId, int $qty, ?int $orderId, string $keterangan): void
    {
        $this->pdo->prepare(
            'INSERT INTO stock_movements (product_id, order_id, jenis, qty, keterangan)
             VALUES (?, ?, ?, ?, ?)'
        )->execute([$productId, $orderId, $jenis, $qty, $keterangan]);
    }

    /**
     * Lempar exception yang benar bila produk ternyata tidak ada.
     */
    private function pastikanProdukAda(int $productId): void
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM products WHERE id = ?');
        $stmt->execute([$productId]);
        if ((int) $stmt->fetchColumn() === 0) {
            throw new InvalidArgumentException("Produk #$productId tidak ditemukan.");
        }
    }
}

src/Validator.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Validasi masukan order sebelum diproses: customer ada, produk ada,
 * qty positif, dan stok mencukupi.
 *
 * Berbeda dengan exception di OrderService yang gagal-cepat (fail-fast),
 * Validator mengumpulkan SELURUH galat dalam satu daftar string agar cocok
 * ditampilkan sekaligus di antarmuka pengguna.
 */
final class Validator
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Kembalikan daftar galat; daftar kosong berarti order layak diproses.
     *
     * @param list<array{0: int, 1: int}> $items setiap elemen [productId, qty]
     *
     * @return list<string>
     */
    public function validateOrder(int $customerId, array $items): array
    {
        $errors = [];

        if ($items === []) {
            return ['Order harus memiliki minimal satu item.'];
        }

        if (!$this->customerAda($customerId)) {
            $errors[] = "Customer #$customerId tidak ditemukan.";
        }

        $diminta = [];
        foreach ($items as $nomor => $item) {
            if (!is_array($item) || count($item) !== 2) {
                $errors[] = sprintf('Item #%d: format salah, harap [productId, qty].', $nomor + 1);
                continue;
            }
            [$productId, $qty] = $item;
            if (!is_int($productId) || $productId < 1) {
                $errors[] = sprintf('Item #%d: productId tidak valid.', $nomor + 1);
                continue;
            }
            if (!is_int($qty) || $qty < 1) {
                $errors[] = sprintf('Item #%d: qty harus bilangan bulat >= 1 (diterima %s).',
                    $nomor + 1, var_export($qty, true));
                continue;
            }
            if (!$this->produkAda($productId)) {
                $errors[] = sprintf('Item #%d: produk #%d tidak ditemukan.', $nomor + 1, $productId);
                continue;
            }
            $diminta[$productId] = ($diminta[$productId] ?? 0) + $qty;
        }

        $stmt = $this->pdo->prepare('SELECT sku, nama, stok FROM products WHERE id = ?');
        foreach ($diminta as $productId => $qty) {
            $stmt->execute([$productId]);
            $produk = $stmt->fetch(PDO::FETCH_ASSOC);
            if ($produk !== false && (int) $produk['stok'] < $qty) {
                $errors[] = sprintf('Stok %s (%s) tidak cukup: diminta %d, tersedia %d.',
                    $produk['nama'], $produk['sku'], $qty, $produk['stok']);
            }
        }

        return $errors;
    }

    private function customerAda(int $customerId): bool
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM customers WHERE id = ?');
        $stmt->execute([$customerId]);

        return (int) $stmt->fetchColumn() > 0;
    }

    private function produkAda(int $productId): bool
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM products WHERE id = ?');
        $stmt->execute([$productId]);

        return (int) $stmt->fetchColumn() > 0;
    }
}