feat: enhance cutting status management by adding verified status transition and refining user permissions for verification actions

This commit is contained in:
Yoga Pangestu 2026-06-28 22:02:21 +07:00
parent 573e81193a
commit aeb7d1f292
7 changed files with 370 additions and 192 deletions

View File

@ -46,7 +46,7 @@ public function canTransitionTo(self $status): bool
{
return match ($this) {
self::IN_PROGRESS => $status === self::COMPLETED,
self::COMPLETED => in_array($status, [self::PENDING_VERIFICATION, self::REJECTED, self::IN_PROGRESS], true),
self::COMPLETED => in_array($status, [self::PENDING_VERIFICATION, self::REJECTED, self::IN_PROGRESS, self::VERIFIED], true),
self::PENDING_VERIFICATION => in_array($status, [self::VERIFIED, self::REJECTED, self::COMPLETED], true),
self::REJECTED => $status === self::IN_PROGRESS,
default => false,
@ -80,7 +80,15 @@ public function availableActions(): array
'icon_only' => false,
],
],
self::COMPLETED => [],
self::COMPLETED => [
[
'status' => self::VERIFIED->value,
'label' => 'Verifikasi',
'destructive' => false,
'permission' => Permission::CUTTINGS_VERIFY->value,
'icon_only' => false,
],
],
self::PENDING_VERIFICATION => [],
self::REJECTED => [
[

View File

@ -5,6 +5,7 @@
use App\Enums\CuttingStatus;
use App\Enums\Permission;
use App\Enums\PriceType;
use App\Enums\Role;
use App\Models\Cutting;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -83,6 +84,14 @@ public function withValidator(Validator $validator): void
$validator->errors()->add('status', 'Status cutting tidak dapat diubah.');
}
// Only owner/developer can verify directly from COMPLETED status
if ($cutting->status === CuttingStatus::COMPLETED && $status === CuttingStatus::VERIFIED) {
$user = $this->user();
if (! $user || ! $user->hasRole([Role::OWNER->value, Role::DEVELOPER->value])) {
$validator->errors()->add('status', 'Hanya owner yang dapat langsung memverifikasi cutting.');
}
}
if ($status === CuttingStatus::REJECTED && trim((string) $this->input('reason')) === '') {
$validator->errors()->add('reason', 'Alasan penolakan wajib diisi.');
}

View File

@ -3,6 +3,7 @@
namespace App\Services\Manage;
use App\Enums\CuttingStatus;
use App\Enums\Role;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingResult;
@ -58,12 +59,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
->paginate(25)
->withQueryString()
->through(function (Cutting $cutting) use ($user) {
$actions = collect($cutting->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$cutting->setAttribute('available_actions', $actions);
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
@ -85,12 +81,7 @@ public function getInProgressCuttings(User $user): Collection
->latest()
->get()
->each(function (Cutting $cutting) use ($user): void {
$actions = collect($cutting->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$cutting->setAttribute('available_actions', $actions);
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
});
@ -110,12 +101,7 @@ public function getCompletedCuttings(User $user): Collection
->latest()
->get()
->each(function (Cutting $cutting) use ($user): void {
$actions = collect($cutting->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$cutting->setAttribute('available_actions', $actions);
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
});
@ -891,6 +877,25 @@ private function formatMaterialUsageSummary(Cutting $cutting): string
->join(', ');
}
/**
* @return list<array{status: string, label: string, destructive: bool, permission: string, icon_only: bool}>
*/
private function filterActionsForUser(Cutting $cutting, User $user): array
{
return collect($cutting->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->filter(function (array $action) use ($cutting, $user): bool {
// Only owner/developer can verify directly from COMPLETED status
if ($cutting->status === CuttingStatus::COMPLETED && $action['status'] === CuttingStatus::VERIFIED->value) {
return $user->hasRole([Role::OWNER->value, Role::DEVELOPER->value]);
}
return true;
})
->values()
->all();
}
private function appendCostPreview(Cutting $cutting): void
{
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));

View File

@ -4,6 +4,7 @@
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
@ -201,8 +202,10 @@ public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice):
public function create(array $validated, User $user): Purchase
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
try {
$purchase = DB::transaction(function () use ($validated, $user): Purchase {
$purchase = DB::transaction(function () use ($validated, $user, $isOwner): Purchase {
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
@ -239,6 +242,11 @@ public function create(array $validated, User $user): Purchase
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
if ($isOwner) {
foreach ($purchase->items as $item) {
$this->incrementStock($item);
}
} else {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
@ -250,13 +258,14 @@ public function create(array $validated, User $user): Purchase
'new' => $this->snapshotPurchase($purchase),
],
]);
}
return $purchase;
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan pembelian: '.$e->getMessage(), [
Log::error('Gagal membuat pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -265,6 +274,7 @@ public function create(array $validated, User $user): Purchase
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Tambah Belanja',
@ -272,16 +282,26 @@ public function create(array $validated, User $user): Purchase
route('admin.manage.purchases.index', ['search' => $purchase->supplier->name]),
$purchase->supplier->name,
);
}
return $purchase;
}
public function update(Purchase $purchase, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
try {
DB::transaction(function () use ($purchase, $validated, $user): void {
DB::transaction(function () use ($purchase, $validated, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->buildPayloadFromValidated($validated);
$this->applyPayloadToPurchase($purchase, $payload);
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
$this->syncPhotos($purchase, $validated);
}
} else {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
@ -297,11 +317,12 @@ public function update(Purchase $purchase, array $validated, User $user): void
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
$this->syncRequestPhotos($verificationRequest, $validated);
}
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan belanja: '.$e->getMessage(), [
Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -310,6 +331,7 @@ public function update(Purchase $purchase, array $validated, User $user): void
]);
}
if (! $isOwner) {
$purchase->load('supplier');
$this->notifyForPendingRequest(
@ -320,9 +342,18 @@ public function update(Purchase $purchase, array $validated, User $user): void
$purchase->supplier->name,
);
}
}
public function delete(Purchase $purchase, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$this->executeDelete($purchase);
return;
}
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
try {

View File

@ -4,6 +4,7 @@
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant;
use App\Models\StockRetailHistory;
@ -19,7 +20,7 @@ public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): OwnerVerificationRequest
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): ?OwnerVerificationRequest
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
@ -35,6 +36,14 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not
]);
}
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$this->executeTransfer($variant, $quantity, $user, $notes);
return null;
}
$existingPending = OwnerVerificationRequest::query()
->where('subject_type', ProductVariant::class)
->where('subject_id', $variant->id)
@ -111,9 +120,20 @@ public function applyStockRetailTransfer(OwnerVerificationRequest $request): voi
return;
}
DB::transaction(function () use ($request, $quantity, $newData): void {
$variant = ProductVariant::query()->find($request->subject_id);
if (! $variant) {
return;
}
$this->executeTransfer($variant, $quantity, $request->submittedBy, $newData['notes'] ?? null);
}
private function executeTransfer(ProductVariant $variant, int $quantity, User $user, ?string $notes = null): void
{
DB::transaction(function () use ($variant, $quantity, $user, $notes): void {
$variant = ProductVariant::query()
->whereKey($request->subject_id)
->whereKey($variant->id)
->lockForUpdate()
->firstOrFail();
@ -125,13 +145,13 @@ public function applyStockRetailTransfer(OwnerVerificationRequest $request): voi
StockRetailHistory::create([
'product_variant_id' => $variant->id,
'user_id' => $request->submitted_by_id,
'user_id' => $user->id,
'quantity' => $quantity,
'stock_before' => $stockBefore,
'stock_retail_before' => $stockRetailBefore,
'stock_after' => $stockBefore - $quantity,
'stock_retail_after' => $stockRetailBefore + $quantity,
'notes' => $newData['notes'] ?? null,
'notes' => $notes,
'created_at' => now(),
]);
});

View File

@ -4,6 +4,7 @@
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\Category;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
@ -115,12 +116,14 @@ public function findForEdit(Product $product): Product
public function create(array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
try {
DB::transaction(function () use ($validated, $user): void {
DB::transaction(function () use ($validated, $user, $isOwner): void {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_active' => false,
'is_active' => $isOwner,
]);
$product->categories()->sync($validated['category_ids'] ?? []);
@ -135,6 +138,7 @@ public function create(array $validated, User $user): void
$this->syncVariantImages($variant, $variantData, $index);
}
if (! $isOwner) {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
@ -146,11 +150,12 @@ public function create(array $validated, User $user): void
'new' => $this->snapshotProduct($product->fresh(['categories', 'variants'])),
],
]);
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan pembuatan produk: '.$e->getMessage(), [
Log::error('Gagal membuat produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -159,6 +164,7 @@ public function create(array $validated, User $user): void
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Tambah Produk',
@ -167,11 +173,34 @@ public function create(array $validated, User $user): void
$validated['name'],
);
}
}
public function update(Product $product, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
try {
DB::transaction(function () use ($validated, $product, $user): void {
DB::transaction(function () use ($validated, $product, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload);
foreach ($validated['variants'] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->find($variantData['id']);
if ($variant) {
$this->syncVariantImages($variant, $variantData, $index);
}
} else {
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
]);
$this->syncVariantImages($variant, $variantData, $index);
}
}
} else {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
@ -188,11 +217,12 @@ public function update(Product $product, array $validated, User $user): void
$isNewVariant = empty($variantData['id']);
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
}
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan produk: '.$e->getMessage(), [
Log::error('Gagal memperbarui produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -201,6 +231,7 @@ public function update(Product $product, array $validated, User $user): void
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Ubah Produk',
@ -209,9 +240,18 @@ public function update(Product $product, array $validated, User $user): void
$product->name,
);
}
}
public function delete(Product $product, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$this->applyDeleteSubject($product);
return;
}
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE,
@ -245,6 +285,16 @@ public function delete(Product $product, User $user): void
public function toggleStatus(Product $product, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$product->update([
'is_active' => (bool) $validated['is_active'],
]);
return;
}
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS,
@ -418,6 +468,11 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
]);
}
$this->applyDeleteSubject($product);
}
private function applyDeleteSubject(Product $product): void
{
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');

View File

@ -4,6 +4,7 @@
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
@ -109,18 +110,21 @@ public function findForEdit(RawMaterial $rawMaterial): RawMaterial
public function create(array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
try {
DB::transaction(function () use ($validated, $user): void {
DB::transaction(function () use ($validated, $user, $isOwner): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => false,
'is_active' => $isOwner,
]);
foreach ($validated['prices'] as $index => $priceData) {
$this->createPrice($rawMaterial, $priceData, $index);
}
if (! $isOwner) {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
@ -132,11 +136,12 @@ public function create(array $validated, User $user): void
'new' => $this->snapshotRawMaterial($rawMaterial->fresh(['prices'])),
],
]);
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan pembuatan bahan baku: '.$e->getMessage(), [
Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -145,6 +150,7 @@ public function create(array $validated, User $user): void
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Tambah Bahan Baku',
@ -153,11 +159,29 @@ public function create(array $validated, User $user): void
$validated['name'],
);
}
}
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
try {
DB::transaction(function () use ($validated, $rawMaterial, $user): void {
DB::transaction(function () use ($validated, $rawMaterial, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToRawMaterial($rawMaterial, $payload);
foreach ($validated['prices'] as $index => $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->find($priceData['id']);
if ($price) {
$this->syncPriceImages($price, $priceData, $index);
}
} else {
$this->createPrice($rawMaterial, $priceData, $index);
}
}
} else {
$verificationRequest = OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::UPDATE,
'status' => OwnerVerificationStatus::PENDING,
@ -174,11 +198,12 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
$isNewPrice = empty($priceData['id']);
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
}
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan bahan baku: '.$e->getMessage(), [
Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
@ -187,6 +212,7 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Ubah Bahan Baku',
@ -195,9 +221,18 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
$rawMaterial->name,
);
}
}
public function delete(RawMaterial $rawMaterial, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$this->applyDeleteSubject($rawMaterial);
return;
}
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE,
@ -231,6 +266,16 @@ public function delete(RawMaterial $rawMaterial, User $user): void
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
if ($isOwner) {
$rawMaterial->update([
'is_active' => (bool) $validated['is_active'],
]);
return;
}
try {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS,
@ -366,6 +411,11 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
]);
}
$this->applyDeleteSubject($rawMaterial);
}
private function applyDeleteSubject(RawMaterial $rawMaterial): void
{
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');