diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 246bb42..c7699e3 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -12,6 +12,7 @@ use App\Models\RawMaterialPrice; use App\Models\Supplier; use App\Models\User; +use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; @@ -19,11 +20,12 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class PurchaseService { + use RunsInTransaction; + private const MAX_PHOTOS = 1; public function __construct( @@ -252,8 +254,8 @@ public function create(array $validated, User $user): Purchase { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - try { - $purchase = DB::transaction(function () use ($validated, $user, $isOwner): Purchase { + $purchase = $this->runInTransaction( + function () use ($validated, $user, $isOwner): Purchase { $draftItems = $this->draftItemsQuery($user) ->lockForUpdate() @@ -309,18 +311,9 @@ public function create(array $validated, User $user): Purchase } return $purchase; - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat pembelian: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat pembelian', + ); if (! $isOwner) { $this->notifyForPendingRequest( @@ -340,8 +333,8 @@ public function update(Purchase $purchase, array $validated, User $user): void $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); - try { - DB::transaction(function () use ($purchase, $validated, $user, $isOwner): void { + $this->runInTransaction( + function () use ($purchase, $validated, $user, $isOwner): void { if ($isOwner) { $payload = $this->buildPayloadFromValidated($validated); $this->applyPayloadToPurchase($purchase, $payload); @@ -366,18 +359,9 @@ public function update(Purchase $purchase, array $validated, User $user): void $this->syncRequestPhotos($verificationRequest, $validated); } } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui pembelian', + ); if (! $isOwner) { $purchase->load('supplier'); @@ -404,27 +388,22 @@ public function delete(Purchase $purchase, User $user): void $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); - try { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::DELETE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Purchase::class, - 'subject_id' => $purchase->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => $this->snapshotPurchase($purchase), - 'new' => null, - ], - ]); - } catch (\Throwable $e) { - Log::error('Gagal mengajukan penghapusan belanja: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + $this->runInTransaction( + function () use ($purchase, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::DELETE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Purchase::class, + 'subject_id' => $purchase->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => $this->snapshotPurchase($purchase), + 'new' => null, + ], + ]); + }, + 'Gagal mengajukan penghapusan belanja', + ); $purchase->load('supplier'); @@ -615,26 +594,32 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo return; } - DB::transaction(function () use ($purchase): void { - $purchase->clearMediaCollection('photos'); - $purchase->items()->delete(); - $purchase->delete(); - }); + $this->runInTransaction( + function () use ($purchase): void { + $purchase->clearMediaCollection('photos'); + $purchase->items()->delete(); + $purchase->delete(); + }, + 'Gagal menolak belanja', + ); } private function executeDelete(Purchase $purchase): void { - DB::transaction(function () use ($purchase): void { - $purchase->load('items'); + $this->runInTransaction( + function () use ($purchase): void { + $purchase->load('items'); - foreach ($purchase->items as $item) { - $this->decrementStock($item); - } + foreach ($purchase->items as $item) { + $this->decrementStock($item); + } - $purchase->clearMediaCollection('photos'); - $purchase->items()->delete(); - $purchase->delete(); - }); + $purchase->clearMediaCollection('photos'); + $purchase->items()->delete(); + $purchase->delete(); + }, + 'Gagal menghapus belanja', + ); } private function applyPayloadToPurchase( @@ -642,38 +627,41 @@ private function applyPayloadToPurchase( array $payload, ?OwnerVerificationRequest $verificationRequest = null, ): void { - DB::transaction(function () use ($purchase, $payload, $verificationRequest): void { - $purchase->load('items'); + $this->runInTransaction( + function () use ($purchase, $payload, $verificationRequest): void { + $purchase->load('items'); - foreach ($purchase->items as $item) { - $this->decrementStock($item); - } + foreach ($purchase->items as $item) { + $this->decrementStock($item); + } - $purchase->items()->delete(); + $purchase->items()->delete(); - foreach ($payload['items'] ?? [] as $itemData) { - $purchaseItem = $purchase->items()->create([ - 'raw_material_price_id' => $itemData['raw_material_price_id'], - 'quantity' => $itemData['quantity'], - 'unit_price' => $itemData['unit_price'], - 'subtotal' => $itemData['subtotal'], + foreach ($payload['items'] ?? [] as $itemData) { + $purchaseItem = $purchase->items()->create([ + 'raw_material_price_id' => $itemData['raw_material_price_id'], + 'quantity' => $itemData['quantity'], + 'unit_price' => $itemData['unit_price'], + 'subtotal' => $itemData['subtotal'], + ]); + $this->incrementStock($purchaseItem); + } + + $purchase->update([ + 'supplier_id' => $payload['supplier_id'], + 'subtotal' => $payload['subtotal'], + 'discount' => $payload['discount'], + 'shipping_cost' => $payload['shipping_cost'], + 'total' => $payload['total'], + 'notes' => $payload['notes'] ?? null, ]); - $this->incrementStock($purchaseItem); - } - $purchase->update([ - 'supplier_id' => $payload['supplier_id'], - 'subtotal' => $payload['subtotal'], - 'discount' => $payload['discount'], - 'shipping_cost' => $payload['shipping_cost'], - 'total' => $payload['total'], - 'notes' => $payload['notes'] ?? null, - ]); - - if ($verificationRequest !== null) { - $this->applyRequestPhotos($verificationRequest, $purchase, $payload); - } - }); + if ($verificationRequest !== null) { + $this->applyRequestPhotos($verificationRequest, $purchase, $payload); + } + }, + 'Gagal memperbarui belanja', + ); } private function snapshotPurchase(Purchase $purchase): array diff --git a/tests/Feature/Admin/Manage/PurchaseTest.php b/tests/Feature/Admin/Manage/PurchaseTest.php index 62bbd83..eb95ef6 100644 --- a/tests/Feature/Admin/Manage/PurchaseTest.php +++ b/tests/Feature/Admin/Manage/PurchaseTest.php @@ -339,3 +339,418 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica ); }); }); + +// ─── Index ──────────────────────────────────────────────── + +describe('Purchase Index', function () { + test('authenticated user with permission can view purchase index', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.purchases.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.index')) + ->assertForbidden(); + }); + + test('index can search purchases by supplier name', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW); + + $supplier = Supplier::factory()->create(['name' => 'Supplier Kain']); + $purchase = Purchase::factory()->create(['supplier_id' => $supplier->id, 'created_by_id' => $user->id]); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.index', ['search' => 'Kain'])) + ->assertOk(); + }); +}); + +// ─── Create ─────────────────────────────────────────────── + +describe('Purchase Create', function () { + test('authenticated user with permission can view create form', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.create')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.purchases.create')) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.create')) + ->assertForbidden(); + }); +}); + +// ─── Store Validation ───────────────────────────────────── + +describe('Purchase Store Validation', function () { + test('supplier_id is required', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE); + + $price = RawMaterialPrice::factory()->create(); + + $this->actingAs($user) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 2, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => '', + 'discount' => 0, + 'shipping_cost' => 0, + ]) + ->assertSessionHasErrors('supplier_id'); + }); + + test('supplier must exist', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE); + + $price = RawMaterialPrice::factory()->create(); + + $this->actingAs($user) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 2, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => 99999, + 'discount' => 0, + 'shipping_cost' => 0, + ]) + ->assertSessionHasErrors('supplier_id'); + }); + + test('store fails without draft items', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE); + $supplier = Supplier::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + ]) + ->assertSessionHasErrors('items'); + }); +}); + +// ─── Edit ───────────────────────────────────────────────── + +describe('Purchase Edit', function () { + test('authenticated user with permission can view edit form', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE); + $verifier = createPurchaseVerifierUser(); + + $supplier = Supplier::factory()->create(); + $price = RawMaterialPrice::factory()->create(['stock' => 10]); + + $this->actingAs($user) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 2, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + ]); + + approveLatestPurchaseVerificationRequest($verifier); + + $purchase = Purchase::query()->latest()->firstOrFail(); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.edit', $purchase)) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $purchase = Purchase::factory()->create(); + + $this->get(route('admin.manage.purchases.edit', $purchase)) + ->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW); + $purchase = Purchase::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.manage.purchases.edit', $purchase)) + ->assertForbidden(); + }); +}); + +// ─── Update Validation ──────────────────────────────────── + +describe('Purchase Update Validation', function () { + test('items is required on update', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE); + $verifier = createPurchaseVerifierUser(); + + $supplier = Supplier::factory()->create(); + $price = RawMaterialPrice::factory()->create(['stock' => 10]); + + $this->actingAs($user) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 2, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + ]); + + approveLatestPurchaseVerificationRequest($verifier); + + $purchase = Purchase::query()->latest()->firstOrFail(); + + $this->actingAs($user) + ->put(route('admin.manage.purchases.update', $purchase), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + 'items' => [], + ]) + ->assertSessionHasErrors('items'); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Purchase Destroy', function () { + test('guest cannot delete a purchase', function () { + $purchase = Purchase::factory()->create(); + + $this->delete(route('admin.manage.purchases.destroy', $purchase)) + ->assertRedirect(route('login')); + }); + + test('user without delete permission is forbidden', function () { + $user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW); + $purchase = Purchase::factory()->create(); + + $this->actingAs($user) + ->delete(route('admin.manage.purchases.destroy', $purchase)) + ->assertForbidden(); + }); +}); + +// ─── Owner Direct Actions ────────────────────────────────── + +describe('Owner Direct Actions', function () { + test('owner can create purchase directly and increments stock', function () { + $owner = createPurchaseUserWithPermission( + PermissionEnum::PURCHASES_VIEW, + PermissionEnum::PURCHASES_CREATE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $supplier = Supplier::factory()->create(); + $price = RawMaterialPrice::factory()->create(['stock' => 10]); + + $this->actingAs($owner) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 3, + ]) + ->assertOk(); + + $this->actingAs($owner) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + ]) + ->assertRedirect(route('admin.manage.purchases.index')); + + $purchase = Purchase::query()->latest()->first(); + + expect($purchase)->not->toBeNull(); + + $this->assertDatabaseMissing('owner_verification_requests', [ + 'subject_id' => $purchase->id, + 'subject_type' => Purchase::class, + ]); + + expect((float) $price->fresh()->stock)->toBe(13.0); + }); + + test('owner can delete purchase directly and decrements stock', function () { + $owner = createPurchaseUserWithPermission( + PermissionEnum::PURCHASES_VIEW, + PermissionEnum::PURCHASES_CREATE, + PermissionEnum::PURCHASES_DELETE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $supplier = Supplier::factory()->create(); + $price = RawMaterialPrice::factory()->create(['stock' => 10]); + + $this->actingAs($owner) + ->postJson(route('admin.manage.purchases.draft_items.store'), [ + 'raw_material_price_id' => $price->id, + 'quantity' => 3, + ]) + ->assertOk(); + + $this->actingAs($owner) + ->post(route('admin.manage.purchases.store'), [ + 'supplier_id' => $supplier->id, + 'discount' => 0, + 'shipping_cost' => 0, + ]); + + expect((float) $price->fresh()->stock)->toBe(13.0); + + $purchase = Purchase::query()->latest()->firstOrFail(); + + $this->actingAs($owner) + ->delete(route('admin.manage.purchases.destroy', $purchase)) + ->assertRedirect(route('admin.manage.purchases.index')); + + expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse(); + expect((float) $price->fresh()->stock)->toBe(10.0); + }); +}); + +// ─── Purchase Model ──────────────────────────────────────── + +describe('Purchase Model', function () { + test('purchase uses soft deletes', function () { + $purchase = Purchase::factory()->create(); + + $purchase->delete(); + + expect($purchase->trashed())->toBeTrue(); + }); + + test('purchase has amounts cast to integer', function () { + $purchase = Purchase::factory()->create([ + 'subtotal' => 100000, + 'discount' => 5000, + 'shipping_cost' => 10000, + 'total' => 105000, + ]); + + expect($purchase->subtotal)->toBeInt(); + expect($purchase->discount)->toBeInt(); + expect($purchase->shipping_cost)->toBeInt(); + expect($purchase->total)->toBeInt(); + }); + + test('purchase has formatted amount accessors', function () { + $purchase = Purchase::factory()->create([ + 'subtotal' => 100000, + 'discount' => 5000, + 'shipping_cost' => 10000, + 'total' => 105000, + ]); + + expect($purchase->subtotal_formatted)->toBe('Rp 100.000'); + expect($purchase->discount_formatted)->toBe('Rp 5.000'); + expect($purchase->shipping_cost_formatted)->toBe('Rp 10.000'); + expect($purchase->total_formatted)->toBe('Rp 105.000'); + }); + + test('purchase belongs to supplier', function () { + $supplier = Supplier::factory()->create(); + $purchase = Purchase::factory()->create(['supplier_id' => $supplier->id]); + + expect($purchase->supplier)->not->toBeNull(); + expect($purchase->supplier->id)->toBe($supplier->id); + }); + + test('purchase belongs to created by user', function () { + $user = User::factory()->create(); + $purchase = Purchase::factory()->create(['created_by_id' => $user->id]); + + expect($purchase->createdBy)->not->toBeNull(); + expect($purchase->createdBy->id)->toBe($user->id); + }); + + test('purchase can have items', function () { + $purchase = Purchase::factory()->create(); + PurchaseItem::factory()->count(3)->create(['purchase_id' => $purchase->id]); + + expect($purchase->fresh()->items)->toHaveCount(3); + }); +}); + +// ─── PurchaseItem Model ──────────────────────────────────── + +describe('PurchaseItem Model', function () { + test('purchase item has amounts cast to integer', function () { + $purchase = Purchase::factory()->create(); + $item = PurchaseItem::factory()->create([ + 'purchase_id' => $purchase->id, + 'unit_price' => 50000, + 'subtotal' => 100000, + ]); + + expect($item->unit_price)->toBeInt(); + expect($item->subtotal)->toBeInt(); + }); + + test('purchase item has formatted amount accessors', function () { + $purchase = Purchase::factory()->create(); + $item = PurchaseItem::factory()->create([ + 'purchase_id' => $purchase->id, + 'unit_price' => 50000, + 'subtotal' => 100000, + ]); + + expect($item->unit_price_formatted)->toBe('Rp 50.000'); + expect($item->subtotal_formatted)->toBe('Rp 100.000'); + }); + + test('purchase item belongs to purchase', function () { + $purchase = Purchase::factory()->create(); + $item = PurchaseItem::factory()->create(['purchase_id' => $purchase->id]); + + expect($item->purchase)->not->toBeNull(); + expect($item->purchase->id)->toBe($purchase->id); + }); + + test('purchase item uses soft deletes', function () { + $purchase = Purchase::factory()->create(); + $item = PurchaseItem::factory()->create(['purchase_id' => $purchase->id]); + + $item->delete(); + + expect($item->trashed())->toBeTrue(); + }); +});