isEditable(); } public function canTransitionTo(OrderStatus $from, OrderStatus $to): bool { return $from->canTransitionTo($to); } public function transitionPermission(OrderStatus $status): Permission { return $status->transitionPermission(); } /** * @return list */ public function availableActions(OrderStatus $status): array { return $status->availableActions(); } 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', ProductStockQuality::ECER => 'stock_ecer', }; } /** * @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->hasAnyRole(['marketing-offline', 'marketing-online']), fn (Builder $query) => $query->where('marketing_id', $user->id)) ->when($user->hasRole('cashier'), fn (Builder $query) => $query->where('created_by_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 */ 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 (Builder $roleQuery) => $roleQuery->whereIn('name', ['marketing-offline', 'marketing-online'])) ->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::HARGA_MODAL, 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() : []); $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', 'media', ]); $order->setAttribute( 'photos', MediaPresenter::first($order, 'photos'), ); $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', 'media', ]); $order->setAttribute( 'photos', MediaPresenter::first($order, 'photos'), ); $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> */ 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 { // Force ecer price type and stock quality for cashier role $priceType = $user->hasRole('cashier') ? PriceType::ECER : PriceType::from($validated['price_type']); $stockQuality = $user->hasRole('cashier') ? ProductStockQuality::ECER : 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> */ public function resyncDraftPrices(User $user, string $priceTypeValue): array { // Force ecer price type for cashier role $priceType = $user->hasRole('cashier') ? PriceType::ECER : 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 $validated */ public function create(array $validated, User $user): Order { try { // Force cashier settings if ($user->hasRole('cashier')) { $validated['channel'] = 'store'; $validated['price_type'] = 'ecer'; $validated['payment_type'] = 'cash'; unset($validated['customer_id']); } $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); $negoPrice = isset($validated['nego_price']) && $validated['nego_price'] !== '' ? (int) $validated['nego_price'] : null; $totalAmount = $negoPrice !== null ? max($negoPrice, 0) : max($subtotal - $discount, 0); $channel = OrderChannel::from($validated['channel']); $status = isset($validated['status']) ? OrderStatus::from($validated['status']) : OrderStatus::PENDING; $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' => $status, '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, 'nego_price' => $negoPrice, '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(); } $this->syncPhotos($order, $validated); 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; }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal membuat pesanan: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToRoles( '📦 Pesanan Baru', "Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.", ['owner', 'developer'], route('admin.manage.orders.index'), ); return $order; } /** * @param array $validated */ public function update(Order $order, array $validated): void { if (! $this->isEditable($order->status)) { throw ValidationException::withMessages([ 'status' => 'Pesanan tidak dapat diubah.', ]); } try { 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); $negoPrice = isset($validated['nego_price']) && $validated['nego_price'] !== '' ? (int) $validated['nego_price'] : null; $totalAmount = $negoPrice !== null ? max($negoPrice, 0) : 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->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->nego_price = $negoPrice; $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; if (isset($validated['status'])) { $newStatus = OrderStatus::from($validated['status']); if ($order->status->canTransitionTo($newStatus) || $newStatus === $order->status) { $order->status = $newStatus; } } $order->save(); $this->syncPhotos($order, $validated); 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); } }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal memperbarui pesanan: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToRoles( '✏️ Pesanan Diperbarui', "Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.", ['owner', 'developer'], route('admin.manage.orders.index'), ); } public function delete(Order $order): void { $orderNumber = $order->order_number; $totalAmount = $order->total_amount; try { 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(); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal menghapus pesanan: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToRoles( '🗑️ Pesanan Dihapus', "Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.", ['owner', 'developer'], route('admin.manage.orders.index'), ); } public function transitionStatus(Order $order, OrderStatus $status): void { if (! $this->canTransitionTo($order->status, $status)) { throw ValidationException::withMessages([ 'status' => 'Status pesanan tidak dapat diubah.', ]); } try { 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(); }); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { Log::error('Gagal mengubah status pesanan: '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); throw ValidationException::withMessages([ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', ]); } $this->pushNotificationService->sendToRoles( '📦 Status Pesanan Diubah', "Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.", ['owner', 'developer'], route('admin.manage.orders.index'), ); } /** * @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.', ]); } $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 */ 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 ?? '', '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 $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 array $validated */ private function syncPhotos(Order $order, array $validated): void { $paymentType = $validated['payment_type'] ?? null; $requiresPhoto = in_array($paymentType, ['qris', 'transfer'], true); $this->mediaService->syncCollection( $order, 'photos', $validated['photos'] ?? null, $validated['remove_media_ids'] ?? null, self::MAX_PHOTOS, required: $requiresPhoto, errorKey: 'photos', ); } /** * @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(); } 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 */ 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(); } }