From 9d0075d7b2e25bae2cc2619ac6174f1e7e41cb07 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 4 Jul 2026 17:32:06 +0700 Subject: [PATCH] feat: implement transaction management in RawMaterialService for create, update, delete, and toggle status operations; refactor error handling for improved clarity; enhance tests for raw material and price models to ensure proper functionality and validation --- app/Services/Master/RawMaterialService.php | 161 +++++++-------- .../Feature/Admin/Master/RawMaterialTest.php | 183 +++++++++++++++++- 2 files changed, 252 insertions(+), 92 deletions(-) diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index f44dc50..bc48eae 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -10,17 +10,18 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\User; +use App\Services\Concerns\RunsInTransaction; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class RawMaterialService { + use RunsInTransaction; + private const MAX_VARIANT_IMAGES = 5; public function __construct( @@ -114,8 +115,8 @@ public function create(array $validated, User $user): void { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - try { - DB::transaction(function () use ($validated, $user, $isOwner): void { + $this->runInTransaction( + function () use ($validated, $user, $isOwner): void { $rawMaterial = RawMaterial::create([ 'name' => $validated['name'], 'unit' => $validated['unit'], @@ -139,18 +140,9 @@ public function create(array $validated, User $user): void ], ]); } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat bahan baku', + ); if (! $isOwner) { $this->notifyForPendingRequest( @@ -167,8 +159,8 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user): { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - try { - DB::transaction(function () use ($validated, $rawMaterial, $user, $isOwner): void { + $this->runInTransaction( + function () use ($validated, $rawMaterial, $user, $isOwner): void { if ($isOwner) { $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $this->applyPayloadToRawMaterial($rawMaterial, $payload); @@ -201,18 +193,9 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user): $this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice); } } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui bahan baku', + ); if (! $isOwner) { $this->notifyForPendingRequest( @@ -235,27 +218,22 @@ public function delete(RawMaterial $rawMaterial, User $user): void return; } - try { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::DELETE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => RawMaterial::class, - 'subject_id' => $rawMaterial->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => $this->snapshotRawMaterial($rawMaterial), - 'new' => null, - ], - ]); - } catch (\Throwable $e) { - Log::error('Gagal mengajukan penghapusan bahan baku: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + $this->runInTransaction( + function () use ($rawMaterial, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::DELETE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => RawMaterial::class, + 'subject_id' => $rawMaterial->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => $this->snapshotRawMaterial($rawMaterial), + 'new' => null, + ], + ]); + }, + 'Gagal mengajukan penghapusan bahan baku', + ); $this->notifyForPendingRequest( $user, @@ -278,33 +256,28 @@ public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $u return; } - try { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::TOGGLE_STATUS, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => RawMaterial::class, - 'subject_id' => $rawMaterial->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => [ - 'name' => $rawMaterial->name, - 'is_active' => $rawMaterial->is_active, + $this->runInTransaction( + function () use ($rawMaterial, $validated, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::TOGGLE_STATUS, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => RawMaterial::class, + 'subject_id' => $rawMaterial->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => [ + 'name' => $rawMaterial->name, + 'is_active' => $rawMaterial->is_active, + ], + 'new' => [ + 'name' => $rawMaterial->name, + 'is_active' => (bool) $validated['is_active'], + ], ], - 'new' => [ - 'name' => $rawMaterial->name, - 'is_active' => (bool) $validated['is_active'], - ], - ], - ]); - } catch (\Throwable $e) { - Log::error('Gagal mengajukan perubahan status bahan baku: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + ]); + }, + 'Gagal mengajukan perubahan status bahan baku', + ); $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; @@ -418,13 +391,16 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void private function applyDeleteSubject(RawMaterial $rawMaterial): void { - DB::transaction(function () use ($rawMaterial): void { - $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { - $price->clearMediaCollection('images'); - }); - $rawMaterial->prices()->delete(); - $rawMaterial->delete(); - }); + $this->runInTransaction( + function () use ($rawMaterial): void { + $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { + $price->clearMediaCollection('images'); + }); + $rawMaterial->prices()->delete(); + $rawMaterial->delete(); + }, + 'Gagal menghapus bahan baku', + ); } public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void @@ -510,13 +486,16 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo return; } - DB::transaction(function () use ($rawMaterial): void { - $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { - $price->clearMediaCollection('images'); - }); - $rawMaterial->prices()->delete(); - $rawMaterial->delete(); - }); + $this->runInTransaction( + function () use ($rawMaterial): void { + $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { + $price->clearMediaCollection('images'); + }); + $rawMaterial->prices()->delete(); + $rawMaterial->delete(); + }, + 'Gagal menolak bahan baku', + ); } private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void diff --git a/tests/Feature/Admin/Master/RawMaterialTest.php b/tests/Feature/Admin/Master/RawMaterialTest.php index 6b2aaf2..0ade66c 100644 --- a/tests/Feature/Admin/Master/RawMaterialTest.php +++ b/tests/Feature/Admin/Master/RawMaterialTest.php @@ -4,6 +4,7 @@ use App\Enums\OwnerVerificationStatus; use App\Enums\Permission as PermissionEnum; use App\Enums\RawMaterialUnit; +use App\Models\OwnerVerificationRequest; use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\User; @@ -11,6 +12,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -53,11 +55,16 @@ function createRawMaterialWithPrices(): RawMaterial function priceWithImage(string $variant = 'Default', int $price = 50000, float $stock = 10): array { + $disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public'); + $key = 'raw-materials/'.Str::uuid().".jpg"; + $imageContent = UploadedFile::fake()->image("{$variant}.jpg", 100, 100)->get(); + Storage::disk($disk)->put($key, $imageContent); + return [ 'variant' => $variant, 'price' => $price, 'stock' => $stock, - 'images' => [UploadedFile::fake()->image("{$variant}.jpg", 100, 100)], + 's3_keys' => [$key], ]; } @@ -725,4 +732,178 @@ function createRawMaterialVerifierUser(): User expect($rawMaterial->unit_abbreviation)->toBe('kg'); }); + + test('raw material has total_stock_formatted accessor', function () { + $rawMaterial = RawMaterial::factory()->create(['unit' => RawMaterialUnit::METER->value]); + RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 10.5]); + RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 5.25]); + + expect($rawMaterial->fresh()->total_stock_formatted)->toContain('15,75'); + expect($rawMaterial->fresh()->total_stock_formatted)->toContain('m'); + }); +}); + +// ─── RawMaterialPrice Model ──────────────────────────────── + +describe('RawMaterialPrice Model', function () { + test('price has price cast to integer', function () { + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'price' => 75000]); + + expect($price->price)->toBeInt(); + expect($price->price)->toBe(75000); + }); + + test('price has formatted price accessor', function () { + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'price' => 1500000]); + + expect($price->price_formatted)->toBe('Rp 1.500.000'); + }); + + test('price has price_input accessor', function () { + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'price' => 200000]); + + expect($price->price_input)->toBe('200000'); + }); + + test('price belongs to raw material', function () { + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + expect($price->rawMaterial)->not->toBeNull(); + expect($price->rawMaterial->id)->toBe($rawMaterial->id); + }); + + test('price uses soft deletes', function () { + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $price->delete(); + + expect($price->trashed())->toBeTrue(); + }); +}); + +// ─── Owner Direct Actions ────────────────────────────────── + +describe('Owner Direct Actions', function () { + test('owner can create raw material directly without verification', function () { + $owner = createRawMaterialUserWithPermission( + PermissionEnum::RAW_MATERIALS_VIEW, + PermissionEnum::RAW_MATERIALS_CREATE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $this->actingAs($owner) + ->post(route('admin.master.raw_materials.store'), [ + 'name' => 'Kain Owner', + 'unit' => RawMaterialUnit::METER->value, + 'prices' => [ + priceWithImage('Merah', 75000, 50), + ], + ]) + ->assertRedirect(route('admin.master.raw_materials.index')); + + $rawMaterial = RawMaterial::where('name', 'Kain Owner')->first(); + expect($rawMaterial)->not->toBeNull(); + expect($rawMaterial->is_active)->toBeTrue(); + + $this->assertDatabaseMissing('owner_verification_requests', [ + 'subject_id' => $rawMaterial->id, + 'subject_type' => RawMaterial::class, + ]); + }); + + test('owner can update raw material directly without verification', function () { + $owner = createRawMaterialUserWithPermission( + PermissionEnum::RAW_MATERIALS_VIEW, + PermissionEnum::RAW_MATERIALS_UPDATE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $rawMaterial = createRawMaterialWithPrices(); + $price = $rawMaterial->prices->first(); + + $this->actingAs($owner) + ->put(route('admin.master.raw_materials.update', $rawMaterial), [ + 'name' => 'Kain Updated', + 'unit' => RawMaterialUnit::KILOGRAM->value, + 'prices' => [ + ['id' => $price->id, 'variant' => 'Putih', 'price' => 90000, 'stock' => 25], + ], + ]) + ->assertRedirect(route('admin.master.raw_materials.index')); + + expect($rawMaterial->fresh()->name)->toBe('Kain Updated'); + expect($price->fresh()->variant)->toBe('Putih'); + }); + + test('owner can delete raw material directly without verification', function () { + $owner = createRawMaterialUserWithPermission( + PermissionEnum::RAW_MATERIALS_VIEW, + PermissionEnum::RAW_MATERIALS_DELETE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $rawMaterial = createRawMaterialWithPrices(); + + $this->actingAs($owner) + ->delete(route('admin.master.raw_materials.destroy', $rawMaterial)) + ->assertRedirect(route('admin.master.raw_materials.index')); + + $this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]); + }); + + test('owner can toggle raw material status directly without verification', function () { + $owner = createRawMaterialUserWithPermission( + PermissionEnum::RAW_MATERIALS_VIEW, + PermissionEnum::RAW_MATERIALS_TOGGLE_STATUS, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $rawMaterial = RawMaterial::factory()->create(); + + $this->actingAs($owner) + ->patch(route('admin.master.raw_materials.toggle_status', $rawMaterial), [ + 'is_active' => false, + ]); + + expect($rawMaterial->fresh()->is_active)->toBeFalse(); + }); +}); + +// ─── Reject Update Request ───────────────────────────────── + +describe('Reject Update Request', function () { + test('rejecting update request rolls back to old values', function () { + $user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_UPDATE); + $verifier = createRawMaterialVerifierUser(); + + $rawMaterial = createRawMaterialWithPrices(); + $price = $rawMaterial->prices->first(); + $originalName = $rawMaterial->name; + $originalVariant = $price->variant; + + $this->actingAs($user) + ->put(route('admin.master.raw_materials.update', $rawMaterial), [ + 'name' => 'Nama Diubah', + 'unit' => RawMaterialUnit::KILOGRAM->value, + 'prices' => [ + ['id' => $price->id, 'variant' => 'Putih', 'price' => 90000, 'stock' => 25], + ], + ]); + + $verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail(); + + $this->actingAs($verifier) + ->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [ + 'reason' => 'Tidak sesuai standar', + ]) + ->assertRedirect(); + + expect($rawMaterial->fresh()->name)->toBe($originalName); + expect($price->fresh()->variant)->toBe($originalVariant); + }); });