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('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 */ 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 */ public function marketingOptions(): array { return User::query() ->active() ->whereHas('roles', fn ($q) => $q->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 */ 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 */ 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> */ 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 $validated * @return array */ 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']; if ($quantity > $variant->stock) { throw ValidationException::withMessages([ 'quantity' => "Stok tidak mencukupi. Stok saat ini: {$variant->stock} pcs.", ]); } $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> */ 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 $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 $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); $totalAmount = max($subtotal - $discount, 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, 'status' => OrderStatus::PENDING, 'created_by_id' => $user->id, 'subtotal' => $subtotal, 'discount' => $discount, 'marketplace_settings_snapshot' => $this->marketplaceService->buildOrderSnapshot( $channel, $totalAmount, $this->lineItemsForSnapshot($draftItems), ), 'total_amount' => $totalAmount, 'notes' => $validated['notes'] ?? null, ]); 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.', ]); } if ($variant->stock < $item->quantity) { throw ValidationException::withMessages([ 'items' => "Stok produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$variant->stock} 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 $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); $totalAmount = max($subtotal - $discount, 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->subtotal = $subtotal; $order->discount = $discount; $order->marketplace_settings_snapshot = $this->marketplaceService->buildOrderSnapshot( $channel, $totalAmount, $this->lineItemsForSnapshot($lineItems), ); $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.', ]); } if ($variant->stock < $itemData['quantity']) { throw ValidationException::withMessages([ 'items' => "Stok produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$variant->stock} 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 ($order->status->isEditable()) { foreach ($order->items as $item) { $this->incrementStock($item); } } $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 (! $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(); }); $this->pushNotificationService->sendToRoles( '📦 Status Pesanan Diubah', "Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.", ['owner', 'developer'], '/admin/manage/orders', ); } /** * @param list $items * @return list */ 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 */ private function draftItemsQuery(User $user): Builder { return OrderItem::query() ->whereNull('order_id') ->where('user_id', $user->id); } /** * @return array */ 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 $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); } /** * @param EloquentCollection|list $items * @return list */ 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(); } }