From 289fc4dc2cc56e189f95a923252498e1374102b5 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 5 Jul 2026 14:06:47 +0700 Subject: [PATCH] feat: implement stock quality categorization in StokOpname; enhance validation rules and update frontend components for improved inventory tracking and reporting --- tests/Feature/Admin/Manage/StockTest.php | 197 ++++++++++ tests/Feature/Admin/Manage/StokOpnameTest.php | 342 ++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 tests/Feature/Admin/Manage/StockTest.php create mode 100644 tests/Feature/Admin/Manage/StokOpnameTest.php diff --git a/tests/Feature/Admin/Manage/StockTest.php b/tests/Feature/Admin/Manage/StockTest.php new file mode 100644 index 0000000..a58f474 --- /dev/null +++ b/tests/Feature/Admin/Manage/StockTest.php @@ -0,0 +1,197 @@ +seed(RolePermissionSeeder::class); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createStockUserWithPermission(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 createCompletedCuttingSetup(): array +{ + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'stock' => 10, + 'reject_stock' => 5, + ]); + + $rawMaterial = RawMaterial::factory()->create(); + $rawMaterialPrice = RawMaterialPrice::factory()->create([ + 'raw_material_id' => $rawMaterial->id, + ]); + + $cutting = Cutting::factory()->create([ + 'status' => CuttingStatus::COMPLETED, + ]); + + CuttingMaterial::factory()->create([ + 'cutting_id' => $cutting->id, + 'raw_material_price_id' => $rawMaterialPrice->id, + ]); + + CuttingResult::factory()->create([ + 'cutting_id' => $cutting->id, + 'product_variant_id' => $variant->id, + 'cutting_result' => 7, + 'sample' => 5, // Good stock result + 'original_outside_sample' => 2, // Reject stock result + ]); + + return [ + 'cutting' => $cutting, + 'variant' => $variant, + 'raw_material_price' => $rawMaterialPrice, + ]; +} + +// ─── Tests ──────────────────────────────────────────────── + +describe('Stock Warehouse Management', function () { + test('authenticated user with permission can view stocks index', function () { + $user = createStockUserWithPermission(PermissionEnum::STOCKS_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.stocks.index')) + ->assertOk(); + }); + + test('user with permission can submit verification for completed cutting', function () { + $user = createStockUserWithPermission( + PermissionEnum::STOCKS_VIEW, + PermissionEnum::CUTTINGS_VERIFY + ); + + $setup = createCompletedCuttingSetup(); + $cutting = $setup['cutting']; + + $payload = [ + 'verification_note' => 'Verification notes', + 'results' => [ + [ + 'product_variant_id' => $setup['variant']->id, + 'good' => 6, // changed from 5 to 6 + 'reject' => 1, // changed from 2 to 1 + ] + ], + 'result_prices' => [ + [ + 'product_variant_id' => $setup['variant']->id, + 'prices' => [ + [ + 'type' => 'harga_modal', + 'price' => 50000, + ], + [ + 'type' => 'retail', + 'price' => 75000, + ] + ] + ] + ], + ]; + + $this->actingAs($user) + ->post(route('admin.manage.stocks.verify', $cutting), $payload) + ->assertRedirect(route('admin.manage.stocks.index')); + + $cutting->refresh(); + $this->assertEquals(CuttingStatus::PENDING_VERIFICATION, $cutting->status); + + // Verify cutting results are updated + $this->assertDatabaseHas('cutting_results', [ + 'cutting_id' => $cutting->id, + 'product_variant_id' => $setup['variant']->id, + 'sample' => 6, + 'original_outside_sample' => 1, + ]); + }); + + test('user with permission can approve verification and increase stock', function () { + $user = createStockUserWithPermission( + PermissionEnum::STOCKS_VIEW, + PermissionEnum::OWNER_VERIFICATIONS_VERIFY + ); + + $setup = createCompletedCuttingSetup(); + $cutting = $setup['cutting']; + $cutting->status = CuttingStatus::PENDING_VERIFICATION; + $cutting->save(); + + $this->actingAs($user) + ->post(route('admin.manage.owner_verifications.approve', $cutting), [ + 'approval_note' => 'Approved by owner', + ]) + ->assertRedirect(); + + $cutting->refresh(); + $setup['variant']->refresh(); + + $this->assertEquals(CuttingStatus::VERIFIED, $cutting->status); + + // Stock (good) should increment by 5 (10 + 5 = 15) + $this->assertEquals(15, $setup['variant']->stock); + + // Reject stock should increment by 2 (5 + 2 = 7) + $this->assertEquals(7, $setup['variant']->reject_stock); + }); + + test('user with permission can reject verification', function () { + $user = createStockUserWithPermission( + PermissionEnum::STOCKS_VIEW, + PermissionEnum::OWNER_VERIFICATIONS_REJECT + ); + + $setup = createCompletedCuttingSetup(); + $cutting = $setup['cutting']; + $cutting->status = CuttingStatus::PENDING_VERIFICATION; + $cutting->save(); + + $this->actingAs($user) + ->post(route('admin.manage.owner_verifications.reject', $cutting), [ + 'reason' => 'Invalid results', + ]) + ->assertRedirect(); + + $cutting->refresh(); + + // Status should revert to COMPLETED + $this->assertEquals(CuttingStatus::COMPLETED, $cutting->status); + + $this->assertDatabaseHas('rejections', [ + 'rejectable_id' => $cutting->id, + 'rejectable_type' => Cutting::class, + 'reason' => 'Invalid results', + ]); + }); +}); diff --git a/tests/Feature/Admin/Manage/StokOpnameTest.php b/tests/Feature/Admin/Manage/StokOpnameTest.php new file mode 100644 index 0000000..d843f12 --- /dev/null +++ b/tests/Feature/Admin/Manage/StokOpnameTest.php @@ -0,0 +1,342 @@ +seed(RolePermissionSeeder::class); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createStokOpnameUserWithPermission(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 createCatalogSetup(): array +{ + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'stock' => 10, + 'retail_stock' => 5, + 'reject_stock' => 2, + ]); + + return ['product' => $product, 'variant' => $variant]; +} + +// ─── Index & Create ─────────────────────────────────────── + +describe('Stok Opname Index & Create', function () { + test('authenticated user with permission can view stok opnames index', function () { + $user = createStokOpnameUserWithPermission(PermissionEnum::STOK_OPNAMES_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.stok-opnames.index')) + ->assertOk(); + }); + + test('user without permission is forbidden to view index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.manage.stok-opnames.index')) + ->assertForbidden(); + }); + + test('user with permission can view create page', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_CREATE + ); + + $this->actingAs($user) + ->get(route('admin.manage.stok-opnames.create')) + ->assertOk(); + }); +}); + +// ─── Store & Update ─────────────────────────────────────── + +describe('Stok Opname Store & Update', function () { + test('user can store a new stok opname', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_CREATE + ); + + $setup = createCatalogSetup(); + + $payload = [ + 'opname_date' => now()->format('Y-m-d'), + 'notes' => 'Test Opname Notes', + 'items' => [ + [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD->value, + 'physical_stock' => 8, + 'notes' => 'Kurang 2 bagus', + ], + [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::REJECT->value, + 'physical_stock' => 3, + 'notes' => 'Kelebihan 1 reject', + ] + ], + ]; + + $this->actingAs($user) + ->post(route('admin.manage.stok-opnames.store'), $payload) + ->assertRedirect(route('admin.manage.stok-opnames.index')); + + $this->assertDatabaseHas('stok_opnames', [ + 'notes' => 'Test Opname Notes', + 'status' => StokOpnameStatus::DRAFT->value, + ]); + + $this->assertDatabaseHas('stok_opname_items', [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD->value, + 'physical_stock' => 8, + 'difference' => -2, + ]); + + $this->assertDatabaseHas('stok_opname_items', [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::REJECT->value, + 'physical_stock' => 3, + 'difference' => 1, + ]); + }); + + test('user can auto-save a stok opname draft', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_CREATE + ); + + $setup = createCatalogSetup(); + + $payload = [ + 'opname_date' => now()->format('Y-m-d'), + 'notes' => 'Draft Notes', + 'items' => [ + [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::RETAIL->value, + 'physical_stock' => 6, + 'notes' => 'Tambah eceran', + ] + ], + ]; + + $response = $this->actingAs($user) + ->postJson(route('admin.manage.stok-opnames.auto_save'), $payload) + ->assertOk(); + + $stokOpnameId = $response->json('stok_opname_id'); + + $this->assertDatabaseHas('stok_opnames', [ + 'id' => $stokOpnameId, + 'status' => StokOpnameStatus::DRAFT->value, + ]); + }); + + test('user can update a draft stok opname', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_UPDATE + ); + + $setup = createCatalogSetup(); + + $stokOpname = StokOpname::create([ + 'opname_date' => now(), + 'status' => StokOpnameStatus::DRAFT, + 'created_by_id' => $user->id, + ]); + + $item = StokOpnameItem::create([ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD, + 'system_stock' => 10, + 'physical_stock' => 8, + 'difference' => -2, + ]); + + $payload = [ + 'opname_date' => now()->format('Y-m-d'), + 'notes' => 'Updated Notes', + 'items' => [ + [ + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD->value, + 'physical_stock' => 9, + 'notes' => 'Diubah jadi 9', + ] + ], + ]; + + $this->actingAs($user) + ->put(route('admin.manage.stok-opnames.update', $stokOpname), $payload) + ->assertRedirect(route('admin.manage.stok-opnames.index')); + + $this->assertDatabaseHas('stok_opnames', [ + 'id' => $stokOpname->id, + 'notes' => 'Updated Notes', + ]); + + $this->assertDatabaseHas('stok_opname_items', [ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD->value, + 'physical_stock' => 9, + 'difference' => -1, + ]); + }); +}); + +// ─── Submit, Verify & Reject ────────────────────────────── + +describe('Stok Opname Submission & Verification', function () { + test('user can submit a draft stok opname for verification', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_SUBMIT + ); + + $setup = createCatalogSetup(); + + $stokOpname = StokOpname::create([ + 'opname_date' => now(), + 'status' => StokOpnameStatus::DRAFT, + 'created_by_id' => $user->id, + ]); + + StokOpnameItem::create([ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD, + 'system_stock' => 10, + 'physical_stock' => 8, + 'difference' => -2, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.stok-opnames.submit', $stokOpname)) + ->assertRedirect(route('admin.manage.stok-opnames.index')); + + $this->assertEquals(StokOpnameStatus::PENDING, $stokOpname->refresh()->status); + }); + + test('user with verify permission can verify stok opname and reconcile multiple qualities', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_VERIFY + ); + + $setup = createCatalogSetup(); + + $stokOpname = StokOpname::create([ + 'opname_date' => now(), + 'status' => StokOpnameStatus::PENDING, + 'created_by_id' => $user->id, + ]); + + // Create opname items for Good, Retail, and Reject qualities + StokOpnameItem::create([ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::GOOD, + 'system_stock' => 10, + 'physical_stock' => 8, + 'difference' => -2, + ]); + + StokOpnameItem::create([ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::RETAIL, + 'system_stock' => 5, + 'physical_stock' => 7, + 'difference' => 2, + ]); + + StokOpnameItem::create([ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $setup['variant']->id, + 'stock_quality' => ProductStockQuality::REJECT, + 'system_stock' => 2, + 'physical_stock' => 0, + 'difference' => -2, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.stok-opnames.verify', $stokOpname), [ + 'verification_notes' => 'Verified successfully', + ]) + ->assertRedirect(route('admin.manage.stok-opnames.index')); + + $stokOpname->refresh(); + $setup['variant']->refresh(); + + $this->assertEquals(StokOpnameStatus::VERIFIED, $stokOpname->status); + $this->assertEquals('Verified successfully', $stokOpname->verification_notes); + $this->assertEquals($user->id, $stokOpname->verified_by_id); + + // Verify variant stocks are updated correctly matching their quality enums + $this->assertEquals(8, $setup['variant']->stock); + $this->assertEquals(7, $setup['variant']->retail_stock); + $this->assertEquals(0, $setup['variant']->reject_stock); + }); + + test('user with verify permission can reject stok opname', function () { + $user = createStokOpnameUserWithPermission( + PermissionEnum::STOK_OPNAMES_VIEW, + PermissionEnum::STOK_OPNAMES_VERIFY + ); + + $setup = createCatalogSetup(); + + $stokOpname = StokOpname::create([ + 'opname_date' => now(), + 'status' => StokOpnameStatus::PENDING, + 'created_by_id' => $user->id, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.stok-opnames.reject', $stokOpname), [ + 'reason' => 'Data is invalid', + ]) + ->assertRedirect(route('admin.manage.stok-opnames.index')); + + $stokOpname->refresh(); + $this->assertEquals(StokOpnameStatus::REJECTED, $stokOpname->status); + $this->assertEquals('Data is invalid', $stokOpname->verification_notes); + }); +});