with([ 'createdBy.profile', 'pendingOwnerVerificationRequest.submittedBy.profile', 'items.productVariant.product:id,name', 'items.productVariant.media', 'media', ]) ->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void { $query->where('restocks.id', $tableQuery['search_id']); }) ->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { $query->where('notes', 'like', "%{$search}%") ->orWhereHas('items.productVariant', function (Builder $query) use ($search): void { $query->where('name', 'like', "%{$search}%") ->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); }); }); }); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); return $query ->paginate(25) ->withQueryString() ->through(function (Restock $restock) { $restock->setAttribute( 'photos', MediaPresenter::first($restock, 'photos'), ); $pendingRequest = $restock->pendingOwnerVerificationRequest; $restock->setAttribute('has_pending_request', $pendingRequest !== null); $restock->setAttribute('pending_request_id', $pendingRequest?->id); $restock->setAttribute('pending_request_action', $pendingRequest?->action->value); $restock->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); $restock->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username); $restock->setAttribute('stock_type_label', $restock->stock_type->label()); $restock->items->each(fn (RestockItem $item) => $this->breakItemCircularReference($item)); return $restock; }); } public function productCatalog(?Restock $restock = null, ?User $user = null): Collection { $selectedVariantIds = $restock ? $restock->items()->pluck('product_variant_id')->all() : ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []); return Product::query() ->with([ 'variants' => fn ($query) => $query ->with(['media', 'prices']) ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($selectedVariantIds): void { $query->active(); if ($selectedVariantIds !== []) { $query->orWhereHas( 'variants', fn (Builder $query) => $query->whereIn('id', $selectedVariantIds), ); } }) ->orderBy('name') ->get() ->each(function (Product $product): void { $product->variants->each(function (ProductVariant $variant): void { $variant->setAttribute( 'images', MediaPresenter::collection($variant, 'images'), ); $hargaModal = $variant->prices ->firstWhere('type', PriceType::HARGA_MODAL); $variant->setAttribute( 'harga_modal', $hargaModal?->price ?? 0, ); $variant->unsetRelation('prices'); }); }); } public function findForEdit(Restock $restock): Restock { $restock->load([ 'items.productVariant.product:id,name', 'items.productVariant.media', 'media', ]); $restock->setAttribute( 'photos', MediaPresenter::first($restock, 'photos'), ); $restock->items->each(function (RestockItem $item): void { $variant = $item->productVariant; if ($variant) { $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); } $this->breakItemCircularReference($item); }); return $restock; } public function draftItemsForUser(User $user): array { return $this->draftItemsQuery($user) ->with([ 'productVariant.product:id,name', 'productVariant.media', ]) ->get() ->map(function (RestockItem $item) { $result = $this->presentDraftItem($item); $this->breakItemCircularReference($item); return $result; }) ->values() ->all(); } public function clearDraftItemsForUser(User $user): void { $this->draftItemsQuery($user)->delete(); } public function syncDraftItem(array $validated, User $user): array { $variant = ProductVariant::query() ->with('product:id,name') ->findOrFail($validated['product_variant_id']); $quantity = (float) $validated['quantity']; $unitPrice = (int) $validated['unit_price']; $subtotal = (int) round($quantity * $unitPrice); $item = RestockItem::query()->updateOrCreate( [ 'user_id' => $user->id, 'product_variant_id' => $variant->id, 'restock_id' => null, ], [ 'quantity' => $quantity, 'unit_price' => $unitPrice, 'subtotal' => $subtotal, ], ); $item->load([ 'productVariant.product:id,name', 'productVariant.media', ]); $result = $this->presentDraftItem($item); $this->breakItemCircularReference($item); return $result; } public function removeDraftItem(User $user, ProductVariant $productVariant): void { RestockItem::query() ->whereNull('restock_id') ->where('user_id', $user->id) ->where('product_variant_id', $productVariant->id) ->delete(); } public function create(array $validated, User $user): Restock { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $restock = $this->runInTransaction( function () use ($validated, $user, $isOwner): Restock { $resolvedItems = $this->processRequestItems($validated['items'] ?? []); $subtotal = array_sum(array_column($resolvedItems, 'subtotal')); $restock = Restock::create([ 'created_by_id' => $user->id, 'subtotal' => $subtotal, 'total' => $subtotal, 'notes' => $validated['notes'] ?? null, 'stock_type' => $validated['stock_type'] ?? 'good', ]); foreach ($resolvedItems as $itemData) { $restock->items()->create([ 'product_variant_id' => $itemData['product_variant_id'], 'quantity' => $itemData['quantity'], 'unit_price' => $itemData['unit_price'], 'subtotal' => $itemData['subtotal'], ]); } $this->syncPhotos($restock, $validated); $restock->load(['items.productVariant.product:id,name']); $stockType = $restock->stock_type; if ($isOwner) { foreach ($restock->items as $item) { $this->incrementStock($item, $stockType); } } else { OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::CREATE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Restock::class, 'subject_id' => $restock->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => null, 'new' => $this->snapshotRestock($restock), ], ]); } return $restock; }, 'Gagal membuat restock', ); if (! $isOwner) { $this->notifyForPendingRequest( $user, 'Tambah Restock', "Pengajuan restock senilai {$restock->total_formatted} oleh {$user->profile?->full_name} menunggu verifikasi owner.", route('admin.manage.restocks.index', ['search_id' => $restock->id]), (string) $restock->id, ); } $this->cacheForgetByPattern('manage:restocks:*'); return $restock; } public function update(Restock $restock, array $validated, User $user): void { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $restock->load(['items.productVariant.product:id,name']); $this->runInTransaction( function () use ($restock, $validated, $user, $isOwner): void { if ($isOwner) { $payload = $this->buildPayloadFromValidated($validated); $this->applyPayloadToRestock($restock, $payload); if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { $this->syncPhotos($restock, $validated); } } else { $verificationRequest = OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::UPDATE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Restock::class, 'subject_id' => $restock->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotRestock($restock), 'new' => $this->buildPayloadFromValidated($validated), ], ]); if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { $this->syncRequestPhotos($verificationRequest, $validated); } } }, 'Gagal memperbarui restock', ); if (! $isOwner) { $this->notifyForPendingRequest( $user, 'Ubah Restock', "Pengajuan ubah restock oleh {$user->profile?->full_name} menunggu verifikasi owner.", route('admin.manage.restocks.index', ['search_id' => $restock->id]), (string) $restock->id, ); } $this->cacheForgetByPattern('manage:restocks:*'); } public function delete(Restock $restock, User $user): void { $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); if ($isOwner) { $this->executeDelete($restock); $this->cacheForgetByPattern('manage:restocks:*'); return; } $restock->load(['items.productVariant.product:id,name']); $this->runInTransaction( function () use ($restock, $user): void { OwnerVerificationRequest::create([ 'action' => OwnerVerificationAction::DELETE, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => Restock::class, 'subject_id' => $restock->id, 'submitted_by_id' => $user->id, 'payload' => [ 'old' => $this->snapshotRestock($restock), 'new' => null, ], ]); }, 'Gagal mengajukan penghapusan restock', ); $this->notifyForPendingRequest( $user, 'Hapus Restock', "Pengajuan hapus restock oleh {$user->profile?->full_name} menunggu verifikasi owner.", route('admin.manage.restocks.index', ['search_id' => $restock->id]), (string) $restock->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), default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi restock tidak didukung.', ]), }; $this->cacheForgetByPattern('manage:restocks:*'); } public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void { match ($verificationRequest->action) { OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest), OwnerVerificationAction::UPDATE, OwnerVerificationAction::DELETE => null, default => throw ValidationException::withMessages([ 'action' => 'Aksi verifikasi restock tidak didukung.', ]), }; $this->cacheForgetByPattern('manage:restocks:*'); } public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void { $verificationRequest->clearMediaCollection('photos'); } public function applyCreate(OwnerVerificationRequest $verificationRequest): void { $restock = $verificationRequest->subject; if (! $restock instanceof Restock) { throw ValidationException::withMessages([ 'restock' => 'Restock tidak ditemukan.', ]); } $restock->load('items'); $stockType = $restock->stock_type; foreach ($restock->items as $item) { $this->incrementStock($item, $stockType); } } public function applyUpdate(OwnerVerificationRequest $verificationRequest): void { $restock = $verificationRequest->subject; if (! $restock instanceof Restock) { throw ValidationException::withMessages([ 'restock' => 'Restock tidak ditemukan.', ]); } $this->applyPayloadToRestock($restock, $this->payloadNew($verificationRequest), $verificationRequest); } public function applyDelete(OwnerVerificationRequest $verificationRequest): void { $restock = $verificationRequest->subject; if (! $restock instanceof Restock) { throw ValidationException::withMessages([ 'restock' => 'Restock tidak ditemukan.', ]); } $this->executeDelete($restock); } private function processRequestItems(array $items): array { $resolvedItems = []; foreach ($items as $index => $itemData) { $variant = ProductVariant::query()->find($itemData['product_variant_id']); if ($variant === null) { throw ValidationException::withMessages([ "items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.', ]); } $quantity = (float) $itemData['quantity']; $unitPrice = (int) $itemData['unit_price']; $lineSubtotal = (int) round($quantity * $unitPrice); $resolvedItems[] = [ 'product_variant_id' => $variant->id, 'quantity' => $quantity, 'unit_price' => $unitPrice, 'subtotal' => $lineSubtotal, ]; } return $resolvedItems; } private function syncPhotos(Restock $restock, array $validated): void { $this->mediaService->syncCollection( $restock, 'photos', $validated['photos'] ?? null, $validated['remove_media_ids'] ?? null, self::MAX_PHOTOS, required: false, errorKey: 'photos', s3Keys: $validated['s3_keys'] ?? null, ); } private function draftItemsQuery(User $user): Builder { return RestockItem::query() ->whereNull('restock_id') ->where('user_id', $user->id); } private function presentDraftItem(RestockItem $item): array { $variant = $item->productVariant; $product = $variant?->product; return [ 'product_variant_id' => $item->product_variant_id, 'product_name' => $product?->name ?? '', 'variant_name' => $variant?->name ?? '', 'stock' => $variant?->stock ?? 0, 'quantity' => $item->quantity_input, 'unit_price' => $item->unit_price, 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], ]; } private function stockColumn(ProductStockQuality $stockType): string { return match ($stockType) { ProductStockQuality::GOOD => 'stock', ProductStockQuality::REJECT => 'reject_stock', ProductStockQuality::RETAIL => 'retail_stock', }; } private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void { ProductVariant::query() ->whereKey($item->product_variant_id) ->increment($this->stockColumn($stockType), $item->quantity); } private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void { ProductVariant::query() ->whereKey($item->product_variant_id) ->decrement($this->stockColumn($stockType), $item->quantity); } private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void { $ownerUrl = route('admin.manage.restocks.index'); if ($search !== null) { $ownerUrl = route('admin.manage.restocks.index', ['search_id' => $search]); } $this->pushNotificationService->sendToRoles( "📦 {$typeLabel} Menunggu Persetujuan Owner", $body, ['owner', 'developer', 'direktur'], $ownerUrl, ); $this->pushNotificationService->sendToUser( '📤 Pengajuan Terkirim', $body, $user->id, $submitterUrl, ); } private function rejectCreate(OwnerVerificationRequest $verificationRequest): void { $restock = $verificationRequest->subject; if (! $restock instanceof Restock) { return; } $this->runInTransaction( function () use ($restock): void { $restock->clearMediaCollection('photos'); $restock->items()->delete(); $restock->delete(); }, 'Gagal menolak restock', ); } private function executeDelete(Restock $restock): void { $this->runInTransaction( function () use ($restock): void { $restock->load('items'); $stockType = $restock->stock_type; foreach ($restock->items as $item) { $this->decrementStock($item, $stockType); } $restock->clearMediaCollection('photos'); $restock->items()->delete(); $restock->delete(); }, 'Gagal menghapus restock', ); } private function applyPayloadToRestock( Restock $restock, array $payload, ?OwnerVerificationRequest $verificationRequest = null, ): void { $this->runInTransaction( function () use ($restock, $payload, $verificationRequest): void { $restock->load('items'); $stockType = $restock->stock_type; foreach ($restock->items as $item) { $this->decrementStock($item, $stockType); } $restock->items()->delete(); foreach ($payload['items'] ?? [] as $itemData) { $restockItem = $restock->items()->create([ 'product_variant_id' => $itemData['product_variant_id'], 'quantity' => $itemData['quantity'], 'unit_price' => $itemData['unit_price'], 'subtotal' => $itemData['subtotal'], ]); $this->incrementStock($restockItem, $stockType); } $restock->update([ 'subtotal' => $payload['subtotal'], 'total' => $payload['total'], 'notes' => $payload['notes'] ?? null, 'stock_type' => $payload['stock_type'] ?? $restock->stock_type, ]); if ($verificationRequest !== null) { $this->applyRequestPhotos($verificationRequest, $restock, $payload); } }, 'Gagal memperbarui restock', ); } private function snapshotRestock(Restock $restock): array { $restock->load([ 'items.productVariant.product:id,name', ]); return [ 'subtotal' => $restock->subtotal, 'total' => $restock->total, 'notes' => $restock->notes, 'stock_type' => $restock->stock_type->value, 'items' => $restock->items ->map(fn (RestockItem $item) => [ 'product_variant_id' => $item->product_variant_id, 'product_name' => $item->productVariant?->product?->name, 'variant_name' => $item->productVariant?->name, 'quantity' => (float) $item->quantity, 'unit_price' => $item->unit_price, 'subtotal' => $item->subtotal, ]) ->all(), 'has_photos' => $restock->hasMedia('photos'), ]; } private function buildPayloadFromValidated(array $validated): array { $lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'])); $subtotal = array_sum(array_column($lineItems, 'subtotal')); return [ 'subtotal' => $subtotal, 'total' => $subtotal, 'notes' => $validated['notes'] ?? null, 'stock_type' => $validated['stock_type'] ?? 'good', 'items' => $lineItems, 'remove_media_ids' => $validated['remove_media_ids'] ?? [], ]; } private function enrichLineItems(array $lineItems): array { $variants = ProductVariant::query() ->with('product:id,name') ->whereIn('id', array_column($lineItems, 'product_variant_id')) ->get() ->keyBy('id'); return collect($lineItems) ->map(function (array $item) use ($variants) { $variant = $variants->get($item['product_variant_id']); return array_merge($item, [ 'product_name' => $variant?->product?->name, 'variant_name' => $variant?->name, ]); }) ->all(); } private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest, array $validated): void { $this->mediaService->syncCollection( $verificationRequest, 'photos', $validated['photos'] ?? null, $validated['remove_media_ids'] ?? null, self::MAX_PHOTOS, required: false, errorKey: 'photos', s3Keys: $validated['s3_keys'] ?? null, ); } private function applyRequestPhotos( OwnerVerificationRequest $verificationRequest, Restock $restock, array $payload, ): void { if ($verificationRequest->hasMedia('photos')) { $restock->clearMediaCollection('photos'); foreach ($verificationRequest->getMedia('photos') as $media) { $media->copy($restock, 'photos'); } return; } foreach ($payload['remove_media_ids'] ?? [] as $mediaId) { $restock->deleteMedia((int) $mediaId); } } private function payloadNew(OwnerVerificationRequest $verificationRequest): array { $payload = $verificationRequest->payload ?? []; if (array_key_exists('new', $payload)) { return is_array($payload['new']) ? $payload['new'] : []; } return is_array($payload) ? $payload : []; } private function breakItemCircularReference(RestockItem $item): void { $variant = $item->productVariant; if ($variant) { $product = $variant->product; $item->setAttribute('variant_name', $variant->name); $item->setAttribute('stock', $variant->stock ?? 0); if ($product) { $item->setAttribute('product_id', $product->id); $item->setAttribute('product_name', $product->name); } $variant->unsetRelation('product'); } $item->unsetRelation('productVariant'); } private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'total', 'subtotal'], true)) { $query->orderBy($sort, $direction); return; } $query->latest(); } }