value => PriceType::DISTRIBUTOR, PriceType::AGEN->value => PriceType::AGEN, PriceType::SUB_AGEN->value => PriceType::SUB_AGEN, PriceType::WHOLESALE->value => PriceType::WHOLESALE, PriceType::RETAIL->value => PriceType::RETAIL, PriceType::TIKTOK->value => PriceType::TIKTOK, PriceType::SHOPEE->value => PriceType::SHOPEE, ]; public function __construct( private S3PresignedService $s3Service, ) {} public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { $paginator = Order::query() ->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at']) ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', 'customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', 'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price,subtotal', 'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', 'orderItems.productVariant.product:id,name', ]) ->when($search, function ($q) use ($search) { $q->whereHas('orderItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%")) ->orWhere('order_number', 'like', "%{$search}%") ->orWhere('notes', 'like', "%{$search}%"); }) ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status)) ->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel)) ->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType)) ->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId)) ->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId)) ->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById)) ->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom)) ->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo)) ->orderBy($sort, $direction) ->paginate($perPage); $paginator->getCollection()->each(function (Order $order) { $order->status_label = $order->status->label(); $order->payment_type_label = $order->payment_type->label(); $order->channel_label = $order->channel->label(); $order->price_type_label = $order->price_type->label(); $order->orderItems->each(function (OrderItem $item) { if (! $item->productVariant) { return; } $media = $item->productVariant->getFirstMedia('photos'); $item->productVariant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null; }); $order->profit = $order->total_amount - $order->cogs; }); return $paginator; } public function getSummary(array $filters = []): array { $query = Order::query() ->selectRaw('COUNT(*) as total_orders') ->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal') ->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount') ->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount') ->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs') ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status)) ->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel)) ->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType)) ->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId)) ->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId)) ->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById)) ->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom)) ->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo)) ->first(); return [ 'total_orders' => $query->total_orders, 'total_subtotal' => $query->total_subtotal, 'total_discount' => $query->total_discount, 'total_amount' => $query->total_amount, 'net_total' => $query->total_amount - $query->total_cogs, ]; } public function getFilterOptions(): array { return [ 'statusOptions' => OrderStatus::toSelect(), 'channelOptions' => OrderChannel::toSelect(), 'paymentTypeOptions' => PaymentType::toSelect(), 'customers' => Customer::query() ->select(['id', 'name']) ->orderBy('name') ->get(), 'employees' => User::query() ->select('id') ->active() ->with('userProfile:id,user_id,full_name') ->orderBy('id') ->get() ->filter(fn(User $user) => $user->userProfile?->full_name) ->values() ->map(fn(User $user) => [ 'id' => $user->id, 'name' => $user->userProfile->full_name, ]), ]; } public function getForCreate(): array { return [ 'products' => Product::query() ->select(['id', 'name', 'status']) ->with([ 'productVariants:id,product_id,name,stock,reject_stock', 'productVariants.productPrices:id,variant_id,type,price', ]) ->active() ->orderBy('name') ->get() ->each(function (Product $product) { $product->productVariants->each(function (ProductVariant $variant) { $media = $variant->getFirstMedia('photos'); $variant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null; $prices = $variant->productPrices->mapWithKeys(fn($p) => [$p->type->value => $p->price]); $variant->prices = $prices; }); }), 'customers' => Customer::query() ->select(['id', 'name']) ->orderBy('name') ->get(), 'employees' => User::query() ->select('id') ->active() ->with('userProfile:id,user_id,full_name') ->orderBy('id') ->get() ->filter(fn(User $user) => $user->userProfile?->full_name) ->values(), 'channelOptions' => OrderChannel::toSelect(), 'paymentTypeOptions' => PaymentType::toSelect(), 'priceTypeOptions' => PriceType::toSelect()->filter(fn($p) => $p['value'] !== PriceType::CAPITAL->value)->values(), ]; } public function getForEdit(Order $order): array { $order->load('orderItems.productVariant.product'); $media = $order->getFirstMedia('photos'); return [ 'id' => $order->id, 'order_number' => $order->order_number, 'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value, 'channel' => $order->channel?->value ?? OrderChannel::STORE->value, 'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value, 'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value, 'customer_id' => $order->customer_id, 'marketing_id' => $order->marketing_id, 'discount' => $order->discount, 'nego_price' => $order->nego_price, 'is_completed' => $order->status === OrderStatus::COMPLETED, 'is_affiliate' => $order->is_affiliate, 'tiktok_order_id' => $order->tiktok_order_id, 'shopee_order_id' => $order->shopee_order_id, 'notes' => $order->notes, 'photo_key' => $media?->file_name, 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, 'items' => $order->orderItems->map(fn(OrderItem $item) => [ 'id' => $item->id, 'product_variant_id' => $item->product_variant_id, 'quantity' => $item->quantity, 'unit_price' => $item->unit_price, ])->values(), ]; } public function create(array $data): Order { return DB::transaction(function () use ($data) { $now = now(); $stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value; $priceType = $data['price_type'] ?? PriceType::RETAIL->value; $channel = $data['channel'] ?? OrderChannel::STORE->value; $paymentType = $data['payment_type'] ?? PaymentType::CASH->value; $subtotal = 0; $totalCost = 0; $itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost); $discount = (int) ($data['discount'] ?? 0); $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $totalAmount = $subtotal - $discount + ($negoPrice ?? 0); $order = Order::create([ 'created_by_id' => auth()->id(), 'customer_id' => $data['customer_id'] ?? null, 'marketing_id' => $data['marketing_id'] ?? null, 'order_number' => $this->generateOrderNumber(), 'channel' => $channel, 'price_type' => $priceType, 'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING, 'payment_type' => $paymentType, 'is_affiliate' => $data['is_affiliate'] ?? false, 'tiktok_order_id' => $data['tiktok_order_id'] ?? null, 'shopee_order_id' => $data['shopee_order_id'] ?? null, 'subtotal' => $subtotal, 'discount' => $discount, 'nego_price' => $negoPrice, 'total_amount' => $totalAmount, 'cogs' => $totalCost, 'notes' => $data['notes'] ?? null, ]); foreach ($itemRows as &$row) { $row['order_id'] = $order->id; } DB::table('order_items')->insert($itemRows); $this->applyStock($data['items'], $stockType, -1); if ($paymentType !== PaymentType::CASH->value) { $this->syncPhoto($order, $data); } NotificationService::notify( roles: ['Owner', 'Developer', 'Admin Toko'], title: 'Transaksi Baru', body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.', url: route('admin.manage.transactions.index'), ); return $order; }); } public function update(Order $order, array $data): Order { return DB::transaction(function () use ($order, $data) { $order->load('orderItems'); $oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value; $order->orderItems->each(function (OrderItem $item) use ($oldStockType) { $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType); }); $order->orderItems()->delete(); $now = now(); $stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value; $priceType = $data['price_type'] ?? PriceType::RETAIL->value; $subtotal = 0; $totalCost = 0; $itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost); $discount = (int) ($data['discount'] ?? 0); $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $totalAmount = $subtotal - $discount + ($negoPrice ?? 0); foreach ($itemRows as &$row) { $row['order_id'] = $order->id; } DB::table('order_items')->insert($itemRows); $order->update([ 'customer_id' => $data['customer_id'] ?? null, 'marketing_id' => $data['marketing_id'] ?? null, 'channel' => $data['channel'] ?? $order->channel->value, 'price_type' => $priceType, 'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING, 'payment_type' => $data['payment_type'] ?? $order->payment_type->value, 'is_affiliate' => $data['is_affiliate'] ?? false, 'tiktok_order_id' => $data['tiktok_order_id'] ?? null, 'shopee_order_id' => $data['shopee_order_id'] ?? null, 'subtotal' => $subtotal, 'discount' => $discount, 'nego_price' => $negoPrice, 'total_amount' => $totalAmount, 'cogs' => $totalCost, 'notes' => $data['notes'] ?? null, ]); $this->applyStock($data['items'], $stockType, -1); $paymentType = $data['payment_type'] ?? $order->payment_type->value; if ($paymentType !== PaymentType::CASH->value) { $this->syncPhoto($order, $data); } else { $order->clearMediaCollection('photos'); } return $order; }); } public function delete(Order $order): bool { return DB::transaction(function () use ($order) { $order->load('orderItems'); $stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value; $order->orderItems->each(function (OrderItem $item) use ($stockType) { $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType); }); $order->orderItems()->delete(); $order->clearMediaCollection('photos'); $order->delete(); return true; }); } public function updateStatus(Order $order, string $status): Order { $order->update(['status' => $status]); return $order; } private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array { $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value ? PriceType::REJECT : (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); $variants = ProductVariant::query() ->whereKey($variantIds) ->with('productPrices:id,variant_id,type,price') ->get(); $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $price = $variant->productPrices ->first(fn($p) => $p->type === $resolvedPriceType); return [$variant->id => $price?->price ?? 0]; }); $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) { $price = $variant->productPrices ->first(fn($p) => $p->type === PriceType::CAPITAL); return [$variant->id => $price?->price ?? 0]; }); return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) { $quantity = (int) $item['quantity']; $unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); $itemSubtotal = $unitPrice * $quantity; $subtotal += $itemSubtotal; $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); $totalCost += $capitalPrice * $quantity; return [ 'order_id' => null, 'user_id' => auth()->id(), 'product_variant_id' => $item['product_variant_id'], 'stock_quality' => $stockType, 'quantity' => $quantity, 'unit_price' => $unitPrice, 'subtotal' => $itemSubtotal, 'created_at' => $now, 'updated_at' => $now, ]; })->toArray(); } private function generateOrderNumber(): string { $prefix = 'TRX'; $date = now()->format('ymd'); $lastOrder = Order::where('order_number', 'like', "{$prefix}{$date}%") ->orderByDesc('order_number') ->first(); if ($lastOrder) { $lastSequence = (int) substr($lastOrder->order_number, -4); $sequence = $lastSequence + 1; } else { $sequence = 1; } return $prefix . $date . str_pad($sequence, 4, '0', STR_PAD_LEFT); } }