with([ 'prices' => fn ($query) => $query ->orderBy('created_at') ->with('media') ->when($tableQuery['search'] !== '', function ($query) use ($tableQuery): void { $query->where('variant', 'like', "%{$tableQuery['search']}%"); }) ->when($stockStatus === 'out_of_stock', fn ($q) => $q->where('stock', '<=', 0)) ->when($stockStatus === 'low_stock', function ($q): void { $q->where('stock', '>', 0) ->where(function ($q): void { $q->orWhere(function ($q): void { $q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::YARD->value)) ->where('stock', '<', 20); }) ->orWhere(function ($q): void { $q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::METER->value)) ->where('stock', '<', 10); }) ->orWhere(function ($q): void { $q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::KILOGRAM->value)) ->where('stock', '<', 5); }); }); }), ]) ->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void { $query->where('raw_materials.id', $tableQuery['search_id']); }) ->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->whereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%")); }) ->when($rawMaterialId !== '', fn (Builder $query) => $query->where('id', $rawMaterialId)) ->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 { $query->where(function (Builder $query): void { $query->where('unit', RawMaterialUnit::YARD->value) ->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 20)); })->orWhere(function (Builder $query): void { $query->where('unit', RawMaterialUnit::METER->value) ->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 10)); })->orWhere(function (Builder $query): void { $query->where('unit', RawMaterialUnit::KILOGRAM->value) ->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 5)); }); }); $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'); }); 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 { $rawMaterial = $this->runInTransaction( function () use ($validated): RawMaterial { $rawMaterial = RawMaterial::create([ 'name' => $validated['name'], 'unit' => $validated['unit'], 'is_active' => true, ]); foreach ($validated['prices'] as $index => $priceData) { $this->createPrice($rawMaterial, $priceData, $index); } return $rawMaterial; }, 'Gagal membuat bahan baku', ); $this->cacheForgetByPattern('master:raw_materials:*'); $this->notifyOwner( 'Tambah Bahan Baku', "Bahan baku '{$validated['name']}' telah ditambahkan oleh {$user->profile?->full_name}.", route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]), ); } public function update(RawMaterial $rawMaterial, array $validated, User $user): void { $this->runInTransaction( function () use ($validated, $rawMaterial): void { $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $this->applyPayloadToRawMaterial($rawMaterial, $payload, originalPrices: $validated['prices'] ?? []); }, 'Gagal memperbarui bahan baku', ); $this->cacheForgetByPattern('master:raw_materials:*'); $this->notifyOwner( 'Ubah Bahan Baku', "Bahan baku '{$rawMaterial->name}' telah diperbarui oleh {$user->profile?->full_name}.", route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]), ); } public function delete(RawMaterial $rawMaterial, User $user): void { $name = $rawMaterial->name; $this->applyDeleteSubject($rawMaterial); $this->cacheForgetByPattern('master:raw_materials:*'); $this->notifyOwner( 'Hapus Bahan Baku', "Bahan baku '{$name}' telah dihapus oleh {$user->profile?->full_name}.", route('admin.master.raw_materials.index'), ); } public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void { $isActive = $validated['status'] === 'active'; $rawMaterial->update([ 'is_active' => $isActive, ]); $this->cacheForgetByPattern('master:raw_materials:*'); $statusLabel = $isActive ? 'aktif' : 'nonaktif'; $this->notifyOwner( 'Ubah Status Bahan Baku', "Status bahan baku '{$rawMaterial->name}' telah diubah menjadi {$statusLabel} oleh {$user->profile?->full_name}.", route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]), ); } 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.', ]), }; $this->cacheForgetByPattern('master:raw_materials:*'); } 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.', ]), }; $this->cacheForgetByPattern('master:raw_materials:*'); } 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 notifyOwner(string $typeLabel, string $body, string $url): void { $this->pushNotificationService->sendToRoles( "📦 {$typeLabel}", $body, ['owner', 'developer'], $url, ); } 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 { $this->runInTransaction( function () use ($rawMaterial): void { $rawMaterial->prices()->delete(); $rawMaterial->delete(); }, 'Gagal menghapus bahan baku', ); } 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' => ($newPayload['status'] ?? 'inactive') === 'active', ]); } private function applyPayloadToRawMaterial( RawMaterial $rawMaterial, array $payload, ?OwnerVerificationRequest $verificationRequest = null, array $originalPrices = [], ): 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->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); } elseif (isset($originalPrices[$index])) { $this->syncPriceImages($price, $originalPrices[$index], $index); } continue; } $price = $rawMaterial->prices()->create([ 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], ]); if ($verificationRequest !== null) { $this->copyRequestPriceImages($verificationRequest, (int) $index, $price); } elseif (isset($originalPrices[$index])) { $this->syncPriceImages($price, $originalPrices[$index], $index); } } } private function rejectCreate(OwnerVerificationRequest $verificationRequest): void { $rawMaterial = $verificationRequest->subject; if (! $rawMaterial instanceof RawMaterial) { return; } $this->runInTransaction( function () use ($rawMaterial): void { $rawMaterial->prices()->delete(); $rawMaterial->delete(); }, 'Gagal menolak bahan baku', ); } 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}.s3_keys", s3Keys: $priceData['s3_keys'] ?? null, ); } 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}.s3_keys", s3Keys: $priceData['s3_keys'] ?? null, ); } 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')); } }