store/app/Services/Master/ProductService.php

708 lines
26 KiB
PHP

<?php
namespace App\Services\Master;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Models\Category;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class ProductService
{
private const MAX_VARIANT_IMAGES = 5;
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = '', string $stockStatus = ''): LengthAwarePaginator
{
$query = Product::query()
->with([
'categories',
'pendingOwnerVerificationRequest',
'variants' => fn ($query) => $query
->with(['media', 'prices' => fn ($query) => $query->orderBy('type')])
->orderBy('created_at'),
])
->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}%")
->orWhere('slug', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
})
->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1'))
->when($categoryId !== '', function (Builder $query) use ($categoryId): void {
$query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId));
})
->when($stockStatus === 'out_of_stock', function (Builder $query): void {
$query->whereHas('variants', fn (Builder $variantQuery) => $variantQuery->where('stock', '<=', 0));
})
->when($stockStatus === 'low_stock', function (Builder $query): void {
$query->whereHas('variants', fn (Builder $variantQuery) => $variantQuery->where('stock', '>', 0)->where('stock', '<', ProductVariant::minStock()));
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(25)
->withQueryString()
->through(function (Product $product) {
$product->variants->each(function (ProductVariant $variant): void {
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
});
$pendingRequest = $product->pendingOwnerVerificationRequest;
// Also check for pending stock retail transfer on variants
if ($pendingRequest === null) {
$pendingRequest = OwnerVerificationRequest::query()
->where('subject_type', ProductVariant::class)
->whereIn('subject_id', $product->variants->pluck('id'))
->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER)
->pending()
->latest()
->first();
}
$product->setAttribute('has_pending_request', $pendingRequest !== null);
$product->setAttribute('pending_request_id', $pendingRequest?->id);
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
$product->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$product->setAttribute(
'display_is_active',
$pendingRequest?->pendingToggleIsActive() ?? $product->is_active,
);
return $product;
});
}
public function findForEdit(Product $product): Product
{
$product->load([
'categories',
'variants' => fn ($query) => $query
->with('media')
->orderBy('created_at'),
]);
$product->variants->each(function (ProductVariant $variant): void {
$variant->setAttribute('images', MediaPresenter::collection($variant, 'images'));
});
return $product;
}
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 {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_active' => $isOwner,
]);
$product->categories()->sync($validated['category_ids'] ?? []);
foreach ($validated['variants'] as $index => $variantData) {
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
]);
$this->syncVariantImages($variant, $variantData, $index);
}
if (! $isOwner) {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => null,
'new' => $this->snapshotProduct($product->fresh(['categories', 'variants'])),
],
]);
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat produk: '.$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 Produk',
"Pengajuan tambah produk '{$validated['name']}' menunggu verifikasi owner.",
route('admin.master.products.index', ['search' => $validated['name']]),
$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, $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,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotProduct($product),
'new' => $this->enrichPayload($this->buildPayloadFromValidated($validated)),
],
]);
foreach ($validated['variants'] as $index => $variantData) {
$isNewVariant = empty($variantData['id']);
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
}
}
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui produk: '.$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 Produk',
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
route('admin.master.products.index', ['search' => $product->name]),
$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,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotProduct($product),
'new' => null,
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest(
$user,
'Hapus Produk',
"Pengajuan hapus produk '{$product->name}' menunggu verifikasi owner.",
route('admin.master.products.index', ['search' => $product->name]),
$product->name,
);
}
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,
'status' => OwnerVerificationStatus::PENDING,
'subject_type' => Product::class,
'subject_id' => $product->id,
'submitted_by_id' => $user->id,
'payload' => [
'old' => [
'name' => $product->name,
'is_active' => $product->is_active,
],
'new' => [
'name' => $product->name,
'is_active' => (bool) $validated['is_active'],
],
],
]);
} catch (\Throwable $e) {
Log::error('Gagal mengajukan perubahan status produk: '.$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 Produk',
"Pengajuan ubah status produk '{$product->name}' menjadi {$statusLabel} menunggu verifikasi owner.",
route('admin.master.products.index', ['search' => $product->name]),
$product->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 produk 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 produk tidak didukung.',
]),
};
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->load('media');
$newPayload = $this->payloadNew($verificationRequest);
foreach ($newPayload['variants'] ?? [] as $index => $_variant) {
$verificationRequest->clearMediaCollection($verificationRequest->variantImageCollection((int) $index));
}
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$product->update([
'is_active' => true,
]);
}
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$this->applyPayloadToProduct($product, $this->payloadNew($verificationRequest), $verificationRequest);
}
private function applyPayloadToProduct(
Product $product,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
$product->update([
'name' => $payload['name'],
'description' => $payload['description'] ?? null,
]);
if (array_key_exists('is_active', $payload)) {
$product->update([
'is_active' => (bool) $payload['is_active'],
]);
}
$product->categories()->sync($payload['category_ids'] ?? []);
$submittedVariantIds = collect($payload['variants'] ?? [])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$product->variants()
->whereNotIn('id', $submittedVariantIds)
->get()
->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
$variant->delete();
});
foreach ($payload['variants'] ?? [] as $index => $variantData) {
if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
]);
if ($verificationRequest !== null) {
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
}
continue;
}
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
]);
if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
}
}
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$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');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$newPayload = $this->payloadNew($verificationRequest);
$product->update([
'is_active' => (bool) ($newPayload['is_active'] ?? false),
]);
}
private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void
{
$ownerUrl = route('admin.master.products.index');
if ($search !== null) {
$ownerUrl = route('admin.master.products.index', ['search' => $search]);
}
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel} Menunggu Persetujuan Owner",
$body,
['owner', 'developer'],
$ownerUrl,
);
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
$body,
$user->id,
$submitterUrl,
);
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
return;
}
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
{
$product = $verificationRequest->subject;
if (! $product instanceof Product) {
throw ValidationException::withMessages([
'product' => 'Produk tidak ditemukan.',
]);
}
$oldPayload = $this->payloadOld($verificationRequest);
if ($oldPayload === []) {
return;
}
$this->applyPayloadToProduct($product, $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 snapshotProduct(Product $product): array
{
$product->load(['categories', 'variants']);
return $this->enrichPayload([
'name' => $product->name,
'description' => $product->description,
'category_ids' => $product->categories->pluck('id')->all(),
'is_active' => $product->is_active,
'variants' => $product->variants
->map(fn (ProductVariant $variant) => [
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'stock_retail' => $variant->stock_retail,
])
->all(),
]);
}
private function enrichPayload(array $data): array
{
$categoryIds = $data['category_ids'] ?? [];
$data['category_names'] = $categoryIds === []
? []
: Category::query()->whereIn('id', $categoryIds)->pluck('name')->all();
return $data;
}
private function buildPayloadFromValidated(array $validated): array
{
return [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'category_ids' => $validated['category_ids'] ?? [],
'variants' => collect($validated['variants'] ?? [])
->map(fn (array $variantData) => [
'id' => $variantData['id'] ?? null,
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'stock_retail' => $variantData['stock_retail'],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
])
->all(),
];
}
private function syncVariantImages(
ProductVariant $variant,
array $variantData,
int $index,
): void {
$this->mediaService->syncCollection(
$variant,
'images',
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: true,
errorKey: "variants.{$index}.images",
);
}
private function syncRequestVariantImages(
OwnerVerificationRequest $verificationRequest,
array $variantData,
int $index,
bool $required = true,
): void {
$collection = $verificationRequest->variantImageCollection($index);
$this->mediaService->syncCollection(
$verificationRequest,
$collection,
$variantData['images'] ?? null,
$variantData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: $required,
errorKey: "variants.{$index}.images",
);
}
private function applyVariantImageChanges(
OwnerVerificationRequest $verificationRequest,
ProductVariant $variant,
array $variantData,
int $index,
): void {
$removeIds = $variantData['remove_media_ids'] ?? [];
if ($removeIds !== []) {
$variant->getMedia('images')
->whereIn('id', $removeIds)
->each->delete();
}
$this->copyRequestVariantImages($verificationRequest, $index, $variant);
if ($variant->getMedia('images')->isEmpty()) {
throw ValidationException::withMessages([
"variants.{$index}.images" => 'Foto varian wajib diisi.',
]);
}
}
private function copyRequestVariantImages(OwnerVerificationRequest $verificationRequest, int $index, ProductVariant $variant): void
{
$verificationRequest->getMedia($verificationRequest->variantImageCollection($index))
->each(fn ($media) => $media->copy($variant, 'images'));
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'slug', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}