value => 'stock', ProductStockQuality::REJECT->value => 'reject_stock', ]; private const SELLING_PRICE_MAP = [ PriceType::DISTRIBUTOR->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 = new S3PresignedService, ) {} 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)) ->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 getFilterOptions(): array { return [ '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', ]) ->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' => collect(OrderChannel::cases())->map(fn ($c) => ['value' => $c->value, 'label' => $c->label()])->values(), 'paymentTypeOptions' => collect(PaymentType::cases())->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(), 'priceTypeOptions' => collect(PriceType::cases())->filter(fn ($p) => ! in_array($p, [PriceType::CAPITAL]))->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->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; }); } 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 applyStock(array $items, string $stockType, int $sign): void { foreach ($items as $item) { $this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType); } } private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void { $field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock'; if ($sign > 0) { ProductVariant::whereKey($variantId)->increment($field, $quantity); } else { ProductVariant::whereKey($variantId)->decrement($field, $quantity); } } private function syncPhoto(Order $order, array $data): void { if (! array_key_exists('photo_key', $data)) { return; } $currentKey = $order->getFirstMedia('photos')?->file_name; if ($data['photo_key'] === $currentKey) { return; } $order->clearMediaCollection('photos'); if (! empty($data['photo_key'])) { $this->registerMedia( model: $order, s3Key: $data['photo_key'], collectionName: 'photos', orderColumn: 1, ); } } 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); } }