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, PriceType::REJECT_SELLING->value => PriceType::REJECT_SELLING, ]; public function __construct( private S3PresignedService $s3Service, ) {} public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?User $user = null, ?int $highlight = null): LengthAwarePaginator { $itemsCountQuery = '(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)'; $totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)'; $productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM order_items oi JOIN product_variants pv ON pv.id = oi.product_variant_id JOIN products p ON p.id = pv.product_id WHERE oi.order_id = orders.id AND oi.deleted_at IS NULL)'; $paginator = Order::query() ->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', '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', ]) ->selectRaw("{$itemsCountQuery} as items_count") ->selectRaw("{$totalQtyQuery} as total_qty") ->selectRaw("{$productNamesQuery} as product_names") ->when($highlight, fn ($q) => $q->where('id', $highlight)) ->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id)) ->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(); $orderMedia = $order->getFirstMedia('photos'); $order->photo_url = $orderMedia ? $this->s3Service->getTemporaryUrl($orderMedia->getPath()) : null; $order->photo_conversion_url = $orderMedia ? ($orderMedia->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb')) : $this->s3Service->getTemporaryUrl($orderMedia->getPath())) : null; $order->profit = $order->total_amount - $order->cogs; }); return $paginator; } public function getForEdit(Order $order): array { $order->load([ 'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price', ]); $stockType = $order->orderItems->first()?->stock_quality?->value ?? 'good'; $orderMedia = $order->getFirstMedia('photos'); return [ 'id' => $order->id, 'order_number' => $order->order_number, 'stock_type' => $stockType, 'channel' => $order->channel->value, 'price_type' => $order->price_type->value, 'payment_type' => $order->payment_type->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, 'tiktok_order_id' => $order->tiktok_order_id, 'shopee_order_id' => $order->shopee_order_id, 'notes' => $order->notes, 'photo_key' => $orderMedia?->getCustomProperty('s3_key') ?? $orderMedia?->file_name, 'photo_url' => $orderMedia ? $this->s3Service->getTemporaryUrl($orderMedia->getPath()) : 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, ]), ]; } public function getItems(Order $order): \Illuminate\Support\Collection { return $order->orderItems() ->select(['id', 'order_id', 'product_variant_id', 'stock_quality', 'quantity', 'unit_price', 'subtotal']) ->with(['productVariant:id,product_id,name', 'productVariant.product:id,name']) ->get() ->each(function (OrderItem $item) { if (! $item->productVariant) { return; } $media = $item->productVariant->getFirstMedia('images'); $item->productVariant->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $item->productVariant->photo_conversion_url = $media ? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath())) : null; }); } 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 store(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); if ($totalAmount <= 0) { throw ValidationException::withMessages([ 'discount' => 'Total harga tidak boleh nol atau kurang.', ]); } $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, '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) { $cashTransaction = $this->creditCash( amount: $totalAmount, description: 'Pembayaran tunai: '.$order->order_number, type: CashTransactionType::TRANSACTION, ); $order->update(['cash_transaction_id' => $cashTransaction->id]); } else { $this->syncPhoto($order, $data); } NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO], title: 'Transaksi Baru', body: 'Transaksi '.$order->order_number.' sebesar Rp '.$order->formatted_total_amount.' berhasil dicatat oleh '.auth()->user()->full_name.'.', url: route('admin.manage.transactions.index', ['highlight' => $order->id]), ); $this->logCreated($order, 'Transaksi', $this->getOrderLogValues($order)); return $order; }); } public function update(Order $order, array $data): Order { $oldValues = $this->getOrderLogValues($order); $order = DB::transaction(function () use ($order, $data) { $order->load('orderItems'); $oldPaymentType = $order->payment_type; $oldStockType = $order->orderItems->first()?->stock_quality?->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); if ($totalAmount <= 0) { throw ValidationException::withMessages([ 'discount' => 'Total harga tidak boleh nol atau kurang.', ]); } foreach ($itemRows as &$row) { $row['order_id'] = $order->id; } DB::table('order_items')->insert($itemRows); $newPaymentType = $data['payment_type'] ?? $order->payment_type->value; $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' => $newPaymentType, '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); if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) { if ($order->cash_transaction_id) { $order->cashTransaction()->delete(); $order->update(['cash_transaction_id' => null]); } } elseif ($oldPaymentType !== PaymentType::CASH && $newPaymentType === PaymentType::CASH->value) { $cashTransaction = $this->creditCash( amount: $totalAmount, description: 'Pembayaran tunai: '.$order->order_number, type: CashTransactionType::TRANSACTION, ); $order->update(['cash_transaction_id' => $cashTransaction->id]); } elseif ($oldPaymentType === PaymentType::CASH && $newPaymentType === PaymentType::CASH->value && $order->cash_transaction_id) { $cashTransaction = $order->cashTransaction; $oldAmount = $cashTransaction->amount; $difference = $totalAmount - $oldAmount; if ($difference !== 0) { $cashAccount = \App\Models\CashAccount::firstOrFail(); $newBalance = $cashAccount->balance + $difference; $cashAccount->update(['balance' => $newBalance]); $cashTransaction->update([ 'amount' => $totalAmount, 'balance_after' => $newBalance, ]); } } if ($newPaymentType === PaymentType::CASH->value) { $order->clearMediaCollection('photos'); } else { $this->syncPhoto($order, $data); } return $order; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO], title: 'Transaksi Diperbarui', body: 'Transaksi '.$order->order_number.' sebesar '.$order->formatted_total_amount.' berhasil diperbarui oleh '.auth()->user()->full_name.'.', url: route('admin.manage.transactions.index', ['highlight' => $order->id]), ); $this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order)); return $order; } public function destroy(Order $order): bool { $oldValues = $this->getOrderLogValues($order); $result = DB::transaction(function () use ($order) { $order->load('orderItems'); if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) { $stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value; $order->orderItems->each(function (OrderItem $item) use ($stockType) { $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType); }); } if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) { $this->debitCash( amount: $order->total_amount, description: 'Pembatalan transaksi: '.$order->order_number, type: CashTransactionType::EXPENSE, ); } $order->orderItems()->delete(); $order->delete(); return true; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO], title: 'Transaksi Dihapus', body: 'Transaksi '.$order->order_number.' berhasil dihapus oleh '.auth()->user()->full_name.'.', url: route('admin.manage.transactions.index'), ); $this->logDeleted($order, 'Transaksi', $oldValues); return $result; } public function updateStatus(Order $order, string $status): Order { $oldValues = $this->getOrderLogValues($order); $oldStatus = $order->status->value; $order->update(['status' => $status]); if (in_array($status, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value]) && ! in_array($oldStatus, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value])) { $order->load('orderItems'); $stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value; $order->orderItems->each(function (OrderItem $item) use ($stockType) { $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType); }); if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) { $this->debitCash( amount: $order->total_amount, description: 'Pembatalan transaksi: '.$order->order_number, type: CashTransactionType::EXPENSE, ); } } $this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order)); return $order; } private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array { $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value ? PriceType::REJECT_SELLING : (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', 'product:id,name']) ->get(); $variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [ $v->id => $v->product->name.' - '.$v->name, ]); $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $price = $variant->productPrices ->first(fn ($p) => $p->type === $resolvedPriceType); return [$variant->id => $price?->price ?? 0]; }); $capitalPriceType = $stockType === ProductStockQuality::REJECT->value ? PriceType::REJECT_CAPITAL : PriceType::CAPITAL; $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) use ($capitalPriceType) { $price = $variant->productPrices ->first(fn ($p) => $p->type === $capitalPriceType); return [$variant->id => $price?->price ?? 0]; }); return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $stockType, &$subtotal, &$totalCost) { $quantity = (int) $item['quantity']; $unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); $label = $variantLabels[$item['product_variant_id']] ?? ' Produk'; if ($unitPrice <= 0) { throw ValidationException::withMessages([ 'items' => 'Harga untuk "'.$label.'" belum diatur.', ]); } $itemSubtotal = $unitPrice * $quantity; $subtotal += $itemSubtotal; $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); if ($capitalPrice <= 0) { throw ValidationException::withMessages([ 'items' => 'Harga modal untuk "'.$label.'" belum diatur.', ]); } $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::withTrashed() ->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); } private function isMarketingUser(User $user): bool { return $user->hasAnyRole([ Role::MARKETING_OFFLINE->value, Role::MARKETING_ONLINE->value, ]); } private function getOrderLogValues(Order $order): array { $order->load(['customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', 'orderItems.productVariant.product:id,name']); return [ 'No. Transaksi' => $order->order_number, 'Customer' => $order->customer?->name ?? '-', 'Marketing' => $order->marketing?->userProfile?->full_name ?? '-', 'Channel' => $order->channel?->label(), 'Tipe Harga' => $order->price_type?->label(), 'Status' => $order->status?->label(), 'Tipe Pembayaran' => $order->payment_type?->label(), 'Subtotal' => $this->formatCurrency($order->subtotal), 'Diskon' => $this->formatCurrency($order->discount), 'Harga Nego' => $order->nego_price ? $this->formatCurrency($order->nego_price) : null, 'Total' => $this->formatCurrency($order->total_amount), 'Catatan' => $order->notes, 'Item' => $order->orderItems->map(fn ($item) => [ 'Nama' => $item->productVariant?->product?->name.' - '.$item->productVariant?->name ?? '-', 'Qty' => $item->quantity, 'Harga' => $this->formatCurrency($item->unit_price), 'Subtotal' => $this->formatCurrency($item->subtotal), ])->toArray(), ]; } }