feat: implement stock quality categorization in StokOpname; enhance validation rules and update frontend components for improved inventory tracking and reporting
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-05 14:06:47 +07:00
parent 0d77d6a4fd
commit 289fc4dc2c
2 changed files with 539 additions and 0 deletions

View File

@ -0,0 +1,197 @@
<?php
use App\Enums\CuttingStatus;
use App\Enums\Permission as PermissionEnum;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingResult;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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',
]);
});
});

View File

@ -0,0 +1,342 @@
<?php
use App\Enums\Permission as PermissionEnum;
use App\Enums\ProductStockQuality;
use App\Enums\StokOpnameStatus;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\StokOpname;
use App\Models\StokOpnameItem;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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);
});
});