'طلا', 2 => 'نقره', 3 => 'مس', ]; public function __construct( private readonly ConfigService $configService, private readonly WalletBalanceService $walletBalanceService ) { } public function buildForCustomer(int $customerId, int $ownerType): array { $catalog = Product::query() ->where('active', 1) ->with('file') ->get(); $userWallets = Wallet::query() ->with(['product.file']) ->where('owner', $customerId) ->where('owner_type', $ownerType) ->get(); $mainWallet = $this->resolveMainWallet($userWallets); $metalIds = $this->catalogMetalIds($catalog); $weightUnit = Controller::tradeWeightUnit(); $hasCountBased = $this->catalogHasCountBased($catalog); $hasWeightBased = $this->catalogHasWeightBased($catalog); $catalogById = $catalog->keyBy('id'); // محصولات متصل به کیف اصلی (main_wallet_products) — موجودی‌شان = gold کیف اصلی $mainWalletProductIds = $this->configService->mainWalletProductIds(); $metalsWithProductWallet = $this->metalIdsWithProductWallet($userWallets, $catalogById, $mainWalletProductIds, $mainWallet); $inventoryCards = array_merge( $this->buildMeltedInventoryCards($catalog, $catalogById, $mainWallet, $metalIds, $metalsWithProductWallet, $mainWalletProductIds), $this->buildInventoryCards($userWallets, $catalogById, $mainWalletProductIds, $mainWallet), ); $metalTotals = $this->buildMetalTotals($userWallets, $mainWallet, $metalIds, $weightUnit, $catalogById, $metalsWithProductWallet, $mainWalletProductIds); $cashWallet = $this->walletBalanceService->mainWalletCash($userWallets); $assetsRialValue = $this->sumAssetsRialValue( $metalTotals, $userWallets, $catalogById, $mainWalletProductIds ); $totalRialValue = $cashWallet + $assetsRialValue; $portfolio = $this->buildPortfolioInsight( $customerId, $metalTotals, $cashWallet, $assetsRialValue, $totalRialValue ); return [ 'meta' => [ 'metal_ids' => $metalIds, 'metal_count' => count($metalIds), 'has_count_based_products' => $hasCountBased, 'has_weight_based_products' => $hasWeightBased, 'weight_unit' => $weightUnit, 'single_metal' => count($metalIds) === 1, ], 'cash_wallet' => round($cashWallet, 0), 'inventory_cards' => $inventoryCards, 'count_products' => $hasCountBased ? array_values(array_filter($inventoryCards, fn (array $row) => ($row['display_type'] ?? '') === 'count')) : [], 'metal_totals' => $metalTotals, 'assets_rial_value' => round($assetsRialValue, 0), 'total_rial_value' => round($totalRialValue, 0), 'profit_loss' => $portfolio['unrealized_profit_rial'], 'portfolio' => $portfolio, ]; } /** * خلاصه دارایی برای داشبورد — ارزش کل، تفکیک نقد/فلز، سود/زیان تحقق‌نیافته طلا. * * @return array{ * total_rial_value: float, * cash_rial: float, * metal_rial_value: float, * gold_weight: float, * gold_rial_value: float, * average_buy_price: float|null, * cost_basis_rial: float|null, * unrealized_profit_rial: float|null, * unrealized_profit_percent: float|null, * live_gold_price: float * } */ private function buildPortfolioInsight( int $customerId, array $metalTotals, float $cashWallet, float $assetsRialValue, float $totalRialValue ): array { $goldMetal = collect($metalTotals)->first(fn (array $row) => (int) ($row['metal_id'] ?? 0) === 1); $goldWeight = (float) ($goldMetal['total_weight'] ?? 0); $liveGoldPrice = $this->resolveLivePricePerUnit(1); $goldRialValue = $goldWeight > 0 && $liveGoldPrice > 0 ? round($goldWeight * $liveGoldPrice, 0) : 0.0; $averageBuyPrice = null; $costBasisRial = null; $unrealizedProfitRial = null; $unrealizedProfitPercent = null; if ($goldWeight > 0) { $buyOrders = Order::query() ->where('customer_id', $customerId) ->where('direction', 1) ->where('sts', 2) ->where('amount', '>', 0) ->get(['amount', 'price']); $totalBought = (float) $buyOrders->sum('amount'); $weightedSum = (float) $buyOrders->sum(fn (Order $order) => (float) $order->price * (float) $order->amount); if ($totalBought > 0 && $weightedSum > 0) { $averageBuyPrice = round($weightedSum / $totalBought, 0); $costBasisRial = round($averageBuyPrice * $goldWeight, 0); $unrealizedProfitRial = round($goldRialValue - $costBasisRial, 0); if ($costBasisRial > 0) { $unrealizedProfitPercent = round(($unrealizedProfitRial / $costBasisRial) * 100, 2); } } } return [ 'total_rial_value' => round($totalRialValue, 0), 'cash_rial' => round($cashWallet, 0), 'metal_rial_value' => round($assetsRialValue, 0), 'gold_weight' => round($goldWeight, 3), 'gold_rial_value' => $goldRialValue, 'average_buy_price' => $averageBuyPrice, 'cost_basis_rial' => $costBasisRial, 'unrealized_profit_rial' => $unrealizedProfitRial, 'unrealized_profit_percent' => $unrealizedProfitPercent, 'live_gold_price' => round($liveGoldPrice, 0), ]; } private function catalogMetalIds(Collection $catalog): array { return $catalog ->pluck('metal_id') ->filter(fn ($id) => (int) $id > 0) ->map(fn ($id) => (int) $id) ->unique() ->sort() ->values() ->all(); } private function catalogHasCountBased(Collection $catalog): bool { return $catalog->contains(fn (Product $product) => $this->isCountBasedProduct($product)); } private function catalogHasWeightBased(Collection $catalog): bool { return $catalog->contains(fn (Product $product) => !$this->isCountBasedProduct($product)); } private function buildMeltedInventoryCards( Collection $catalog, Collection $catalogById, ?Wallet $mainWallet, array $metalIds, array $metalsWithProductWallet = [], array $mainWalletProductIds = [] ): array { $cards = []; $mainWalletGrams = Controller::weightToGrams((float) ($mainWallet?->gold ?? 0)); $assignedMetalIds = []; $mainProductId = $this->configService->mainProductId(); // حالت صریح: محصولات متصل به کیف اصلی — یک کارت واحد (نماینده = اولین محصول لیست) if ($mainWalletProductIds !== []) { $representative = $this->resolveMainWalletRepresentative($catalogById, $mainWalletProductIds); if (!$representative) { return []; } $mainWalletWeight = (float) ($mainWallet?->gold ?? 0); if ($mainWalletWeight <= 0) { return []; } $productPayload = $this->productPayload($representative); return [[ 'product_id' => (int) $representative->id, 'title' => (string) $representative->title, 'group_type' => $this->meltedGroupTypeLabel($representative), 'metal_id' => (int) ($representative->metal_id ?? 0), 'quantity' => round($mainWalletWeight, 3), 'unit' => Controller::productQuantityUnit($productPayload), 'display_type' => 'weight', 'unit_weight' => $this->productUnitWeightLabel($representative), 'show_in_assets' => $this->shouldShowInAssets($representative), 'is_melted' => true, 'is_main_wallet' => true, 'file' => $this->filePayload($representative), ]]; } foreach ($metalIds as $metalId) { if ( $metalId <= 0 || in_array($metalId, $assignedMetalIds, true) || in_array((int) $metalId, $metalsWithProductWallet, true) ) { continue; } $meltedGrams = $this->meltedGramsForMetalTotal($mainWalletGrams, (int) $metalId, $metalIds); if ($meltedGrams <= 0) { continue; } $catalogProduct = $this->resolveDisplayProductForMetal($catalog, (int) $metalId, $mainProductId); if (!$catalogProduct) { continue; } $assignedMetalIds[] = (int) $metalId; $productPayload = $this->productPayload($catalogProduct); $cards[] = [ 'product_id' => (int) $catalogProduct->id, 'title' => (string) $catalogProduct->title, 'group_type' => $this->meltedGroupTypeLabel($catalogProduct), 'metal_id' => (int) $metalId, 'quantity' => round(Controller::gramsToTradeUnit($meltedGrams), 3), 'unit' => Controller::productQuantityUnit($productPayload), 'display_type' => 'weight', 'unit_weight' => $this->productUnitWeightLabel($catalogProduct), 'show_in_assets' => $this->shouldShowInAssets($catalogProduct), 'is_melted' => true, 'file' => $this->filePayload($catalogProduct), ]; } return $cards; } private function resolveMainWallet(Collection $userWallets): ?Wallet { return $userWallets->first( fn (Wallet $w) => $w->fund_id === null && $w->product_id === null ); } /** * کیف legacy محصول آبشده/کاتد — وقتی همان فلز روی کیف اصلی موجودی دارد نباید دوباره شمرده شود. */ private function shouldIgnoreLegacyProductWalletLine( Product $catalogProduct, ?Wallet $mainWallet, Collection $catalogById, array $mainWalletProductIds ): bool { if (in_array((int) $catalogProduct->id, $mainWalletProductIds, true)) { return true; } if ((float) ($mainWallet?->gold ?? 0) <= 0) { return false; } if (!$this->isMeltedTradeProduct($catalogProduct)) { return false; } $mainMetalId = $this->resolveMainWalletMetalId($mainWallet, $catalogById, $mainWalletProductIds); return $mainMetalId > 0 && (int) ($catalogProduct->metal_id ?? 0) === $mainMetalId; } private function resolveMainWalletMetalId( ?Wallet $mainWallet, Collection $catalogById, array $mainWalletProductIds ): int { if ($mainWalletProductIds !== []) { $representative = $this->resolveMainWalletRepresentative($catalogById, $mainWalletProductIds); if ($representative) { return max(1, (int) ($representative->metal_id ?? 1)); } } if ((float) ($mainWallet?->gold ?? 0) <= 0) { return 0; } $mainProductId = $this->configService->mainProductId(); if ($mainProductId) { $mainProduct = $catalogById->get((int) $mainProductId); if ($mainProduct) { return max(1, (int) ($mainProduct->metal_id ?? 1)); } } return 1; } private function catalogProduct(Product $product, Collection $catalogById): Product { return $catalogById->get((int) $product->id) ?? $product; } /** * محصولات آبشده: دسته «آبشده»/«کاتد مس» + محصول اصلی تنظیمات (main_product). */ private function resolveMeltedProducts(Collection $catalog): Collection { $mainProductId = $this->configService->mainProductId(); return $catalog ->filter(fn (Product $product) => $this->isMeltedCatalogProduct($product, $mainProductId)) ->sortBy(fn (Product $product) => $this->isMainMeltedProduct($product, $mainProductId) ? 0 : 1) ->values(); } private function isMeltedCatalogProduct(Product $product, ?int $mainProductId): bool { if ($mainProductId && (int) $product->id === (int) $mainProductId) { return true; } return $this->isMeltedTradeProduct($product); } private function isMainMeltedProduct(Product $product, ?int $mainProductId): bool { return $mainProductId !== null && (int) $product->id === (int) $mainProductId; } private function isMeltedTradeProduct(Product $product): bool { $group = trim((string) ($product->group_type ?? '')); return in_array($group, ['آبشده', 'کاتد مس'], true); } private function meltedGroupTypeLabel(Product $product): string { $group = trim((string) ($product->group_type ?? '')); return $group !== '' ? $group : 'آبشده'; } /** اولین محصولِ لیست main_wallet_products که در کاتالوگ فعال است. */ private function resolveMainWalletRepresentative(Collection $catalogById, array $mainWalletProductIds): ?Product { foreach ($mainWalletProductIds as $productId) { $product = $catalogById->get((int) $productId); if ($product) { return $product; } } return null; } /** فلزهایی که کاربر روی کیف محصول موجودی دارد — برای جلوگیری از کارت آبشدهٔ تکراری. */ private function metalIdsWithProductWallet( Collection $userWallets, Collection $catalogById, array $mainWalletProductIds = [], ?Wallet $mainWallet = null ): array { $metalIds = []; $mainWallet ??= $this->resolveMainWallet($userWallets); foreach ($userWallets as $walletLine) { if ($walletLine->product_id === null || (float) $walletLine->gold <= 0) { continue; } // کیف محصولاتِ متصل به کیف اصلی نادیده گرفته می‌شود if (in_array((int) $walletLine->product_id, $mainWalletProductIds, true)) { continue; } $product = $walletLine->product; if (!$product) { continue; } $catalogProduct = $this->catalogProduct($product, $catalogById); if ($this->shouldIgnoreLegacyProductWalletLine($catalogProduct, $mainWallet, $catalogById, $mainWalletProductIds)) { continue; } $metalId = (int) ($catalogProduct->metal_id ?? 0); if ($metalId > 0) { $metalIds[] = $metalId; } } return array_values(array_unique($metalIds)); } /** محصول نمایشی برای موجودی کیف اصلی هر فلز. */ private function resolveDisplayProductForMetal( Collection $catalog, int $metalId, ?int $mainProductId ): ?Product { if ($mainProductId) { $mainProduct = $catalog->firstWhere('id', $mainProductId); if ($mainProduct && (int) ($mainProduct->metal_id ?? 0) === $metalId) { return $mainProduct; } } foreach ($this->resolveMeltedProducts($catalog) as $product) { if ((int) ($product->metal_id ?? 0) === $metalId) { return $product; } } return $catalog->first(fn (Product $product) => (int) ($product->metal_id ?? 0) === $metalId); } private function buildInventoryCards( Collection $userWallets, Collection $catalogById, array $mainWalletProductIds = [], ?Wallet $mainWallet = null ): array { $cards = []; $mainWallet ??= $this->resolveMainWallet($userWallets); foreach ($userWallets as $walletLine) { if ($walletLine->fund_id !== null) { continue; } if ($walletLine->product_id === null || (float) $walletLine->gold <= 0) { continue; } // محصول متصل به کیف اصلی دارایی جدا ندارد — کارتش از کیف اصلی ساخته می‌شود if (in_array((int) $walletLine->product_id, $mainWalletProductIds, true)) { continue; } $product = $walletLine->product; if (!$product) { continue; } $catalogProduct = $this->catalogProduct($product, $catalogById); if ($this->shouldIgnoreLegacyProductWalletLine($catalogProduct, $mainWallet, $catalogById, $mainWalletProductIds)) { continue; } $productPayload = $this->productPayload($catalogProduct); $isCountBased = $this->isCountBasedProduct($catalogProduct); $cards[] = [ 'product_id' => (int) $catalogProduct->id, 'title' => (string) $catalogProduct->title, 'group_type' => (string) ($catalogProduct->group_type ?? ''), 'metal_id' => (int) ($catalogProduct->metal_id ?? 0), 'quantity' => round((float) $walletLine->gold, 3), 'unit' => Controller::productQuantityUnit($productPayload), 'display_type' => $isCountBased ? 'count' : 'weight', 'unit_weight' => $this->productUnitWeightLabel($catalogProduct), 'show_in_assets' => $this->shouldShowInAssets($catalogProduct), 'file' => $this->filePayload($catalogProduct), ]; } return $cards; } private function buildMetalTotals( Collection $userWallets, ?Wallet $mainWallet, array $metalIds, string $weightUnit, Collection $catalogById, array $metalsWithProductWallet = [], array $mainWalletProductIds = [] ): array { if ($metalIds === []) { return []; } $mainWalletGrams = Controller::weightToGrams((float) ($mainWallet?->gold ?? 0)); $totals = []; // فلز نمایندهٔ کیف اصلی در حالت main_wallet_products $mainWalletMetalId = null; if ($mainWalletProductIds !== []) { $representative = $this->resolveMainWalletRepresentative($catalogById, $mainWalletProductIds); $mainWalletMetalId = $representative ? (int) ($representative->metal_id ?? 0) : null; } foreach ($metalIds as $metalId) { $productGrams = 0.0; foreach ($userWallets as $walletLine) { if ($walletLine->product_id === null || !$walletLine->product) { continue; } // کیف محصولاتِ متصل به کیف اصلی جداگانه شمرده نمی‌شود if (in_array((int) $walletLine->product_id, $mainWalletProductIds, true)) { continue; } $catalogProduct = $this->catalogProduct($walletLine->product, $catalogById); if ($this->shouldIgnoreLegacyProductWalletLine($catalogProduct, $mainWallet, $catalogById, $mainWalletProductIds)) { continue; } if ((int) ($catalogProduct->metal_id ?? 0) !== (int) $metalId) { continue; } if ((string) ($catalogProduct->group_type ?? '') === 'سکه') { continue; } $productGrams += Controller::productLineWeightToGrams( $catalogProduct, $walletLine->gold ); } if ($mainWalletProductIds !== []) { // حالت صریح: کیف اصلی فقط یک‌بار، زیر فلزِ محصول نماینده $meltedGrams = ($mainWalletMetalId !== null && (int) $metalId === $mainWalletMetalId) ? $mainWalletGrams : 0.0; } else { // کیف اصلی فقط برای فلزی که هیچ کیف محصولی ندارد به مجموع اضافه می‌شود. $meltedGrams = in_array((int) $metalId, $metalsWithProductWallet, true) ? 0.0 : $this->meltedGramsForMetalTotal($mainWalletGrams, $metalId, $metalIds); } $productWeight = round(Controller::gramsToTradeUnit($productGrams), 3); $meltedWeight = round(Controller::gramsToTradeUnit($meltedGrams), 3); $totalWeight = round($productWeight + $meltedWeight, 3); $totals[] = [ 'metal_id' => (int) $metalId, 'metal_label' => self::METAL_LABELS[$metalId] ?? 'فلز', 'label' => 'کل دارایی ' . (self::METAL_LABELS[$metalId] ?? 'فلز'), 'product_weight' => $productWeight, 'melted_weight' => $meltedWeight, 'total_weight' => $totalWeight, 'unit' => $weightUnit, ]; } return $totals; } /** جمع واقعی کیف اصلی برای هر فلز — مستقل از show_in_assets. */ private function meltedGramsForMetalTotal( float $mainWalletGrams, int $metalId, array $catalogMetalIds ): float { if ($mainWalletGrams <= 0) { return 0.0; } if (count($catalogMetalIds) === 1) { return (int) $catalogMetalIds[0] === (int) $metalId ? $mainWalletGrams : 0.0; } return (int) $metalId === 1 ? $mainWalletGrams : 0.0; } /** * ارزش ریالی دارایی‌های فلزی: * • وزن کل هر فلز × قیمت زنده * • سکه‌ها (خارج از جمع وزن) × قیمت محصول */ private function sumAssetsRialValue( array $metalTotals, Collection $userWallets, Collection $catalogById, array $mainWalletProductIds = [] ): float { $total = 0.0; foreach ($metalTotals as $metal) { $weight = (float) ($metal['total_weight'] ?? 0); if ($weight <= 0) { continue; } $livePrice = $this->resolveLivePricePerUnit((int) ($metal['metal_id'] ?? 0)); if ($livePrice > 0) { $total += $weight * $livePrice; } } foreach ($userWallets as $walletLine) { if ($walletLine->product_id === null || !$walletLine->product || (float) $walletLine->gold <= 0) { continue; } if (in_array((int) $walletLine->product_id, $mainWalletProductIds, true)) { continue; } $catalogProduct = $this->catalogProduct($walletLine->product, $catalogById); if ((string) ($catalogProduct->group_type ?? '') !== 'سکه') { continue; } $total += $this->productDisplayUnitPrice($catalogProduct) * (float) $walletLine->gold; } return $total; } private function resolveLivePricePerUnit(int $metalId): float { if ($metalId === 1) { $live = (int) app(ProductDisplayPriceService::class)->resolveAppBasePrice(); if ($live > 0) { return (float) $live; } return (float) (Price::latest('id')->value('price') ?? 0); } $product = Product::query() ->where('active', 1) ->where('metal_id', $metalId) ->where('price', '>', 0) ->orderBy('id') ->first(); if (!$product) { return 0.0; } $unitPrice = $this->productDisplayUnitPrice($product); $amount = (float) ($product->amount ?? 1); return $amount > 0 ? $unitPrice * $amount : $unitPrice; } private function productDisplayUnitPrice(Product $product): float { return (float) ($product->price ?? 0) + (float) ($product->price_profit ?? 0); } private function shouldShowInAssets(Product $product): bool { if (!Product::hasShowInAssetsColumn()) { return true; } return (bool) ($product->show_in_assets ?? false); } private function isCountBasedProduct(Product $product): bool { return Controller::isCountBasedProduct($this->productPayload($product)); } private function productPayload(Product $product): array { return [ 'unit' => $product->unit ?? null, 'direct_price' => $product->direct_price ?? null, 'group_type' => $product->group_type ?? null, 'amount' => $product->amount ?? null, 'trade_by_quantity' => $product->trade_by_quantity ?? null, ]; } private function productUnitWeightLabel(Product $product): ?string { if (!$this->isCountBasedProduct($product)) { return null; } if (!Controller::hasProductUnitWeight($this->productPayload($product))) { return null; } $amount = (float) ($product->amount ?? 0); $unit = Controller::productStorageWeightUnit($product); return trim($amount . ' ' . $unit); } private function filePayload(Product $product): ?array { if (!$product->relationLoaded('file') || !$product->file) { return null; } $fileModel = $product->file; $src = (string) ($fileModel->src ?? ''); return [ 'id' => $fileModel->id, 'src' => $src, 'full_src' => $src, 'url' => $fileModel->url ?? null, ]; } }