269 lines
9.1 KiB
PHP
269 lines
9.1 KiB
PHP
<?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();
|
|
}
|
|
}
|