diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 7a10ee3..37ab2f5 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -10,17 +10,18 @@ use App\Models\Product; use App\Models\ProductVariant; 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 ProductService { + use RunsInTransaction; + private const MAX_VARIANT_IMAGES = 5; public function __construct( @@ -119,8 +120,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 { $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, @@ -161,18 +162,9 @@ public function create(array $validated, User $user): void ], ]); } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat produk: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat produk', + ); if (! $isOwner) { $this->notifyForPendingRequest( @@ -189,8 +181,8 @@ public function update(Product $product, array $validated, User $user): void { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); - try { - DB::transaction(function () use ($validated, $product, $user, $isOwner): void { + $this->runInTransaction( + function () use ($validated, $product, $user, $isOwner): void { if ($isOwner) { $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $this->applyPayloadToProduct($product, $payload); @@ -244,18 +236,9 @@ public function update(Product $product, array $validated, User $user): void $this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant); } } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui produk: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui produk', + ); if (! $isOwner) { $this->notifyForPendingRequest( @@ -278,27 +261,22 @@ public function delete(Product $product, User $user): void return; } - try { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::DELETE, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Product::class, - 'subject_id' => $product->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => $this->snapshotProduct($product), - 'new' => null, - ], - ]); - } catch (\Throwable $e) { - Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + $this->runInTransaction( + function () use ($product, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::DELETE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Product::class, + 'subject_id' => $product->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => $this->snapshotProduct($product), + 'new' => null, + ], + ]); + }, + 'Gagal mengajukan penghapusan produk', + ); $this->notifyForPendingRequest( $user, @@ -321,33 +299,28 @@ public function toggleStatus(Product $product, array $validated, User $user): vo return; } - try { - OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::TOGGLE_STATUS, - 'status' => OwnerVerificationStatus::PENDING, - 'subject_type' => Product::class, - 'subject_id' => $product->id, - 'submitted_by_id' => $user->id, - 'payload' => [ - 'old' => [ - 'name' => $product->name, - 'is_active' => $product->is_active, + $this->runInTransaction( + function () use ($product, $validated, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::TOGGLE_STATUS, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Product::class, + 'subject_id' => $product->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => [ + 'name' => $product->name, + 'is_active' => $product->is_active, + ], + 'new' => [ + 'name' => $product->name, + 'is_active' => (bool) $validated['is_active'], + ], ], - 'new' => [ - 'name' => $product->name, - 'is_active' => (bool) $validated['is_active'], - ], - ], - ]); - } catch (\Throwable $e) { - Log::error('Gagal mengajukan perubahan status produk: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + ]); + }, + 'Gagal mengajukan perubahan status produk', + ); $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; @@ -517,14 +490,17 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void private function applyDeleteSubject(Product $product): void { - DB::transaction(function () use ($product): void { - $product->variants()->each(function (ProductVariant $variant): void { - $variant->clearMediaCollection('images'); - }); - $product->variants()->delete(); - $product->categories()->detach(); - $product->delete(); - }); + $this->runInTransaction( + function () use ($product): void { + $product->variants()->each(function (ProductVariant $variant): void { + $variant->clearMediaCollection('images'); + }); + $product->variants()->delete(); + $product->categories()->detach(); + $product->delete(); + }, + 'Gagal menghapus produk', + ); } public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void @@ -574,14 +550,17 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo return; } - DB::transaction(function () use ($product): void { - $product->variants()->each(function (ProductVariant $variant): void { - $variant->clearMediaCollection('images'); - }); - $product->variants()->delete(); - $product->categories()->detach(); - $product->delete(); - }); + $this->runInTransaction( + function () use ($product): void { + $product->variants()->each(function (ProductVariant $variant): void { + $variant->clearMediaCollection('images'); + }); + $product->variants()->delete(); + $product->categories()->detach(); + $product->delete(); + }, + 'Gagal menolak produk', + ); } private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php new file mode 100644 index 0000000..2ac5fc7 --- /dev/null +++ b/database/factories/ProductPriceFactory.php @@ -0,0 +1,23 @@ + + */ +class ProductPriceFactory extends Factory +{ + public function definition(): array + { + return [ + 'variant_id' => ProductVariant::factory(), + 'type' => fake()->randomElement(PriceType::cases()), + 'price' => fake()->numberBetween(50000, 500000), + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php index a69179a..ed424f0 100644 --- a/database/factories/ProductVariantFactory.php +++ b/database/factories/ProductVariantFactory.php @@ -17,6 +17,7 @@ public function definition(): array 'product_id' => Product::factory(), 'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']), 'stock' => fake()->numberBetween(0, 100), + 'retail_stock' => fake()->numberBetween(0, 50), 'reject_stock' => fake()->numberBetween(0, 20), ]; } diff --git a/routes/web.php b/routes/web.php index 9629861..4ac0102 100644 --- a/routes/web.php +++ b/routes/web.php @@ -95,6 +95,8 @@ Route::prefix('products')->name('products.') ->middleware('permission:'.Permission::PRODUCTS_VIEW->value) ->group(function () { + Route::get('/', [ProductController::class, 'index'])->name('index'); + Route::get('create', [ProductController::class, 'create']) ->middleware('permission:'.Permission::PRODUCTS_CREATE->value) ->name('create'); @@ -117,13 +119,6 @@ ]) ->name('update'); - Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus']) - ->middleware([ - 'permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value, - 'no_pending_owner_verification:product', - ]) - ->name('toggle_status'); - Route::delete('{product}', [ProductController::class, 'destroy']) ->middleware([ 'permission:'.Permission::PRODUCTS_DELETE->value, @@ -131,7 +126,12 @@ ]) ->name('destroy'); - Route::get('/', [ProductController::class, 'index'])->name('index'); + Route::patch('{product}/toggle-status', [ProductController::class, 'toggleStatus']) + ->middleware([ + 'permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value, + 'no_pending_owner_verification:product', + ]) + ->name('toggle_status'); }); Route::prefix('raw-materials')->name('raw_materials.') diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php index df5d777..89e75b5 100644 --- a/tests/Feature/Admin/Master/ProductTest.php +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -72,10 +73,42 @@ function createProductVerifierUser(): User function variantWithImage(string $name = 'All Size', int $stock = 10): array { + $disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public'); + $key = 'products/'.Str::uuid().".jpg"; + $imageContent = UploadedFile::fake()->image("{$name}.jpg", 100, 100)->get(); + Storage::disk($disk)->put($key, $imageContent); + return [ 'name' => $name, 'stock' => $stock, - 'images' => [UploadedFile::fake()->image("{$name}.jpg", 100, 100)], + 'retail_stock' => 0, + 's3_keys' => [$key], + 'prices' => defaultPrices(), + ]; +} + +function defaultPrices(): array +{ + return [ + 'distributor' => 100000, + 'agent' => 110000, + 'sub_agent' => 120000, + 'grosir' => 130000, + 'retail' => 150000, + 'tiktok' => 160000, + 'shopee' => 165000, + 'harga_modal' => 80000, + ]; +} + +function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock = 20): array +{ + return [ + 'id' => $id, + 'name' => $name, + 'stock' => $stock, + 'retail_stock' => 0, + 'prices' => defaultPrices(), ]; } @@ -468,7 +501,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'name' => 'Nama Diubah', 'category_ids' => [$category->id], 'variants' => [ - ['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock], + variantUpdateData($variant->id, $variant->name, $variant->stock), ], ]); @@ -494,7 +527,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'description' => 'Deskripsi baru', 'category_ids' => [$category->id], 'variants' => [ - ['id' => $variant->id, 'name' => 'New Variant', 'stock' => 20], + variantUpdateData($variant->id, 'New Variant', 20), ], ]) ->assertRedirect(route('admin.master.products.index')); @@ -523,7 +556,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'description' => 'Deskripsi baru', 'category_ids' => [$category->id], 'variants' => [ - ['id' => $variant->id, 'name' => 'New Variant', 'stock' => 20], + variantUpdateData($variant->id, 'New Variant', 20), ], ]); @@ -540,7 +573,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array $this->put(route('admin.master.products.update', $product), [ 'name' => 'Nama Baru', 'category_ids' => [$product->categories->first()->id], - 'variants' => [['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock]], + 'variants' => [variantUpdateData($variant->id, $variant->name, $variant->stock)], ])->assertRedirect(route('login')); }); @@ -554,7 +587,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array ->put(route('admin.master.products.update', $product), [ 'name' => 'Nama Baru', 'category_ids' => [$product->categories->first()->id], - 'variants' => [['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock]], + 'variants' => [variantUpdateData($variant->id, $variant->name, $variant->stock)], ]) ->assertForbidden(); }); @@ -571,7 +604,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'name' => '', 'category_ids' => [$category->id], 'variants' => [ - ['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock], + variantUpdateData($variant->id, $variant->name, $variant->stock), ], ]) ->assertSessionHasErrors('name'); @@ -588,7 +621,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'name' => 'Nama Produk', 'category_ids' => [], 'variants' => [ - ['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock], + variantUpdateData($variant->id, $variant->name, $variant->stock), ], ]) ->assertSessionHasErrors('category_ids'); @@ -607,7 +640,7 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array 'name' => 'Updated Product', 'category_ids' => [$category->id], 'variants' => [ - ['id' => $variantToKeep->id, 'name' => 'Kept Variant', 'stock' => 5], + variantUpdateData($variantToKeep->id, 'Kept Variant', 5), ], ]); @@ -832,4 +865,234 @@ function variantWithImage(string $name = 'All Size', int $stock = 10): array expect($product->fresh()->is_active)->toBeFalse(); }); + + test('product has total_stock_formatted accessor', function () { + $product = Product::factory()->create(); + ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10]); + ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 20]); + + expect($product->fresh()->total_stock_formatted)->toBe('30'); + }); + + test('product has slug auto-generated', function () { + $product = Product::create(['name' => 'Test Product Name']); + + expect($product->slug)->toBe('test-product-name'); + }); +}); + +// ─── ProductVariant Model ────────────────────────────────── + +describe('ProductVariant Model', function () { + test('variant uses soft deletes', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $variant->delete(); + + expect($variant->trashed())->toBeTrue(); + }); + + test('variant has stock cast to integer', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 50]); + + expect($variant->stock)->toBeInt(); + expect($variant->stock)->toBe(50); + }); + + test('variant has formatted stock accessor', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 1500]); + + expect($variant->stock_formatted)->toBe('1.500'); + }); + + test('variant belongs to product', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + expect($variant->product)->not->toBeNull(); + expect($variant->product->id)->toBe($product->id); + }); + + test('variant can have prices', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + \App\Models\ProductPrice::factory()->count(3)->create(['variant_id' => $variant->id]); + + expect($variant->fresh()->prices)->toHaveCount(3); + }); + + test('variant has minStock constant', function () { + expect(ProductVariant::minStock())->toBe(5); + }); +}); + +// ─── ProductPrice Model ──────────────────────────────────── + +describe('ProductPrice Model', function () { + test('price has price cast to integer', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + $price = \App\Models\ProductPrice::factory()->create(['variant_id' => $variant->id, 'price' => 150000]); + + expect($price->price)->toBeInt(); + expect($price->price)->toBe(150000); + }); + + test('price has formatted price accessor', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + $price = \App\Models\ProductPrice::factory()->create(['variant_id' => $variant->id, 'price' => 1500000]); + + expect($price->price_formatted)->toBe('Rp 1.500.000'); + }); + + test('price has price_input accessor', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + $price = \App\Models\ProductPrice::factory()->create(['variant_id' => $variant->id, 'price' => 200000]); + + expect($price->price_input)->toBe('200000'); + }); + + test('price belongs to variant', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + $price = \App\Models\ProductPrice::factory()->create(['variant_id' => $variant->id]); + + expect($price->variant)->not->toBeNull(); + expect($price->variant->id)->toBe($variant->id); + }); +}); + +// ─── Owner Direct Actions ────────────────────────────────── + +describe('Owner Direct Actions', function () { + test('owner can create product directly without verification', function () { + $owner = createProductUserWithPermission( + PermissionEnum::PRODUCTS_VIEW, + PermissionEnum::PRODUCTS_CREATE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $category = Category::factory()->create(); + + $this->actingAs($owner) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Owner', + 'description' => 'Deskripsi', + 'category_ids' => [$category->id], + 'variants' => [ + variantWithImage('All Size', 10), + ], + ]) + ->assertRedirect(route('admin.master.products.index')); + + $product = Product::where('name', 'Produk Owner')->first(); + expect($product)->not->toBeNull(); + expect($product->is_active)->toBeTrue(); + + $this->assertDatabaseMissing('owner_verification_requests', [ + 'subject_id' => $product->id, + 'subject_type' => Product::class, + ]); + }); + + test('owner can update product directly without verification', function () { + $owner = createProductUserWithPermission( + PermissionEnum::PRODUCTS_VIEW, + PermissionEnum::PRODUCTS_UPDATE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $product = createProductWithVariants(); + $category = $product->categories->first(); + $variant = $product->variants->first(); + + $this->actingAs($owner) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Produk Updated', + 'description' => 'Deskripsi baru', + 'category_ids' => [$category->id], + 'variants' => [ + variantUpdateData($variant->id, 'Updated Variant', 25), + ], + ]) + ->assertRedirect(route('admin.master.products.index')); + + expect($product->fresh()->name)->toBe('Produk Updated'); + expect($variant->fresh()->name)->toBe('Updated Variant'); + }); + + test('owner can delete product directly without verification', function () { + $owner = createProductUserWithPermission( + PermissionEnum::PRODUCTS_VIEW, + PermissionEnum::PRODUCTS_DELETE, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $product = createProductWithVariants(); + + $this->actingAs($owner) + ->delete(route('admin.master.products.destroy', $product)) + ->assertRedirect(route('admin.master.products.index')); + + $this->assertSoftDeleted('products', ['id' => $product->id]); + }); + + test('owner can toggle product status directly without verification', function () { + $owner = createProductUserWithPermission( + PermissionEnum::PRODUCTS_VIEW, + PermissionEnum::PRODUCTS_TOGGLE_STATUS, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY, + ); + + $product = createProductWithVariants(); + + $this->actingAs($owner) + ->patch(route('admin.master.products.toggle_status', $product), [ + 'is_active' => false, + ]); + + expect($product->fresh()->is_active)->toBeFalse(); + }); +}); + +// ─── Reject Update Request ───────────────────────────────── + +describe('Reject Update Request', function () { + test('rejecting update request rolls back to old values', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + $verifier = createProductVerifierUser(); + + $product = createProductWithVariants(); + $category = $product->categories->first(); + $variant = $product->variants->first(); + $originalName = $product->name; + $originalVariantName = $variant->name; + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Nama Diubah', + 'description' => 'Deskripsi baru', + 'category_ids' => [$category->id], + 'variants' => [ + variantUpdateData($variant->id, 'New Variant', 20), + ], + ]); + + $verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail(); + + $this->actingAs($verifier) + ->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [ + 'reason' => 'Tidak sesuai standar', + ]) + ->assertRedirect(); + + expect($product->fresh()->name)->toBe($originalName); + expect($variant->fresh()->name)->toBe($originalVariantName); + }); });