diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index c2def27..e6f0c2c 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -14,6 +14,7 @@ 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; @@ -21,11 +22,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 CuttingService { + use RunsInTransaction; + public function __construct( private readonly PushNotificationService $pushNotificationService, private readonly MediaService $mediaService, @@ -398,8 +400,8 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v public function create(array $validated, User $user): Cutting { - try { - $cutting = DB::transaction(function () use ($validated, $user): Cutting { + $cutting = $this->runInTransaction( + function () use ($validated, $user): Cutting { $draftMaterials = $this->draftMaterialsQuery($user) ->with('rawMaterialPrice.rawMaterial') @@ -448,18 +450,9 @@ public function create(array $validated, User $user): Cutting $this->deductMaterialStock($cutting); return $cutting; - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat proses cutting: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat proses cutting', + ); $this->pushNotificationService->sendToRoles( '✂️ Proses Cutting Baru', @@ -479,8 +472,8 @@ public function update(Cutting $cutting, array $validated): void ]); } - try { - DB::transaction(function () use ($cutting, $validated): void { + $this->runInTransaction( + function () use ($cutting, $validated): void { $cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results']); if ($cutting->status === CuttingStatus::IN_PROGRESS) { @@ -518,18 +511,9 @@ public function update(Cutting $cutting, array $validated): void $cutting->load('materials.rawMaterialPrice.rawMaterial'); $this->deductMaterialStock($cutting); } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui proses cutting: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui proses cutting', + ); $description = $cutting->description ?? '-'; $this->pushNotificationService->sendToRoles( @@ -550,8 +534,8 @@ public function delete(Cutting $cutting): void $description = $cutting->description ?? '-'; - try { - DB::transaction(function () use ($cutting): void { + $this->runInTransaction( + function () use ($cutting): void { $cutting->load(['materials.rawMaterialPrice.rawMaterial']); if ($cutting->status === CuttingStatus::IN_PROGRESS) { @@ -564,18 +548,9 @@ public function delete(Cutting $cutting): void $cutting->results()->delete(); $cutting->clearMediaCollection('images'); $cutting->delete(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal menghapus proses cutting: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal menghapus proses cutting', + ); $this->pushNotificationService->sendToRoles( '🗑️ Proses Cutting Dihapus', @@ -600,8 +575,8 @@ public function transitionStatus( ]); } - try { - DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void { + $this->runInTransaction( + function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void { $cutting->load(['materials.rawMaterialPrice', 'results']); if ($status === CuttingStatus::COMPLETED) { @@ -642,18 +617,9 @@ public function transitionStatus( $cutting->status = $status; $cutting->save(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal mengubah status proses cutting: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal mengubah status proses cutting', + ); $description = $cutting->description ?? '-'; $message = match ($status) { diff --git a/routes/web.php b/routes/web.php index 4ac0102..ac8315e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -224,22 +224,14 @@ ->middleware('permission:'.Permission::PURCHASES_CREATE->value) ->name('create'); - Route::post('draft-items', [PurchaseDraftItemController::class, 'store']) - ->middleware('permission:'.Permission::PURCHASES_CREATE->value) - ->name('draft_items.store'); - - Route::post('draft-items/new-variant', [PurchaseDraftItemController::class, 'storeNewVariant']) - ->middleware('permission:'.Permission::PURCHASES_CREATE->value) - ->name('draft_items.store_new_variant'); - - Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy']) - ->middleware('permission:'.Permission::PURCHASES_CREATE->value) - ->name('draft_items.destroy'); - Route::post('/', [PurchaseController::class, 'store']) ->middleware('permission:'.Permission::PURCHASES_CREATE->value) ->name('store'); + Route::post('draft-items', [PurchaseDraftItemController::class, 'store']) + ->middleware('permission:'.Permission::PURCHASES_CREATE->value) + ->name('draft_items.store'); + Route::get('{purchase}/edit', [PurchaseController::class, 'edit']) ->middleware([ 'permission:'.Permission::PURCHASES_UPDATE->value, @@ -260,6 +252,14 @@ 'no_pending_owner_verification:purchase', ]) ->name('destroy'); + + Route::post('draft-items/new-variant', [PurchaseDraftItemController::class, 'storeNewVariant']) + ->middleware('permission:'.Permission::PURCHASES_CREATE->value) + ->name('draft_items.store_new_variant'); + + Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy']) + ->middleware('permission:'.Permission::PURCHASES_CREATE->value) + ->name('draft_items.destroy'); }); Route::prefix('orders')->name('orders.') @@ -315,6 +315,29 @@ ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value) ->name('create'); + Route::post('/', [CuttingController::class, 'store']) + ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value) + ->name('store'); + + Route::get('{cutting}/edit', [CuttingController::class, 'edit']) + ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_UPDATE->value) + ->name('edit'); + + Route::put('{cutting}', [CuttingController::class, 'update']) + ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_UPDATE->value) + ->name('update'); + + Route::delete('{cutting}', [CuttingController::class, 'destroy']) + ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value) + ->name('destroy'); + + Route::post('{cutting}/status', [CuttingController::class, 'transitionStatus']) + ->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value.'|'.Permission::CUTTINGS_VERIFY->value.'|'.Permission::CUTTINGS_REJECT->value) + ->name('transition_status'); + + Route::get('{cutting}/share', [CuttingController::class, 'share']) + ->name('share'); + Route::post('draft-materials', [CuttingDraftItemController::class, 'storeMaterial']) ->middleware('permission:'.Permission::CUTTINGS_CREATE->value) ->name('draft_materials.store'); @@ -338,29 +361,6 @@ Route::post('quick-create-product', [CuttingDraftItemController::class, 'quickCreateProduct']) ->middleware('permission:'.Permission::CUTTINGS_CREATE->value) ->name('quick_create_product'); - - Route::post('/', [CuttingController::class, 'store']) - ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value) - ->name('store'); - - Route::get('{cutting}/edit', [CuttingController::class, 'edit']) - ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_UPDATE->value) - ->name('edit'); - - Route::put('{cutting}', [CuttingController::class, 'update']) - ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_UPDATE->value) - ->name('update'); - - Route::post('{cutting}/status', [CuttingController::class, 'transitionStatus']) - ->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value.'|'.Permission::CUTTINGS_VERIFY->value.'|'.Permission::CUTTINGS_REJECT->value) - ->name('transition_status'); - - Route::get('{cutting}/share', [CuttingController::class, 'share']) - ->name('share'); - - Route::delete('{cutting}', [CuttingController::class, 'destroy']) - ->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value) - ->name('destroy'); }); Route::prefix('stocks')->name('stocks.') diff --git a/tests/Feature/Admin/Manage/CuttingTest.php b/tests/Feature/Admin/Manage/CuttingTest.php new file mode 100644 index 0000000..dce8fca --- /dev/null +++ b/tests/Feature/Admin/Manage/CuttingTest.php @@ -0,0 +1,614 @@ +seed(RolePermissionSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createCuttingUserWithPermission(PermissionEnum ...$permissions): User +{ + $user = User::factory()->create(); + + $user->givePermissionTo( + array_merge( + [PermissionEnum::DASHBOARD_VIEW->value], + array_map(fn (PermissionEnum $p) => $p->value, $permissions) + ) + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function createCuttingWithMaterialsAndResults(?User $user = null): Cutting +{ + $user ??= User::factory()->create(); + + $cutting = Cutting::factory()->create(['created_by_id' => $user->id]); + + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + CuttingMaterial::factory()->create([ + 'cutting_id' => $cutting->id, + 'raw_material_price_id' => $price->id, + ]); + + CuttingResult::factory()->create([ + 'cutting_id' => $cutting->id, + 'product_variant_id' => $variant->id, + ]); + + return $cutting; +} + +function setupDraftItems(User $user): array +{ + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + return ['price' => $price, 'variant' => $variant]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Cutting Index', function () { + test('authenticated user with permission can view cutting index', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.cuttings.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.index')) + ->assertForbidden(); + }); + + test('index displays cuttings', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + createCuttingWithMaterialsAndResults($user); + createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.index')) + ->assertOk(); + }); + + test('index can search cuttings by description', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + Cutting::factory()->create([ + 'created_by_id' => $user->id, + 'description' => 'Cutting kain batik', + ]); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.index', ['search' => 'batik'])) + ->assertOk(); + }); +}); + +// ─── Create ─────────────────────────────────────────────── + +describe('Cutting Create', function () { + test('authenticated user with permission can view create form', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.create')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.cuttings.create')) + ->assertRedirect(route('login')); + }); + + test('user without create permission can still view create form', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.create')) + ->assertOk(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Cutting Store', function () { + test('authenticated user with permission can create a cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); + + $draft = setupDraftItems($user); + + // Create draft material + $this->actingAs($user) + ->postJson(route('admin.manage.cuttings.draft_materials.store'), [ + 'raw_material_price_id' => $draft['price']->id, + 'material_usage' => 5, + ]) + ->assertOk(); + + // Create draft result + $this->actingAs($user) + ->postJson(route('admin.manage.cuttings.draft_results.store'), [ + 'product_variant_id' => $draft['variant']->id, + 'cutting_result' => 10, + 'sample' => 8, + 'original_outside_sample' => 2, + ]) + ->assertOk(); + + // Store cutting + $this->actingAs($user) + ->post(route('admin.manage.cuttings.store'), [ + 'description' => 'Cutting test', + 'sewing_cost' => 50000, + 'other_cost' => 10000, + ]) + ->assertRedirect(route('admin.manage.cuttings.index')); + + $cutting = Cutting::where('description', 'Cutting test')->first(); + expect($cutting)->not->toBeNull(); + expect($cutting->status)->toBe(CuttingStatus::IN_PROGRESS); + expect($cutting->materials)->toHaveCount(1); + expect($cutting->results)->toHaveCount(1); + }); + + test('guest cannot create a cutting', function () { + $this->post(route('admin.manage.cuttings.store'), [ + 'description' => 'Test', + ])->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.store'), [ + 'description' => 'Test', + ]) + ->assertForbidden(); + }); + + test('store fails without draft materials', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); + + $draft = setupDraftItems($user); + + // Only create draft result, no material + $this->actingAs($user) + ->postJson(route('admin.manage.cuttings.draft_results.store'), [ + 'product_variant_id' => $draft['variant']->id, + 'cutting_result' => 10, + 'sample' => 8, + 'original_outside_sample' => 2, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.store'), [ + 'description' => 'Test', + ]) + ->assertSessionHasErrors('materials'); + }); + + test('store fails without draft results', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE); + + $draft = setupDraftItems($user); + + // Only create draft material, no result + $this->actingAs($user) + ->postJson(route('admin.manage.cuttings.draft_materials.store'), [ + 'raw_material_price_id' => $draft['price']->id, + 'material_usage' => 5, + ]) + ->assertOk(); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.store'), [ + 'description' => 'Test', + ]) + ->assertSessionHasErrors('results'); + }); +}); + +// ─── Edit ───────────────────────────────────────────────── + +describe('Cutting Edit', function () { + test('authenticated user with permission can view edit form', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.edit', $cutting)) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $cutting = createCuttingWithMaterialsAndResults(); + + $this->get(route('admin.manage.cuttings.edit', $cutting)) + ->assertRedirect(route('login')); + }); + + test('user without update permission can still view edit form', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.edit', $cutting)) + ->assertOk(); + }); + + test('completed cutting cannot be edited', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE); + + $cutting = Cutting::factory()->completed()->create(['created_by_id' => $user->id]); + + $this->actingAs($user) + ->get(route('admin.manage.cuttings.edit', $cutting)) + ->assertRedirect(route('admin.manage.cuttings.index')); + }); +}); + +// ─── Update ─────────────────────────────────────────────── + +describe('Cutting Update', function () { + test('authenticated user with permission can update a cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $rawMaterial = RawMaterial::factory()->create(); + $price = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $this->actingAs($user) + ->put(route('admin.manage.cuttings.update', $cutting), [ + 'description' => 'Updated description', + 'sewing_cost' => 75000, + 'other_cost' => 15000, + 'materials' => [ + ['raw_material_price_id' => $price->id, 'material_usage' => 10], + ], + 'results' => [ + [ + 'product_variant_id' => $variant->id, + 'cutting_result' => 20, + 'sample' => 15, + 'original_outside_sample' => 5, + ], + ], + ]) + ->assertRedirect(route('admin.manage.cuttings.index')); + + expect($cutting->fresh()->description)->toBe('Updated description'); + }); + + test('guest cannot update a cutting', function () { + $cutting = createCuttingWithMaterialsAndResults(); + + $this->put(route('admin.manage.cuttings.update', $cutting), [ + 'description' => 'Test', + 'materials' => [['raw_material_price_id' => 1, 'material_usage' => 1]], + 'results' => [['product_variant_id' => 1, 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]], + ])->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->put(route('admin.manage.cuttings.update', $cutting), [ + 'description' => 'Test', + 'materials' => [['raw_material_price_id' => 1, 'material_usage' => 1]], + 'results' => [['product_variant_id' => 1, 'cutting_result' => 1, 'sample' => 1, 'original_outside_sample' => 0]], + ]) + ->assertForbidden(); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Cutting Destroy', function () { + test('authenticated user with permission can delete a cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_DELETE); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->delete(route('admin.manage.cuttings.destroy', $cutting)) + ->assertRedirect(route('admin.manage.cuttings.index')); + + $this->assertSoftDeleted('cuttings', ['id' => $cutting->id]); + }); + + test('guest cannot delete a cutting', function () { + $cutting = createCuttingWithMaterialsAndResults(); + + $this->delete(route('admin.manage.cuttings.destroy', $cutting)) + ->assertRedirect(route('login')); + + $this->assertNotSoftDeleted('cuttings', ['id' => $cutting->id]); + }); + + test('user without delete permission can still delete cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->delete(route('admin.manage.cuttings.destroy', $cutting)) + ->assertRedirect(route('admin.manage.cuttings.index')); + + $this->assertSoftDeleted('cuttings', ['id' => $cutting->id]); + }); + + test('completed cutting cannot be deleted', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_DELETE); + + $cutting = Cutting::factory()->completed()->create(['created_by_id' => $user->id]); + + $this->actingAs($user) + ->delete(route('admin.manage.cuttings.destroy', $cutting)) + ->assertSessionHasErrors('status'); + + $this->assertNotSoftDeleted('cuttings', ['id' => $cutting->id]); + }); +}); + +// ─── Status Transition ──────────────────────────────────── + +describe('Cutting Status Transition', function () { + test('authenticated user can complete a cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::COMPLETED->value, + ]) + ->assertRedirect(route('admin.manage.cuttings.index')); + + expect($cutting->fresh()->status)->toBe(CuttingStatus::COMPLETED); + }); + + test('guest cannot transition status', function () { + $cutting = createCuttingWithMaterialsAndResults(); + + $this->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::COMPLETED->value, + ])->assertRedirect(route('login')); + }); + + test('user without permission cannot complete cutting', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW); + + $cutting = createCuttingWithMaterialsAndResults($user); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::COMPLETED->value, + ]) + ->assertForbidden(); + }); + + test('invalid status transition is rejected', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE, PermissionEnum::CUTTINGS_VERIFY); + + $cutting = createCuttingWithMaterialsAndResults($user); + + // IN_PROGRESS cannot go directly to VERIFIED + $this->actingAs($user) + ->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::VERIFIED->value, + ]) + ->assertSessionHasErrors('status'); + }); + + test('completed cutting can be verified by owner', function () { + $user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_COMPLETE, PermissionEnum::CUTTINGS_VERIFY); + $user->assignRole('owner'); + $user->forgetCachedPermissions(); + + $cutting = createCuttingWithMaterialsAndResults($user); + + // First complete + $this->actingAs($user) + ->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::COMPLETED->value, + ]); + + expect($cutting->fresh()->status)->toBe(CuttingStatus::COMPLETED); + + // Then verify with result_prices + $result = $cutting->results->first(); + + $this->actingAs($user) + ->post(route('admin.manage.cuttings.transition_status', $cutting), [ + 'status' => CuttingStatus::VERIFIED->value, + 'results' => [ + [ + 'product_variant_id' => $result->product_variant_id, + 'sample' => $result->sample ?? 0, + 'original_outside_sample' => $result->original_outside_sample ?? 0, + ], + ], + 'result_prices' => [ + [ + 'product_variant_id' => $result->product_variant_id, + 'prices' => [ + ['type' => 'retail', 'price' => 150000], + ], + ], + ], + ]); + + expect($cutting->fresh()->status)->toBe(CuttingStatus::VERIFIED); + }); +}); + +// ─── Cutting Model ──────────────────────────────────────── + +describe('Cutting Model', function () { + test('cutting uses soft deletes', function () { + $cutting = Cutting::factory()->create(); + + $cutting->delete(); + + expect($cutting->trashed())->toBeTrue(); + }); + + test('cutting has status cast to enum', function () { + $cutting = Cutting::factory()->create(['status' => CuttingStatus::IN_PROGRESS->value]); + + expect($cutting->status)->toBe(CuttingStatus::IN_PROGRESS); + }); + + test('cutting has status label accessor', function () { + $cutting = Cutting::factory()->create(['status' => CuttingStatus::IN_PROGRESS->value]); + + expect($cutting->status_label)->toBe('Proses'); + }); + + test('cutting can have materials', function () { + $cutting = Cutting::factory()->create(); + CuttingMaterial::factory()->count(3)->create(['cutting_id' => $cutting->id]); + + expect($cutting->fresh()->materials)->toHaveCount(3); + }); + + test('cutting can have results', function () { + $cutting = Cutting::factory()->create(); + CuttingResult::factory()->count(2)->create(['cutting_id' => $cutting->id]); + + expect($cutting->fresh()->results)->toHaveCount(2); + }); + + test('cutting belongs to created by user', function () { + $user = User::factory()->create(); + $cutting = Cutting::factory()->create(['created_by_id' => $user->id]); + + expect($cutting->createdBy)->not->toBeNull(); + expect($cutting->createdBy->id)->toBe($user->id); + }); + + test('cutting status is editable for in_progress and rejected', function () { + $inProgress = Cutting::factory()->create(['status' => CuttingStatus::IN_PROGRESS->value]); + $rejected = Cutting::factory()->create(['status' => CuttingStatus::REJECTED->value]); + + expect($inProgress->status->isEditable())->toBeTrue(); + expect($rejected->status->isEditable())->toBeTrue(); + }); +}); + +// ─── CuttingMaterial Model ───────────────────────────────── + +describe('CuttingMaterial Model', function () { + test('cutting material has material_usage cast to decimal', function () { + $cutting = Cutting::factory()->create(); + $material = CuttingMaterial::factory()->create([ + 'cutting_id' => $cutting->id, + 'material_usage' => 10.5, + ]); + + expect((float) $material->material_usage)->toBe(10.5); + }); + + test('cutting material belongs to cutting', function () { + $cutting = Cutting::factory()->create(); + $material = CuttingMaterial::factory()->create(['cutting_id' => $cutting->id]); + + expect($material->cutting)->not->toBeNull(); + expect($material->cutting->id)->toBe($cutting->id); + }); + + test('cutting material uses soft deletes', function () { + $cutting = Cutting::factory()->create(); + $material = CuttingMaterial::factory()->create(['cutting_id' => $cutting->id]); + + $material->delete(); + + expect($material->trashed())->toBeTrue(); + }); +}); + +// ─── CuttingResult Model ─────────────────────────────────── + +describe('CuttingResult Model', function () { + test('cutting result has integer casts', function () { + $cutting = Cutting::factory()->create(); + $result = CuttingResult::factory()->create([ + 'cutting_id' => $cutting->id, + 'cutting_result' => 50, + 'sample' => 40, + 'original_outside_sample' => 10, + ]); + + expect($result->cutting_result)->toBeInt(); + expect($result->sample)->toBeInt(); + expect($result->original_outside_sample)->toBeInt(); + }); + + test('cutting result belongs to cutting', function () { + $cutting = Cutting::factory()->create(); + $result = CuttingResult::factory()->create(['cutting_id' => $cutting->id]); + + expect($result->cutting)->not->toBeNull(); + expect($result->cutting->id)->toBe($cutting->id); + }); + + test('cutting result uses soft deletes', function () { + $cutting = Cutting::factory()->create(); + $result = CuttingResult::factory()->create(['cutting_id' => $cutting->id]); + + $result->delete(); + + expect($result->trashed())->toBeTrue(); + }); +});