store/app/Services/Manage/RestockService.php
Yoga Pangestu 9089b32d7b
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
refactor: streamline variant and price loading in product-related services
- Removed unnecessary ordering by 'created_at' in variant loading across multiple services and models.
- Implemented global scopes for ordering product variants and raw material prices by 'name' and 'variant', respectively.
- Enhanced code readability and maintainability by simplifying query structures in ProductController, OrderService, and others.
2026-07-29 15:06:16 +07:00

809 lines
28 KiB
PHP

<?php
namespace App\Services\Manage;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Restock;
use App\Models\RestockItem;
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
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\Collection;
use Illuminate\Validation\ValidationException;
class RestockService
{
use CachesQuery, RunsInTransaction;
private const MAX_PHOTOS = 1;
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Restock::query()
->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']),
])
->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
{
$column = $this->stockColumn($stockType);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->increment($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'in',
quantity: $item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore + $item->quantity,
stockQuality: $stockType->value,
source: $item->restock,
description: $item->restock ? "Restock #{$item->restock->id}" : null,
);
}
private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void
{
$column = $this->stockColumn($stockType);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->decrement($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'out',
quantity: -$item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore - $item->quantity,
stockQuality: $stockType->value,
source: $item->restock,
description: $item->restock ? "Restock #{$item->restock->id} (batal)" : null,
);
}
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'],
$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->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->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();
}
}