feat: phase 4 — orders with status workflow, idempotent complete_order RPC, delivery note PDF
This commit is contained in:
@@ -14,6 +14,6 @@ class ExampleTest extends TestCase
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertRedirect('/admin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Orders;
|
||||
|
||||
use App\Filament\Pages\Transactions\Orders as OrdersPage;
|
||||
use App\Models\User;
|
||||
use App\Services\OrderService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Support\InsForgeFake;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OrderTest extends TestCase
|
||||
{
|
||||
private const PRODUCT_ID = '00000000-0000-0000-0000-000000000101';
|
||||
|
||||
private const CUSTOMER_ID = 'cccccccc-0000-0000-0000-000000000001';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
InsForgeFake::reset();
|
||||
InsForgeFake::fakeRest();
|
||||
|
||||
InsForgeFake::seed('products', [
|
||||
'id' => self::PRODUCT_ID,
|
||||
'code' => 'BERAS-001',
|
||||
'name' => 'Beras Premium',
|
||||
'cost_price' => 13000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
InsForgeFake::seed('customers', [
|
||||
'id' => self::CUSTOMER_ID,
|
||||
'customer_code' => 'CUST-0001',
|
||||
'name' => 'Andi',
|
||||
]);
|
||||
}
|
||||
|
||||
private function actor(string $role, string $idSuffix = '0001'): User
|
||||
{
|
||||
$user = new User;
|
||||
|
||||
$user->forceFill([
|
||||
'id' => "eeee5555-0000-0000-0000-{$role}{$idSuffix}",
|
||||
'name' => ucfirst($role),
|
||||
'email' => "{$role}@beraspro.test",
|
||||
'role' => $role,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function seedStock(int $quantity): void
|
||||
{
|
||||
InsForgeFake::seed('stocks', [
|
||||
'id' => 'cccc3333-0000-0000-0000-000000000010',
|
||||
'product_id' => self::PRODUCT_ID,
|
||||
'quantity' => $quantity,
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedOrder(array $overrides = []): array
|
||||
{
|
||||
$order = array_merge([
|
||||
'id' => 'ffff6666-0000-0000-0000-000000000001',
|
||||
'order_number' => 'ORD-20260825-0001',
|
||||
'customer_id' => self::CUSTOMER_ID,
|
||||
'created_by' => $this->actor('operator')->id,
|
||||
'order_status' => OrderService::STATUS_PENDING,
|
||||
'payment_method' => 'cash',
|
||||
'payment_status' => 'unpaid',
|
||||
'deleted_at' => null,
|
||||
'order_date' => '2026-08-25',
|
||||
'customers' => ['code' => 'CUST-0001', 'name' => 'Andi'],
|
||||
'drivers' => null,
|
||||
], $overrides);
|
||||
|
||||
if (! array_key_exists('order_items', $order)) {
|
||||
$order['order_items'] = [
|
||||
[
|
||||
'quantity' => 5,
|
||||
'unit_price' => 15000,
|
||||
'subtotal' => 75000,
|
||||
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
InsForgeFake::seed('orders', $order);
|
||||
|
||||
InsForgeFake::seed('order_items', [
|
||||
'id' => 'aaaa7777-0000-0000-0000-000000000001',
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => self::PRODUCT_ID,
|
||||
'quantity' => 5,
|
||||
'unit_price' => 15000,
|
||||
'subtotal' => 75000,
|
||||
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'],
|
||||
]);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
public function test_create_generates_number_history_and_items(): void
|
||||
{
|
||||
$this->seedStock(100);
|
||||
|
||||
$result = app(OrderService::class)->createOrder([
|
||||
'customer_id' => self::CUSTOMER_ID,
|
||||
'order_date' => '2026-08-25',
|
||||
], [
|
||||
['product_id' => self::PRODUCT_ID, 'quantity' => 3, 'unit_price' => 15000],
|
||||
], $this->actor('operator'));
|
||||
|
||||
$this->assertSame('ORD-'.now()->format('Ymd').'-0001', $result['order_number']);
|
||||
|
||||
$item = end(InsForgeFake::$store['order_items']);
|
||||
$this->assertSame(45000, $item['subtotal']);
|
||||
$this->assertSame(13000, $item['cost_price']);
|
||||
|
||||
$history = end(InsForgeFake::$store['order_status_histories']);
|
||||
$this->assertSame(OrderService::STATUS_PENDING, $history['new_status']);
|
||||
|
||||
$log = end(InsForgeFake::$store['activity_logs']);
|
||||
$this->assertSame('order_created', $log['action']);
|
||||
}
|
||||
|
||||
public function test_create_rejects_when_stock_insufficient(): void
|
||||
{
|
||||
$this->seedStock(2);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
app(OrderService::class)->createOrder([
|
||||
'customer_id' => self::CUSTOMER_ID,
|
||||
], [
|
||||
['product_id' => self::PRODUCT_ID, 'quantity' => 10, 'unit_price' => 15000],
|
||||
], $this->actor('operator'));
|
||||
}
|
||||
|
||||
public function test_valid_transition_pending_to_processing(): void
|
||||
{
|
||||
$this->seedOrder();
|
||||
|
||||
app(OrderService::class)->transition($this->seedOrder()['id'], OrderService::STATUS_PROCESSING, null, $this->actor('admin'));
|
||||
|
||||
$history = end(InsForgeFake::$store['order_status_histories']);
|
||||
$this->assertSame(OrderService::STATUS_PROCESSING, $history['new_status']);
|
||||
}
|
||||
|
||||
public function test_invalid_jump_is_rejected(): void
|
||||
{
|
||||
$order = $this->seedOrder();
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
app(OrderService::class)->transition($order['id'], OrderService::STATUS_DELIVERING, null, $this->actor('admin'));
|
||||
}
|
||||
|
||||
public function test_cancel_by_creator_allowed_but_other_operator_denied(): void
|
||||
{
|
||||
$creator = $this->actor('operator');
|
||||
$other = new User;
|
||||
|
||||
$other->forceFill([
|
||||
'id' => 'eeee5555-9999-9999-9999-operatorX',
|
||||
'name' => 'Other',
|
||||
'email' => 'o@x.test',
|
||||
'role' => 'operator',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$order = $this->seedOrder(['created_by' => $creator->id]);
|
||||
|
||||
app(OrderService::class)->transition($order['id'], OrderService::STATUS_CANCELLED, 'batal', $creator);
|
||||
|
||||
$second = $this->seedOrder([
|
||||
'id' => 'ffff6666-0000-0000-0000-000000000002',
|
||||
'order_number' => 'ORD-20260825-0002',
|
||||
'created_by' => $creator->id,
|
||||
]);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
app(OrderService::class)->transition($second['id'], OrderService::STATUS_CANCELLED, null, $other);
|
||||
}
|
||||
|
||||
public function test_completed_calls_rpc_once_and_stock_moves_once(): void
|
||||
{
|
||||
$this->seedStock(10);
|
||||
|
||||
$order = $this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]);
|
||||
|
||||
app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin'));
|
||||
|
||||
$this->assertSame(1, count(InsForgeFake::$rpcCalls));
|
||||
$this->assertSame('complete_order', InsForgeFake::$rpcCalls[0]['function']);
|
||||
|
||||
// Stok berkurang sekali + satu baris mutasi order_out.
|
||||
$stock = array_values(array_filter(InsForgeFake::$store['stocks'], fn ($s) => $s['product_id'] === self::PRODUCT_ID))[0];
|
||||
$this->assertSame(5, $stock['quantity']);
|
||||
|
||||
$outs = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']);
|
||||
$this->assertCount(1, $outs);
|
||||
|
||||
// Ulangi penyelesaian: transisi lokal ditolak (status sudah completed), rpc TIDAK dipanggil lagi.
|
||||
try {
|
||||
app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin'));
|
||||
$this->fail('repeat completion should be rejected locally');
|
||||
} catch (ValidationException) {
|
||||
}
|
||||
|
||||
$this->assertSame(1, count(InsForgeFake::$rpcCalls));
|
||||
|
||||
$outsAfter = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']);
|
||||
$this->assertCount(1, $outsAfter);
|
||||
}
|
||||
|
||||
public function test_soft_delete_admin_only(): void
|
||||
{
|
||||
$order = $this->seedOrder();
|
||||
|
||||
app(OrderService::class)->softDelete($order['id'], $this->actor('admin'));
|
||||
|
||||
$stored = InsForgeFake::$store['orders'][$order['id']];
|
||||
$this->assertNotNull($stored['deleted_at']);
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
app(OrderService::class)->softDelete($order['id'], $this->actor('operator'));
|
||||
}
|
||||
|
||||
public function test_orders_page_renders_for_operator(): void
|
||||
{
|
||||
$user = $this->actor('operator');
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->seedOrder();
|
||||
|
||||
Livewire::test(OrdersPage::class)
|
||||
->assertSuccessful()
|
||||
->assertSee('ORD-20260825-0001')
|
||||
->assertSee('Andi');
|
||||
}
|
||||
|
||||
public function test_delivery_note_streams_pdf_when_delivering(): void
|
||||
{
|
||||
$this->actingAs($this->actor('admin'));
|
||||
|
||||
$this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]);
|
||||
|
||||
$response = $this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('application/pdf', (string) $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function test_delivery_note_forbidden_while_pending(): void
|
||||
{
|
||||
$this->actingAs($this->actor('admin'));
|
||||
|
||||
$this->seedOrder(['order_status' => OrderService::STATUS_PENDING]);
|
||||
|
||||
$this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note')->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,15 @@ class InsForgeFake
|
||||
/** @var array<string, array<string, array<string, mixed>>> table => id => row */
|
||||
public static array $store = [];
|
||||
|
||||
/** @var list<array{function: string, args: array<string, mixed>}> */
|
||||
public static array $rpcCalls = [];
|
||||
|
||||
public static string $accessToken = '';
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$store = [];
|
||||
self::$rpcCalls = [];
|
||||
}
|
||||
|
||||
public static function seed(string $table, array $row): void
|
||||
@@ -67,6 +71,18 @@ class InsForgeFake
|
||||
{
|
||||
Http::fake([
|
||||
'*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200),
|
||||
'*/api/database/rpc/*' => function (Request $request) {
|
||||
$function = str_replace('/api/database/rpc/', '', (string) parse_url($request->url(), PHP_URL_PATH));
|
||||
$args = $request->data();
|
||||
|
||||
self::$rpcCalls[] = ['function' => $function, 'args' => $args];
|
||||
|
||||
if ($function === 'complete_order') {
|
||||
return self::simulateCompleteOrder($args);
|
||||
}
|
||||
|
||||
return Http::response(['ok' => true], 200);
|
||||
},
|
||||
'*/api/database/records/*' => fn (Request $request) => self::handleRecordsRequest($request),
|
||||
]);
|
||||
}
|
||||
@@ -89,7 +105,12 @@ class InsForgeFake
|
||||
if (str_starts_with((string) $value, 'eq.')) {
|
||||
$column = $key;
|
||||
$expected = substr((string) $value, 3);
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => (string) ($row[$column] ?? '') === $expected));
|
||||
|
||||
if ($expected === 'null') {
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => ($row[$column] ?? null) === null));
|
||||
} else {
|
||||
$rows = array_values(array_filter($rows, fn (array $row): bool => (string) ($row[$column] ?? '') === $expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,4 +171,63 @@ class InsForgeFake
|
||||
|
||||
return Http::response([], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiruan semantik server: idempotent via gerbang unik (reference_type, reference_id),
|
||||
* decrement stok sekali, set status completed + history.
|
||||
*/
|
||||
private static function simulateCompleteOrder(array $args)
|
||||
{
|
||||
$orderId = $args['p_order_id'] ?? null;
|
||||
|
||||
if ($orderId === null || ! isset(self::$store['orders'][$orderId])) {
|
||||
return Http::response(['error' => 'ORDER_NOT_FOUND'], 500);
|
||||
}
|
||||
|
||||
$order = self::$store['orders'][$orderId];
|
||||
|
||||
if (($order['order_status'] ?? '') === 'completed') {
|
||||
return Http::response(['ok' => true], 200);
|
||||
}
|
||||
|
||||
if (($order['order_status'] ?? '') !== 'delivering') {
|
||||
return Http::response(['error' => 'INVALID_TRANSITION'], 500);
|
||||
}
|
||||
|
||||
foreach (self::$store['order_items'] ?? [] as $item) {
|
||||
if (($item['order_id'] ?? null) !== $orderId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyOut = collect(self::$store['stock_movements'] ?? [])->contains(
|
||||
fn (array $m): bool => $m['type'] === 'order_out'
|
||||
&& ($m['reference_id'] ?? null) === $orderId
|
||||
&& ($m['product_id'] ?? null) === $item['product_id'],
|
||||
);
|
||||
|
||||
if ($alreadyOut) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::seed('stock_movements', [
|
||||
'id' => fake()->uuid(),
|
||||
'product_id' => $item['product_id'],
|
||||
'type' => 'order_out',
|
||||
'quantity' => -1 * (int) $item['quantity'],
|
||||
'reference_type' => 'order',
|
||||
'reference_id' => $orderId,
|
||||
]);
|
||||
|
||||
foreach (self::$store['stocks'] as &$stock) {
|
||||
if (($stock['product_id'] ?? null) === $item['product_id']) {
|
||||
$stock['quantity'] -= (int) $item['quantity'];
|
||||
}
|
||||
}
|
||||
unset($stock);
|
||||
}
|
||||
|
||||
self::$store['orders'][$orderId]['order_status'] = 'completed';
|
||||
|
||||
return Http::response(['ok' => true], 200);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user