feat: implement transaction management in CuttingService for create, update, delete, and status transition operations; refactor error handling for improved clarity; update routes to enhance cutting management functionality and ensure proper permission checks
This commit is contained in:
parent
6158a91060
commit
8e306c8f73
@ -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) {
|
||||
|
||||
@ -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.')
|
||||
|
||||
614
tests/Feature/Admin/Manage/CuttingTest.php
Normal file
614
tests/Feature/Admin/Manage/CuttingTest.php
Normal file
@ -0,0 +1,614 @@
|
||||
<?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;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user