store/app/Services/Manage/OrderService.php

540 lines
18 KiB
PHP

<?php
namespace App\Services\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PriceType;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class OrderService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{
$query = Order::query()
->with([
'customer:id,name',
'createdBy.profile',
'items.productVariant.product:id,name',
'items.productVariant:id,product_id,name',
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%")
->orWhereHas('customer', fn (Builder $query) => $query->where('name', '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(10)
->withQueryString()
->through(function (Order $order) use ($user) {
$actions = collect($order->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$order->setAttribute('available_actions', $actions);
$order->setAttribute('is_editable', $order->status->isEditable());
return $order;
});
}
/**
* @return list<array{value: int, label: string}>
*/
public function customerOptions(): array
{
return Customer::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Customer $customer) => [
'value' => $customer->id,
'label' => $customer->name,
])
->all();
}
/**
* @return list<array{value: string, label: string}>
*/
public function storePriceTypeOptions(): array
{
return collect(PriceType::cases())
->reject(fn (PriceType $type) => in_array($type, [PriceType::SHOPEE, PriceType::TIKTOK], true))
->map(fn (PriceType $type) => [
'value' => $type->value,
'label' => $type->label(),
])
->values()
->all();
}
/**
* @return Collection<int, Product>
*/
public function catalogItems(?Order $order = null, ?User $user = null): Collection
{
$orderVariantIds = $order
? $order->items()->pluck('product_variant_id')->all()
: ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []);
return Product::query()
->with([
'variants' => fn ($query) => $query
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
->orderBy('created_at'),
])
->where(function (Builder $query) use ($orderVariantIds): void {
$query->where('is_active', true);
if ($orderVariantIds !== []) {
$query->orWhereHas(
'variants',
fn (Builder $query) => $query->whereIn('id', $orderVariantIds),
);
}
})
->orderBy('name')
->get()
->each(function (Product $product): void {
$product->variants->each(function (ProductVariant $variant): void {
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
});
});
}
public function findForEdit(Order $order): Order
{
$order->load([
'items.productVariant.product:id,name',
'items.productVariant.prices',
'items.productVariant.media',
]);
$order->items->each(function (OrderItem $item): void {
$variant = $item->productVariant;
if ($variant) {
$item->setAttribute('product_name', $variant->product?->name);
$item->setAttribute('variant_name', $variant->name);
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
}
});
return $order;
}
/**
* @return list<array<string, mixed>>
*/
public function draftItemsForUser(User $user): array
{
return $this->draftItemsQuery($user)
->with([
'productVariant.product:id,name',
'productVariant.media',
])
->get()
->map(fn (OrderItem $item) => $this->presentDraftItem($item))
->values()
->all();
}
/**
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
public function syncDraftItem(array $validated, User $user): array
{
$priceType = PriceType::from($validated['price_type']);
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
$price = ProductPrice::query()
->where('variant_id', $variant->id)
->where('type', $priceType)
->first();
if ($price === null) {
throw ValidationException::withMessages([
'product_variant_id' => 'Harga untuk tipe harga ini belum diatur.',
]);
}
$quantity = (int) $validated['quantity'];
$unitPrice = (int) $price->price;
$subtotal = $unitPrice * $quantity;
$item = OrderItem::query()->updateOrCreate(
[
'user_id' => $user->id,
'product_variant_id' => $variant->id,
'order_id' => null,
],
[
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
],
);
$item->load([
'productVariant.product:id,name',
'productVariant.media',
]);
return $this->presentDraftItem($item);
}
public function removeDraftItem(User $user, ProductVariant $productVariant): void
{
OrderItem::query()
->whereNull('order_id')
->where('user_id', $user->id)
->where('product_variant_id', $productVariant->id)
->delete();
}
/**
* @return list<array<string, mixed>>
*/
public function resyncDraftPrices(User $user, string $priceTypeValue): array
{
$priceType = PriceType::from($priceTypeValue);
$items = $this->draftItemsQuery($user)
->with([
'productVariant.product:id,name',
'productVariant.media',
])
->get();
foreach ($items as $item) {
$price = ProductPrice::query()
->where('variant_id', $item->product_variant_id)
->where('type', $priceType)
->first();
if ($price === null) {
$item->delete();
continue;
}
$unitPrice = (int) $price->price;
$item->unit_price = $unitPrice;
$item->subtotal = $unitPrice * $item->quantity;
$item->save();
}
return $this->draftItemsForUser($user);
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated, User $user): Order
{
return DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
/** @var EloquentCollection<int, OrderItem> $draftItems */
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
->get();
if ($draftItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => 'Tambahkan minimal satu produk ke keranjang.',
]);
}
$this->applyDraftPrices($draftItems, $priceType);
$subtotal = $draftItems->sum('subtotal');
$discount = (int) ($validated['discount'] ?? 0);
$marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0);
$netAmount = max($subtotal - $discount - $marketplaceFee, 0);
$order = Order::create([
'customer_id' => $validated['customer_id'] ?? null,
'channel' => $validated['channel'],
'price_type' => $priceType,
'status' => OrderStatus::PENDING,
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'marketplace_fee' => $marketplaceFee,
'net_amount' => $netAmount,
'notes' => $validated['notes'] ?? null,
]);
foreach ($draftItems as $item) {
$item->order_id = $order->id;
$item->save();
$this->decrementStock($item);
}
return $order;
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Order $order, array $validated): void
{
if (! $order->status->isEditable()) {
throw ValidationException::withMessages([
'status' => 'Pesanan tidak dapat diubah.',
]);
}
DB::transaction(function () use ($order, $validated): void {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
$order->items()->delete();
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$lineItems = $this->buildLineItems($validated['items'], $priceType);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0);
$netAmount = max($subtotal - $discount - $marketplaceFee, 0);
$order->customer_id = $validated['customer_id'] ?? null;
$order->channel = $validated['channel'];
$order->price_type = $priceType;
$order->subtotal = $subtotal;
$order->discount = $discount;
$order->marketplace_fee = $marketplaceFee;
$order->net_amount = $netAmount;
$order->notes = $validated['notes'] ?? null;
$order->save();
foreach ($lineItems as $itemData) {
$orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem);
}
});
}
public function delete(Order $order): void
{
DB::transaction(function () use ($order): void {
$order->load('items');
if ($order->status->isEditable()) {
foreach ($order->items as $item) {
$this->incrementStock($item);
}
}
$order->items()->delete();
$order->delete();
});
}
public function transitionStatus(Order $order, OrderStatus $status): void
{
if (! $order->status->canTransitionTo($status)) {
throw ValidationException::withMessages([
'status' => 'Status pesanan tidak dapat diubah.',
]);
}
DB::transaction(function () use ($order, $status): void {
if ($status === OrderStatus::CANCELLED) {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
}
$order->status = $status;
$order->save();
});
}
/**
* @param list<array{product_variant_id: int, quantity: int|string}> $items
* @return list<array{product_variant_id: int, quantity: int, unit_price: int, subtotal: int}>
*/
private function buildLineItems(array $items, PriceType $priceType): array
{
return collect($items)
->map(function (array $itemData, int $index) use ($priceType) {
$variant = ProductVariant::query()->find($itemData['product_variant_id']);
if ($variant === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.',
]);
}
$price = ProductPrice::query()
->where('variant_id', $variant->id)
->where('type', $priceType)
->first();
if ($price === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.',
]);
}
$quantity = (int) $itemData['quantity'];
if ($quantity < 1) {
throw ValidationException::withMessages([
"items.{$index}.quantity" => 'Jumlah minimal 1 pcs.',
]);
}
$unitPrice = (int) $price->price;
$subtotal = $unitPrice * $quantity;
return [
'product_variant_id' => $variant->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
];
})
->all();
}
private function resolvePriceType(string $channel, string $priceType): PriceType
{
$channelEnum = OrderChannel::from($channel);
$priceTypeEnum = PriceType::from($priceType);
$defaultPriceType = $channelEnum->defaultPriceType();
if ($defaultPriceType !== null && $priceTypeEnum !== $defaultPriceType) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga tidak sesuai dengan channel pesanan.',
]);
}
if ($channelEnum === OrderChannel::STORE && in_array($priceTypeEnum, [PriceType::SHOPEE, PriceType::TIKTOK], true)) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga marketplace tidak dapat digunakan untuk channel toko.',
]);
}
return $priceTypeEnum;
}
/**
* @return Builder<OrderItem>
*/
private function draftItemsQuery(User $user): Builder
{
return OrderItem::query()
->whereNull('order_id')
->where('user_id', $user->id);
}
/**
* @return array<string, mixed>
*/
private function presentDraftItem(OrderItem $item): array
{
$variant = $item->productVariant;
return [
'product_variant_id' => $item->product_variant_id,
'product_name' => $variant?->product?->name ?? '',
'variant_name' => $variant?->name ?? '',
'quantity' => (string) $item->quantity,
'unit_price' => $item->unit_price,
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
];
}
/**
* @param EloquentCollection<int, OrderItem> $items
*/
private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void
{
foreach ($items as $index => $item) {
$price = ProductPrice::query()
->where('variant_id', $item->product_variant_id)
->where('type', $priceType)
->first();
if ($price === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.',
]);
}
$unitPrice = (int) $price->price;
$item->unit_price = $unitPrice;
$item->subtotal = $unitPrice * $item->quantity;
$item->save();
}
}
private function decrementStock(OrderItem $item): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->decrement('stock', $item->quantity);
}
private function incrementStock(OrderItem $item): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->increment('stock', $item->quantity);
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'net_amount', 'discount', 'subtotal', 'order_number'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}