feat: implement raw material deletion validation to prevent deletion if used in active cutting
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-24 23:31:08 +07:00
parent 78f422b1cf
commit bca6da8653
6 changed files with 69 additions and 70 deletions

View File

@ -2,8 +2,10 @@
namespace App\Services\Master;
use App\Enums\CuttingStatus;
use App\Enums\OwnerVerificationAction;
use App\Enums\RawMaterialUnit;
use App\Models\CuttingMaterial;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
@ -286,6 +288,8 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
private function applyDeleteSubject(RawMaterial $rawMaterial): void
{
$this->ensureNotUsedInActiveCutting($rawMaterial);
$this->runInTransaction(
function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
@ -295,6 +299,20 @@ function () use ($rawMaterial): void {
);
}
private function ensureNotUsedInActiveCutting(RawMaterial $rawMaterial): void
{
$usedInActiveCutting = CuttingMaterial::query()
->whereHas('rawMaterialPrice', fn ($q) => $q->where('raw_material_id', $rawMaterial->id))
->whereHas('cutting', fn ($q) => $q->where('status', '!=', CuttingStatus::COMPLETED))
->exists();
if ($usedInActiveCutting) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak dapat dihapus karena masih digunakan dalam proses cutting yang belum selesai.',
]);
}
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;

View File

@ -5,7 +5,6 @@ import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import VerificationDetailModal from '@/components/owner-verification/VerificationDetailModal.vue';
import { Badge } from '@/components/ui/badge';
import {
Table,
@ -67,16 +66,6 @@ function allVariantImageStocks(material: RawMaterialListItem): string[] {
);
}
const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null);
function openVerificationDetail(requestId: number | undefined) {
if (requestId) {
selectedRequestId.value = requestId;
verificationModalOpen.value = true;
}
}
// Modal Edit Varian State
const isEditing = ref(false);
const editingMaterial = ref<RawMaterialListItem | null>(null);
@ -118,12 +107,6 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
{{ material.unit_label }}
</Badge>
</div>
<p v-if="material.has_pending_request" class="text-sm text-amber-600">
{{ material.pending_request_submitted_by_name }} mengajukan {{
material.pending_request_action_label?.toLowerCase() }} bahan baku ini
<button type="button" class="underline-offset-2 hover:underline font-semibold"
@click="openVerificationDetail(material.pending_request_id)">lihat</button>
</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>
Total stok <strong class="text-primary font-semibold">
@ -183,7 +166,7 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
{{ price.price_formatted }}
</TableCell>
<TableCell class="text-center">
<RowEditAction :disabled="material.has_pending_request" tooltip="Ubah Varian"
<RowEditAction tooltip="Ubah Varian"
@click="openEditModal(price, material)" />
</TableCell>
</TableRow>
@ -197,8 +180,6 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
<VerificationDetailModal v-model:open="verificationModalOpen" :request-id="selectedRequestId" />
<!-- Dialog Modal Edit Varian -->
<RawMaterialVariantEditModal
v-model:open="isEditing"

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
import { edit, destroy } from '@/routes/admin/master/raw_materials';
import type { RawMaterialListItem } from '@/types/raw-material';
@ -10,30 +9,30 @@ defineProps<{
}>();
const { can } = useCan();
function onDeleteError(errors: Record<string, string>): string | void {
const firstError = Object.values(errors)[0];
if (firstError) {
return firstError;
}
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<OwnerVerificationRowActions
v-if="material.has_pending_request && material.pending_request_id"
type="request"
:id="material.pending_request_id"
/>
<RowEditAction
v-if="can('raw_materials.update')"
:href="edit.url(material.id)"
:disabled="material.has_pending_request"
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Ubah'"
tooltip="Ubah"
/>
<RowDeleteAction
v-if="can('raw_materials.delete')"
:action-url="destroy.url(material.id)"
:disabled="material.has_pending_request"
:tooltip="material.has_pending_request ? 'Menunggu verifikasi owner' : 'Hapus'"
title="Hapus bahan baku?"
:description="`Pengajuan hapus bahan baku ${material.name} akan dikirim ke owner untuk verifikasi.`"
error-message="Gagal mengajukan penghapusan bahan baku."
:description="`Apakah Anda yakin ingin menghapus bahan baku ${material.name}? Tindakan ini tidak dapat dibatalkan.`"
error-message="Gagal menghapus bahan baku."
:on-error="onDeleteError"
/>
</div>
</template>

View File

@ -30,7 +30,7 @@ watch(
);
function toggleStatus(checked: boolean) {
if (!can('raw_materials.toggle_status') || props.material.has_pending_request) {
if (!can('raw_materials.toggle_status')) {
return;
}
@ -66,7 +66,7 @@ function toggleStatus(checked: boolean) {
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('raw_materials.toggle_status') || material.has_pending_request"
:disabled="processing || !can('raw_materials.toggle_status')"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">

View File

@ -29,11 +29,6 @@ export type RawMaterialListItem = {
total_inventory_value_formatted?: string;
is_active: boolean;
prices: RawMaterialPrice[];
has_pending_request?: boolean;
pending_request_id?: number;
pending_request_action?: string;
pending_request_action_label?: string;
pending_request_submitted_by_name?: string;
display_is_active?: boolean;
};

View File

@ -1,9 +1,12 @@
<?php
use App\Enums\CuttingStatus;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Enums\RawMaterialUnit;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
@ -674,36 +677,15 @@ function createRawMaterialVerifierUser(): User
// ─── Destroy ──────────────────────────────────────────────
describe('Raw Material Destroy', function () {
test('authenticated user with permission can submit delete request', function () {
test('authenticated user with permission can delete raw material', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$rawMaterial = createRawMaterialWithPrices();
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
->assertRedirect(route('admin.master.raw_materials.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_id' => $rawMaterial->id,
'subject_type' => RawMaterial::class,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
test('approving delete request soft deletes raw material and prices', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$verifier = createRawMaterialVerifierUser();
$rawMaterial = createRawMaterialWithPrices();
$priceIds = $rawMaterial->prices->pluck('id')->toArray();
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
approveLatestOwnerVerificationRequest($verifier);
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
->assertRedirect(route('admin.master.raw_materials.index'));
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
@ -733,20 +715,44 @@ function createRawMaterialVerifierUser(): User
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
test('deleting raw material is not applied until owner approves', function () {
test('cannot delete raw material that is used in an in-progress cutting', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$rawMaterial = createRawMaterialWithPrices();
$priceIds = $rawMaterial->prices->pluck('id')->toArray();
$price = $rawMaterial->prices->first();
$cutting = Cutting::factory()->create(['status' => CuttingStatus::IN_PROGRESS]);
CuttingMaterial::factory()->create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price->id,
'user_id' => null,
]);
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial));
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
->assertRedirect();
$this->assertNotSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
foreach ($priceIds as $priceId) {
$this->assertNotSoftDeleted('raw_material_prices', ['id' => $priceId]);
}
test('can delete raw material that is used in a completed cutting', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW, PermissionEnum::RAW_MATERIALS_DELETE);
$rawMaterial = createRawMaterialWithPrices();
$price = $rawMaterial->prices->first();
$cutting = Cutting::factory()->create(['status' => CuttingStatus::COMPLETED]);
CuttingMaterial::factory()->create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price->id,
'user_id' => null,
]);
$this->actingAs($user)
->delete(route('admin.master.raw_materials.destroy', $rawMaterial))
->assertRedirect(route('admin.master.raw_materials.index'));
$this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]);
});
});