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

This commit is contained in:
Yoga Pangestu 2026-07-04 17:32:06 +07:00
parent 80e1542151
commit 9d0075d7b2
2 changed files with 252 additions and 92 deletions

View File

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

View File

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