feat: implement transaction management in ProductService for create, update, delete, and toggle status operations; refactor error handling to improve clarity and maintainability; update routes to ensure proper permission checks and enhance product management functionality

This commit is contained in:
Yoga Pangestu 2026-07-04 17:14:01 +07:00
parent 8404b8af41
commit 80e1542151
5 changed files with 376 additions and 110 deletions

View File

@ -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

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Enums\PriceType;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProductPrice>
*/
class ProductPriceFactory extends Factory
{
public function definition(): array
{
return [
'variant_id' => ProductVariant::factory(),
'type' => fake()->randomElement(PriceType::cases()),
'price' => fake()->numberBetween(50000, 500000),
];
}
}

View File

@ -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),
];
}

View File

@ -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.')

View File

@ -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);
});
});