854 lines
31 KiB
PHP
854 lines
31 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Manage;
|
|
|
|
use App\Enums\OrderChannel;
|
|
use App\Enums\OrderStatus;
|
|
use App\Enums\PaymentType;
|
|
use App\Enums\Permission;
|
|
use App\Enums\PriceType;
|
|
use App\Enums\ProductStockQuality;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Product;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\User;
|
|
use App\Services\Finance\CashService;
|
|
use App\Services\System\PushNotificationService;
|
|
use App\Services\System\Setting\MarketplaceService;
|
|
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
|
|
{
|
|
public function __construct(
|
|
private readonly MarketplaceService $marketplaceService,
|
|
private readonly PushNotificationService $pushNotificationService,
|
|
private readonly CashService $cashService,
|
|
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
|
) {}
|
|
|
|
public function isEditable(OrderStatus $status): bool
|
|
{
|
|
return in_array($status, config('order-status.editable'), true);
|
|
}
|
|
|
|
public function canTransitionTo(OrderStatus $from, OrderStatus $to): bool
|
|
{
|
|
$transitions = config('order-status.transitions', []);
|
|
|
|
return in_array($to, $transitions[$from->value] ?? [], true);
|
|
}
|
|
|
|
public function transitionPermission(OrderStatus $status): Permission
|
|
{
|
|
$permissions = config('order-status.permissions', []);
|
|
|
|
return $permissions[$status->value]
|
|
?? throw new \InvalidArgumentException('Status tidak mendukung transisi.');
|
|
}
|
|
|
|
/**
|
|
* @return list<array{status: string, label: string, destructive: bool, permission: string, icon_only: bool}>
|
|
*/
|
|
public function availableActions(OrderStatus $status): array
|
|
{
|
|
return config('order-status.actions.'.$status->value, []);
|
|
}
|
|
|
|
public function defaultPriceType(OrderChannel $channel): ?PriceType
|
|
{
|
|
return match ($channel) {
|
|
OrderChannel::STORE => null,
|
|
OrderChannel::SHOPEE => PriceType::SHOPEE,
|
|
OrderChannel::TIKTOK => PriceType::TIKTOK,
|
|
};
|
|
}
|
|
|
|
public function stockColumn(ProductStockQuality $quality): string
|
|
{
|
|
return match ($quality) {
|
|
ProductStockQuality::GOOD => 'stock',
|
|
ProductStockQuality::REJECT => 'reject_stock',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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($user->hasRole('marketing'), fn (Builder $query) => $query->where('marketing_id', $user->id))
|
|
->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('tiktok_order_id', 'like', "%{$search}%")
|
|
->orWhere('shopee_order_id', '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($this->availableActions($order->status))
|
|
->filter(fn (array $action) => $user->can($action['permission']))
|
|
->values()
|
|
->all();
|
|
|
|
$order->setAttribute('available_actions', $actions);
|
|
$order->setAttribute('is_editable', $this->isEditable($order->status));
|
|
|
|
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: int, label: string}>
|
|
*/
|
|
public function marketingOptions(): array
|
|
{
|
|
return User::query()
|
|
->active()
|
|
->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->where('name', 'marketing'))
|
|
->with('profile:user_id,full_name')
|
|
->orderBy('username')
|
|
->get(['id', 'username'])
|
|
->map(fn (User $user) => [
|
|
'value' => $user->id,
|
|
'label' => $user->profile?->full_name ?? $user->username,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return list<array{value: string, label: string}>
|
|
*/
|
|
public function storePriceTypeOptions(): array
|
|
{
|
|
return collect(PriceType::cases())
|
|
->reject(fn (PriceType $type) => in_array($type, [PriceType::HARGA_MODAL, 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() : []);
|
|
|
|
$products = Product::query()
|
|
->with([
|
|
'variants' => fn ($query) => $query
|
|
->with('media')
|
|
->orderBy('created_at'),
|
|
])
|
|
->where(function (Builder $query) use ($orderVariantIds): void {
|
|
$query->active();
|
|
|
|
if ($orderVariantIds !== []) {
|
|
$query->orWhereHas(
|
|
'variants',
|
|
fn (Builder $query) => $query->whereIn('id', $orderVariantIds),
|
|
);
|
|
}
|
|
})
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$allVariantIds = $products
|
|
->flatMap(fn (Product $product) => $product->variants->pluck('id'))
|
|
->all();
|
|
|
|
$allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($allVariantIds);
|
|
|
|
return $products->each(function (Product $product) use ($allPricesByVariant): void {
|
|
$product->variants->each(function (ProductVariant $variant) use ($allPricesByVariant): void {
|
|
$variant->setAttribute(
|
|
'images',
|
|
MediaPresenter::collection($variant, 'images'),
|
|
);
|
|
$variant->setAttribute(
|
|
'prices',
|
|
$this->presentVariantPricesFromCollection(
|
|
$variant->id,
|
|
$allPricesByVariant,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
public function findForEdit(Order $order): Order
|
|
{
|
|
$order->load([
|
|
'items.productVariant.product:id,name',
|
|
'items.productVariant.media',
|
|
]);
|
|
|
|
$variantIds = $order->items
|
|
->filter(fn (OrderItem $item) => $item->productVariant !== null)
|
|
->map(fn (OrderItem $item) => $item->productVariant->id)
|
|
->all();
|
|
|
|
$allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($variantIds);
|
|
|
|
$order->items->each(function (OrderItem $item) use ($allPricesByVariant): 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'),
|
|
);
|
|
$variant->setAttribute(
|
|
'prices',
|
|
$this->presentVariantPricesFromCollection(
|
|
$variant->id,
|
|
$allPricesByVariant,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
return $order;
|
|
}
|
|
|
|
public function findForShow(Order $order): Order
|
|
{
|
|
$order->load([
|
|
'customer:id,name,phone_number,address',
|
|
'createdBy.profile',
|
|
'marketing.profile',
|
|
'items.productVariant.product:id,name',
|
|
'items.productVariant.media',
|
|
'cashTransaction:id,amount,description,created_at',
|
|
]);
|
|
|
|
$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'),
|
|
);
|
|
}
|
|
});
|
|
|
|
$availableActions = collect($this->availableActions($order->status))
|
|
->values()
|
|
->all();
|
|
|
|
$order->setAttribute('available_actions', $availableActions);
|
|
$order->setAttribute('is_editable', $this->isEditable($order->status));
|
|
|
|
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']);
|
|
$stockQuality = ProductStockQuality::from($validated['stock_quality']);
|
|
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
|
|
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
|
|
|
|
$quantity = (int) $validated['quantity'];
|
|
$availableStock = $this->availableStock($variant, $stockQuality);
|
|
|
|
if ($quantity > $availableStock) {
|
|
throw ValidationException::withMessages([
|
|
'quantity' => "Stok {$stockQuality->label()} tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
|
|
]);
|
|
}
|
|
|
|
$subtotal = $unitPrice * $quantity;
|
|
|
|
$item = OrderItem::query()->updateOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'product_variant_id' => $variant->id,
|
|
'stock_quality' => $stockQuality->value,
|
|
'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, ProductStockQuality $stockQuality): void
|
|
{
|
|
OrderItem::query()
|
|
->whereNull('order_id')
|
|
->where('user_id', $user->id)
|
|
->where('product_variant_id', $productVariant->id)
|
|
->where('stock_quality', $stockQuality->value)
|
|
->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 = $this->cuttingResultPriceResolver->resolve($item->product_variant_id, $priceType);
|
|
|
|
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
|
|
{
|
|
$order = 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);
|
|
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
|
$totalAmount = max($subtotal - $discount + $shippingCost, 0);
|
|
$channel = OrderChannel::from($validated['channel']);
|
|
|
|
$order = Order::create([
|
|
'customer_id' => $validated['customer_id'] ?? null,
|
|
'marketing_id' => $validated['marketing_id'] ?? null,
|
|
'channel' => $channel,
|
|
'price_type' => $priceType,
|
|
'payment_type' => PaymentType::from($validated['payment_type']),
|
|
'is_affiliate' => $validated['is_affiliate'] ?? false,
|
|
'status' => OrderStatus::PENDING,
|
|
'tiktok_order_id' => $validated['tiktok_order_id'] ?? null,
|
|
'shopee_order_id' => $validated['shopee_order_id'] ?? null,
|
|
'created_by_id' => $user->id,
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'shipping_cost' => $shippingCost,
|
|
'marketplace_settings_snapshot' => $this->marketplaceService->buildOrderSnapshot(
|
|
$channel,
|
|
$totalAmount,
|
|
$this->lineItemsForSnapshot($draftItems),
|
|
$validated['is_affiliate'] ?? false,
|
|
),
|
|
'total_amount' => $totalAmount,
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
if ($order->payment_type === PaymentType::CASH) {
|
|
$cashTransaction = $this->cashService->recordIncoming(
|
|
$order,
|
|
$totalAmount,
|
|
"Pembayaran pesanan {$order->order_number}",
|
|
$user,
|
|
);
|
|
|
|
$order->cash_transaction_id = $cashTransaction->id;
|
|
$order->save();
|
|
}
|
|
|
|
foreach ($draftItems as $item) {
|
|
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($item->product_variant_id);
|
|
if ($variant === null) {
|
|
throw ValidationException::withMessages([
|
|
'items' => 'Varian produk tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
|
|
$availableStock = $this->availableStock($variant, $stockQuality);
|
|
|
|
if ($availableStock < $item->quantity) {
|
|
throw ValidationException::withMessages([
|
|
'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
|
|
]);
|
|
}
|
|
$item->order_id = $order->id;
|
|
$item->save();
|
|
$this->decrementStock($item);
|
|
}
|
|
|
|
return $order;
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'📦 Pesanan Baru',
|
|
"Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/orders',
|
|
);
|
|
|
|
return $order;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
public function update(Order $order, array $validated): void
|
|
{
|
|
if (! $this->isEditable($order->status)) {
|
|
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);
|
|
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
|
|
$totalAmount = max($subtotal - $discount + $shippingCost, 0);
|
|
$channel = OrderChannel::from($validated['channel']);
|
|
|
|
$order->customer_id = $validated['customer_id'] ?? null;
|
|
$order->marketing_id = $validated['marketing_id'] ?? null;
|
|
$order->channel = $channel;
|
|
$order->price_type = $priceType;
|
|
$order->payment_type = PaymentType::from($validated['payment_type']);
|
|
$order->is_affiliate = $validated['is_affiliate'] ?? false;
|
|
$order->tiktok_order_id = $validated['tiktok_order_id'] ?? null;
|
|
$order->shopee_order_id = $validated['shopee_order_id'] ?? null;
|
|
$order->subtotal = $subtotal;
|
|
$order->discount = $discount;
|
|
$order->shipping_cost = $shippingCost;
|
|
$order->marketplace_settings_snapshot = $this->marketplaceService->buildOrderSnapshot(
|
|
$channel,
|
|
$totalAmount,
|
|
$this->lineItemsForSnapshot($lineItems),
|
|
$validated['is_affiliate'] ?? false,
|
|
);
|
|
$order->total_amount = $totalAmount;
|
|
$order->notes = $validated['notes'] ?? null;
|
|
$order->save();
|
|
|
|
foreach ($lineItems as $itemData) {
|
|
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($itemData['product_variant_id']);
|
|
if ($variant === null) {
|
|
throw ValidationException::withMessages([
|
|
'items' => 'Varian produk tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$stockQuality = ProductStockQuality::from($itemData['stock_quality']);
|
|
$availableStock = $this->availableStock($variant, $stockQuality);
|
|
|
|
if ($availableStock < $itemData['quantity']) {
|
|
throw ValidationException::withMessages([
|
|
'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
|
|
]);
|
|
}
|
|
$orderItem = $order->items()->create($itemData);
|
|
$this->decrementStock($orderItem);
|
|
}
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'✏️ Pesanan Diperbarui',
|
|
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/orders',
|
|
);
|
|
}
|
|
|
|
public function delete(Order $order): void
|
|
{
|
|
$orderNumber = $order->order_number;
|
|
$totalAmount = $order->total_amount;
|
|
|
|
DB::transaction(function () use ($order): void {
|
|
$order->load('items');
|
|
|
|
if ($this->isEditable($order->status)) {
|
|
foreach ($order->items as $item) {
|
|
$this->incrementStock($item);
|
|
}
|
|
}
|
|
|
|
if ($order->cashTransaction) {
|
|
$this->cashService->deleteReferencedTransaction($order->cashTransaction);
|
|
}
|
|
|
|
$order->items()->delete();
|
|
$order->delete();
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'🗑️ Pesanan Dihapus',
|
|
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/orders',
|
|
);
|
|
}
|
|
|
|
public function transitionStatus(Order $order, OrderStatus $status): void
|
|
{
|
|
if (! $this->canTransitionTo($order->status, $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);
|
|
}
|
|
|
|
if ($order->cashTransaction) {
|
|
$this->cashService->deleteReferencedTransaction($order->cashTransaction);
|
|
$order->cash_transaction_id = null;
|
|
}
|
|
}
|
|
|
|
$order->status = $status;
|
|
$order->save();
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'📦 Status Pesanan Diubah',
|
|
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/orders',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{product_variant_id: int, quantity: int|string, stock_quality?: string}> $items
|
|
* @return list<array{product_variant_id: int, stock_quality: string, 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.',
|
|
]);
|
|
}
|
|
|
|
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType, "items.{$index}.product_variant_id");
|
|
|
|
$quantity = (int) $itemData['quantity'];
|
|
$stockQuality = ProductStockQuality::from($itemData['stock_quality'] ?? ProductStockQuality::GOOD->value);
|
|
|
|
if ($quantity < 1) {
|
|
throw ValidationException::withMessages([
|
|
"items.{$index}.quantity" => 'Jumlah minimal 1 pcs.',
|
|
]);
|
|
}
|
|
|
|
$subtotal = $unitPrice * $quantity;
|
|
|
|
return [
|
|
'product_variant_id' => $variant->id,
|
|
'stock_quality' => $stockQuality->value,
|
|
'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 = $this->defaultPriceType($channelEnum);
|
|
|
|
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 ?? '',
|
|
'stock_quality' => ($item->stock_quality ?? ProductStockQuality::GOOD)->value,
|
|
'stock_quality_label' => ($item->stock_quality ?? ProductStockQuality::GOOD)->label(),
|
|
'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) {
|
|
$unitPrice = $this->resolveUnitPrice(
|
|
$item->product_variant_id,
|
|
$priceType,
|
|
"items.{$index}.product_variant_id",
|
|
);
|
|
|
|
$item->unit_price = $unitPrice;
|
|
$item->subtotal = $unitPrice * $item->quantity;
|
|
$item->save();
|
|
}
|
|
}
|
|
|
|
private function decrementStock(OrderItem $item): void
|
|
{
|
|
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
|
|
|
|
ProductVariant::query()
|
|
->whereKey($item->product_variant_id)
|
|
->decrement($this->stockColumn($stockQuality), $item->quantity);
|
|
}
|
|
|
|
private function incrementStock(OrderItem $item): void
|
|
{
|
|
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
|
|
|
|
ProductVariant::query()
|
|
->whereKey($item->product_variant_id)
|
|
->increment($this->stockColumn($stockQuality), $item->quantity);
|
|
}
|
|
|
|
private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int
|
|
{
|
|
return (int) $variant->{$this->stockColumn($stockQuality)};
|
|
}
|
|
|
|
/**
|
|
* @param EloquentCollection<int, OrderItem>|list<array{quantity: int, subtotal: int}> $items
|
|
* @return list<array{quantity: int, subtotal: int}>
|
|
*/
|
|
private function lineItemsForSnapshot(EloquentCollection|array $items): array
|
|
{
|
|
return collect($items)
|
|
->map(fn (OrderItem|array $item) => [
|
|
'quantity' => (int) (is_array($item) ? $item['quantity'] : $item->quantity),
|
|
'subtotal' => (int) (is_array($item) ? $item['subtotal'] : $item->subtotal),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function applySorting(Builder $query, string $sort, string $direction): void
|
|
{
|
|
if (in_array($sort, ['created_at', 'total_amount', 'discount', 'subtotal', 'order_number'], true)) {
|
|
$query->orderBy($sort, $direction);
|
|
|
|
return;
|
|
}
|
|
|
|
$query->latest();
|
|
}
|
|
|
|
private function resolveUnitPrice(int $variantId, PriceType $priceType, ?string $errorKey = null): int
|
|
{
|
|
$price = $this->cuttingResultPriceResolver->resolve($variantId, $priceType);
|
|
|
|
if ($price === null) {
|
|
throw ValidationException::withMessages([
|
|
$errorKey ?? 'product_variant_id' => 'Harga untuk tipe harga ini belum diatur. Verifikasi cutting terlebih dahulu.',
|
|
]);
|
|
}
|
|
|
|
return (int) $price->price;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* type: string,
|
|
* type_label: string,
|
|
* price: int,
|
|
* price_formatted: string,
|
|
* price_input: string,
|
|
* cost_per_unit: int,
|
|
* cost_per_unit_formatted: string,
|
|
* }>
|
|
*/
|
|
private function presentVariantPrices(int $variantId): array
|
|
{
|
|
return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId))
|
|
->map(fn ($price) => [
|
|
'type' => $price->price_type->value,
|
|
'type_label' => $price->price_type->label(),
|
|
'price' => (int) $price->price,
|
|
'price_formatted' => $price->price_formatted,
|
|
'price_input' => (string) $price->price,
|
|
'cost_per_unit' => (int) $price->cost_per_unit,
|
|
'cost_per_unit_formatted' => $price->cost_per_unit_formatted,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function presentVariantPricesFromCollection(int $variantId, Collection $allPricesByVariant): array
|
|
{
|
|
return $allPricesByVariant
|
|
->get($variantId, collect())
|
|
->map(fn ($price) => [
|
|
'type' => $price->price_type->value,
|
|
'type_label' => $price->price_type->label(),
|
|
'price' => (int) $price->price,
|
|
'price_formatted' => $price->price_formatted,
|
|
'price_input' => (string) $price->price,
|
|
'cost_per_unit' => (int) $price->cost_per_unit,
|
|
'cost_per_unit_formatted' => $price->cost_per_unit_formatted,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|