with([ 'pendingOwnerVerificationRequest', 'prices' => fn ($query) => $query ->orderBy('created_at') ->with('media'), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { $query->where('name', 'like', "%{$search}%") ->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%")); }); }) ->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1')) ->when($stockStatus === 'out_of_stock', function (Builder $query): void { $query->whereHas('prices', fn (Builder $priceQuery) => $priceQuery->where('stock', '<=', 0)); }) ->when($stockStatus === 'low_stock', function (Builder $query): void { $minStock = [ RawMaterialUnit::YARD => 20, RawMaterialUnit::METER => 10, RawMaterialUnit::KILOGRAM => 5, ]; $query->whereHas('prices', function (Builder $priceQuery) use ($minStock): void { $priceQuery->where('stock', '>', 0)->where(function (Builder $priceQuery) use ($minStock): void { foreach (RawMaterialUnit::cases() as $unit) { $priceQuery->orWhere(function (Builder $priceQuery) use ($unit, $minStock): void { $priceQuery->whereHas('rawMaterial', fn (Builder $rawMaterialQuery) => $rawMaterialQuery->where('unit', $unit)) ->where('stock', '<', $minStock[$unit]); }); } }); }); }); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); return $query ->paginate(25) ->withQueryString() ->through(function (RawMaterial $rawMaterial) { $rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void { $price->setAttribute('images', MediaPresenter::collection($price, 'images')); $price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation()); $price->unsetRelation('rawMaterial'); }); $pendingRequest = $rawMaterial->pendingOwnerVerificationRequest; $rawMaterial->setAttribute('has_pending_request', $pendingRequest !== null); $rawMaterial->setAttribute('pending_request_id', $pendingRequest?->id); $rawMaterial->setAttribute('pending_request_action', $pendingRequest?->action->value); $rawMaterial->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); $rawMaterial->setAttribute( 'display_is_active', $pendingRequest?->pendingToggleIsActive() ?? $rawMaterial->is_active, ); return $rawMaterial; }); } public function findForEdit(RawMaterial $rawMaterial): RawMaterial { $rawMaterial->load([ 'prices' => fn ($query) => $query->orderBy('created_at')->with('media'), ]); $rawMaterial->prices->each(function (RawMaterialPrice $price) use ($rawMaterial): void { $price->setAttribute('images', MediaPresenter::collection($price, 'images')); $price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation()); $price->unsetRelation('rawMaterial'); }); return $rawMaterial; } public function create(array $validated, User $user): void { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); try { DB::transaction(function () use ($validated, $user, $isOwner): void { $rawMaterial = RawMaterial::create([ 'name' => $validated['name'], 'unit' => $validated['unit'], 'is_active' => $isOwner, ]); foreach ($validated['prices'] as $index => $priceData) { $this->createPrice($rawMaterial, $priceData, $index); } if (! $isOwner) { OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::CREATE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => RawMaterial::class, 'subject_id' => $rawMaterial->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => null, 'new' => $this->snapshotRawMaterial($rawMaterial->fresh(['prices'])), ], ]); } }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } if (! $isOwner) { $this->notifyForPendingRequest( $user, 'Tambah Bahan Baku', "Pengajuan tambah bahan baku '{$validated['name']}' menunggu verifikasi owner.", route('admin.master.raw_materials.index', ['search' => $validated['name']]), $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, $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, 'subject_type' => RawMaterial::class, 'subject_id' => $rawMaterial->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotRawMaterial($rawMaterial), 'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)), ], ]); foreach ($validated['prices'] as $index => $priceData) { $isNewPrice = empty($priceData['id']); $this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice); } } }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } if (! $isOwner) { $this->notifyForPendingRequest( $user, 'Ubah Bahan Baku', "Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.", route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]), $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, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => RawMaterial::class, 'subject_id' => $rawMaterial->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotRawMaterial($rawMaterial), 'new' => null, ], ]); } catch (\Throwable $e) { Log::error('Gagal mengajukan penghapusan bahan baku: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->notifyForPendingRequest( $user, 'Hapus Bahan Baku', "Pengajuan hapus bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.", route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]), $rawMaterial->name, ); } 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, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => RawMaterial::class, 'subject_id' => $rawMaterial->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => [ 'name' => $rawMaterial->name, 'is_active' => $rawMaterial->is_active, ], 'new' => [ 'name' => $rawMaterial->name, 'is_active' => (bool) $validated['is_active'], ], ], ]); } catch (\Throwable $e) { Log::error('Gagal mengajukan perubahan status bahan baku: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $this->notifyForPendingRequest( $user, 'Ubah Status Bahan Baku', "Pengajuan ubah status bahan baku '{$rawMaterial->name}' menjadi {$statusLabel} menunggu verifikasi owner.", route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]), $rawMaterial->name, ); } public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void { match ($verificationRequest->action) { OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest), OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest), OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest), OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest), default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi bahan baku tidak didukung.', ]), }; } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void { match ($verificationRequest->action) { OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest), OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest), OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null, default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi bahan baku tidak didukung.', ]), }; } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void { $verificationRequest->load('media'); $newPayload = $this->payloadNew($verificationRequest); foreach ($newPayload['prices'] ?? [] as $index => $_price) { $verificationRequest->clearMediaCollection($verificationRequest->priceImageCollection((int) $index)); } } private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void { $ownerUrl = route('admin.master.raw_materials.index'); if ($search !== null) { $ownerUrl = route('admin.master.raw_materials.index', ['search' => $search]); } $this->pushNotificationService->sendToRoles( "📦 {$typeLabel} Menunggu Persetujuan Owner", $body, ['owner', 'developer'], $ownerUrl, ); $this->pushNotificationService->sendToUser( '📤 Pengajuan Terkirim', $body, $user->id, $submitterUrl, ); } public function applyCreate(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { throw ValidationException::withMessages([ 'raw_material' => 'Bahan baku tidak ditemukan.', ]); } $rawMaterial->update([ 'is_active' => true, ]); } public function applyUpdate(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { throw ValidationException::withMessages([ 'raw_material' => 'Bahan baku tidak ditemukan.', ]); } $this->applyPayloadToRawMaterial($rawMaterial, $this->payloadNew($verificationRequest), $verificationRequest); } public function applyDelete(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { throw ValidationException::withMessages([ 'raw_material' => 'Bahan baku tidak ditemukan.', ]); } $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'); }); $rawMaterial->prices()->delete(); $rawMaterial->delete(); }); } public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { throw ValidationException::withMessages([ 'raw_material' => 'Bahan baku tidak ditemukan.', ]); } $newPayload = $this->payloadNew($verificationRequest); $rawMaterial->update([ 'is_active' => (bool) ($newPayload['is_active'] ?? false), ]); } private function applyPayloadToRawMaterial( RawMaterial $rawMaterial, array $payload, ?OwnerVerificationRequest $verificationRequest = null, ): void { $rawMaterial->update([ 'name' => $payload['name'], 'unit' => $payload['unit'] ?? $rawMaterial->unit, ]); if (array_key_exists('is_active', $payload)) { $rawMaterial->update([ 'is_active' => (bool) $payload['is_active'], ]); } $submittedPriceIds = collect($payload['prices'] ?? []) ->pluck('id') ->filter() ->map(fn ($id) => (int) $id) ->all(); $rawMaterial->prices() ->whereNotIn('id', $submittedPriceIds) ->get() ->each(function (RawMaterialPrice $price): void { $price->clearMediaCollection('images'); $price->delete(); }); foreach ($payload['prices'] ?? [] as $index => $priceData) { if (! empty($priceData['id'])) { $price = $rawMaterial->prices()->findOrFail($priceData['id']); $price->update([ 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], ]); if ($verificationRequest !== null) { $this->applyPriceImageChanges($verificationRequest, $price, $priceData, (int) $index); } continue; } $price = $rawMaterial->prices()->create([ 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], ]); if ($verificationRequest !== null) { $this->copyRequestPriceImages($verificationRequest, (int) $index, $price); } } } private function rejectCreate(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { return; } DB::transaction(function () use ($rawMaterial): void { $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { $price->clearMediaCollection('images'); }); $rawMaterial->prices()->delete(); $rawMaterial->delete(); }); } private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { throw ValidationException::withMessages([ 'raw_material' => 'Bahan baku tidak ditemukan.', ]); } $oldPayload = $this->payloadOld($verificationRequest); if ($oldPayload === []) { return; } $this->applyPayloadToRawMaterial($rawMaterial, $oldPayload); } private function payloadOld(OwnerVerificationRequest $verificationRequest): array { $payload = $verificationRequest->payload ?? []; if (array_key_exists('old', $payload)) { return is_array($payload['old']) ? $payload['old'] : []; } return []; } private function payloadNew(OwnerVerificationRequest $verificationRequest): array { $payload = $verificationRequest->payload ?? []; if (array_key_exists('new', $payload)) { return is_array($payload['new']) ? $payload['new'] : []; } return $payload; } private function snapshotRawMaterial(RawMaterial $rawMaterial): array { $rawMaterial->load(['prices']); return $this->enrichPayload([ 'name' => $rawMaterial->name, 'unit' => $rawMaterial->unit->value, 'is_active' => $rawMaterial->is_active, 'prices' => $rawMaterial->prices ->map(fn (RawMaterialPrice $price) => [ 'id' => $price->id, 'variant' => $price->variant, 'price' => $price->price, 'stock' => $price->stock, ]) ->all(), ]); } private function enrichPayload(array $data): array { if (isset($data['unit'])) { $unit = $data['unit'] instanceof RawMaterialUnit ? $data['unit'] : RawMaterialUnit::from($data['unit']); $data['unit'] = $unit->value; $data['unit_label'] = $unit->label(); } return $data; } private function buildPayloadFromValidated(array $validated): array { return [ 'name' => $validated['name'], 'unit' => $validated['unit'], 'prices' => collect($validated['prices'] ?? []) ->map(fn (array $priceData) => [ 'id' => $priceData['id'] ?? null, 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], 'remove_media_ids' => $priceData['remove_media_ids'] ?? [], ]) ->all(), ]; } private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['name', 'unit', 'is_active'], true)) { $query->orderBy($sort, $direction); return; } $query->latest(); } private function createPrice(RawMaterial $rawMaterial, array $priceData, int $index): RawMaterialPrice { $price = $rawMaterial->prices()->create([ 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], ]); $this->syncPriceImages($price, $priceData, $index); return $price; } private function syncPriceImages(RawMaterialPrice $price, array $priceData, int $index): void { $this->mediaService->syncCollection( $price, 'images', $priceData['images'] ?? null, $priceData['remove_media_ids'] ?? null, self::MAX_VARIANT_IMAGES, required: true, errorKey: "prices.{$index}.images", ); } private function syncRequestPriceImages( OwnerVerificationRequest $verificationRequest, array $priceData, int $index, bool $required = true, ): void { $collection = $verificationRequest->priceImageCollection($index); $this->mediaService->syncCollection( $verificationRequest, $collection, $priceData['images'] ?? null, $priceData['remove_media_ids'] ?? null, self::MAX_VARIANT_IMAGES, required: $required, errorKey: "prices.{$index}.images", ); } private function applyPriceImageChanges( OwnerVerificationRequest $verificationRequest, RawMaterialPrice $price, array $priceData, int $index, ): void { $removeIds = $priceData['remove_media_ids'] ?? []; if ($removeIds !== []) { $price->getMedia('images') ->whereIn('id', $removeIds) ->each->delete(); } $this->copyRequestPriceImages($verificationRequest, $index, $price); if ($price->getMedia('images')->isEmpty()) { throw ValidationException::withMessages([ "prices.{$index}.images" => 'Foto varian wajib diisi.', ]); } } private function copyRequestPriceImages(OwnerVerificationRequest $verificationRequest, int $index, RawMaterialPrice $price): void { $verificationRequest->getMedia($verificationRequest->priceImageCollection($index)) ->each(fn ($media) => $media->copy($price, 'images')); } }