feat: implement raw material deletion validation to prevent deletion if used in active cutting
This commit is contained in:
parent
78f422b1cf
commit
bca6da8653
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'">
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user