diff --git a/app/Console/Commands/AuditIntvalGoldLossCommand.php b/app/Console/Commands/AuditIntvalGoldLossCommand.php new file mode 100644 index 0000000..7188be1 --- /dev/null +++ b/app/Console/Commands/AuditIntvalGoldLossCommand.php @@ -0,0 +1,168 @@ +option('min')); + $limit = max(1, (int) $this->option('limit')); + $onlyCustomer = (int) $this->option('customer'); + + $weightProductIds = $this->weightBasedProductIds(); + if ($weightProductIds === []) { + $this->warn('هیچ محصول وزنی یافت نشد.'); + + return self::SUCCESS; + } + + $walletQuery = Wallet::query() + ->whereNull('fund_id') + ->whereNotNull('product_id') + ->whereIn('product_id', $weightProductIds) + ->where('owner_type', 2); + + if ($onlyCustomer > 0) { + $walletQuery->where('owner', $onlyCustomer); + } + + $rows = []; + $walletQuery->orderBy('id')->chunkById(200, function ($wallets) use (&$rows, $min, $limit) { + foreach ($wallets as $wallet) { + if (count($rows) >= $limit) { + return false; + } + + $report = $this->inspectWallet($wallet, $min); + if ($report !== null) { + $rows[] = $report; + } + } + + return count($rows) < $limit; + }); + + if ($rows === []) { + $this->info('موردی با اختلاف معنادار پیدا نشد.'); + + return self::SUCCESS; + } + + $this->table( + ['customer_id', 'name', 'wallet_id', 'product_id', 'tx_net', 'wallet_gold', 'missing', 'matches_intval_bug'], + array_map(static fn (array $r) => [ + $r['customer_id'], + $r['name'], + $r['wallet_id'], + $r['product_id'], + $r['tx_net'], + $r['wallet_gold'], + $r['missing'], + $r['matches_intval_bug'] ? 'yes' : 'maybe', + ], $rows) + ); + + $totalMissing = round(array_sum(array_column($rows, 'missing')), 3); + $this->newLine(); + $this->warn('تعداد کیف مشکوک: '.count($rows)); + $this->warn("جمع طلای کم‌شده (تقریبی): {$totalMissing} گرم"); + $this->line('این فقط گزارش است؛ برای برگرداندن موجودی باید جداگانه اصلاح شود.'); + + return self::SUCCESS; + } + + /** @return list */ + private function weightBasedProductIds(): array + { + $ids = []; + Product::query() + ->select(['id', 'unit', 'direct_price', 'group_type', 'amount', 'trade_by_quantity']) + ->orderBy('id') + ->chunkById(200, function ($products) use (&$ids) { + foreach ($products as $product) { + if (! Controller::isCountBasedProduct(Controller::productUnitPayload($product))) { + $ids[] = (int) $product->id; + } + } + }); + + return $ids; + } + + private function inspectWallet(Wallet $wallet, float $min): ?array + { + $txs = Transaction::query() + ->where('wallet', $wallet->id) + ->whereNotNull('gold') + ->where('gold', '!=', 0) + ->orderBy('id') + ->get(['id', 'gold', 'type', 'created_at']); + + if ($txs->isEmpty()) { + return null; + } + + $txNet = 0.0; + $clean = 0.0; + $buggy = 0.0; + + foreach ($txs as $tx) { + $delta = (float) $tx->gold; + $txNet += $delta; + + // الگوی سفارش: قبل از واریز طلای مثبت روی کیف محصول، intval می‌زد + if ($delta > 0) { + $buggy = (float) intval($buggy); + } + $buggy += $delta; + $clean += $delta; + } + + $walletGold = (float) $wallet->gold; + $missing = round($txNet - $walletGold, 6); + + if ($missing < $min) { + return null; + } + + // اگر موجودی فعلی نزدیک مسیر باگ باشد، با اطمینان بالاتر «همان باگ» است + $matchesIntvalBug = abs($walletGold - $buggy) < $min + || abs($walletGold - round($buggy, 3)) < $min; + + $customer = Customer::query()->select(['id', 'name'])->find((int) $wallet->owner); + + return [ + 'customer_id' => (int) $wallet->owner, + 'name' => $customer?->name ?? '-', + 'wallet_id' => (int) $wallet->id, + 'product_id' => (int) $wallet->product_id, + 'tx_net' => round($txNet, 3), + 'wallet_gold' => round($walletGold, 3), + 'missing' => round($missing, 3), + 'matches_intval_bug' => $matchesIntvalBug, + 'clean_replay' => round($clean, 3), + 'buggy_replay' => round($buggy, 3), + ]; + } +} diff --git a/app/Http/Controllers/Admin/CustomerController.php b/app/Http/Controllers/Admin/CustomerController.php index a11b4e2..d83591b 100644 --- a/app/Http/Controllers/Admin/CustomerController.php +++ b/app/Http/Controllers/Admin/CustomerController.php @@ -32,7 +32,7 @@ class CustomerController extends SuperController // (referral_to_id در DB هست؛ بدون with فقط کد خام دیده نمی‌شود و UI از userInfo.referral می‌خواند) $this->with = ['referral']; $this->select = ['*']; - $search=['name','mobile']; + $search = ['name', 'mobile', 'moshtari_code', 'pargar_account_id', 'referral_id', 'n_code', 'id']; $this->query = Customer::with($this->with,'wallet')->where('is_archive',false)->where('type',1); // $this->query = Customer::where('is_archive',false)->get(); parent::__construct(Customer::class, $this->with, $this->select, 'customer', $this->query, $search); diff --git a/app/Http/Controllers/Customer/CartController.php b/app/Http/Controllers/Customer/CartController.php index e2b930e..4bb96a1 100644 --- a/app/Http/Controllers/Customer/CartController.php +++ b/app/Http/Controllers/Customer/CartController.php @@ -318,12 +318,35 @@ class CartController extends Controller 'title' => "سبد خرید" ], 12, false, false); foreach ($cart->items as $item) { - $wallet_interface_product = new WalletInterface(auth('customer')->id(), auth('customer')->user()->type, null, $item->product_id); - $wallet_interface_product->wallet->gold = intval($wallet_interface_product->wallet->gold); - $wallet_interface_product->wallet->save(); + $productId = (int) $item->product_id; + $customerId = (int) auth('customer')->id(); + $ownerType = (int) auth('customer')->user()->type; + if (self::isMainWalletProduct($productId)) { + app(\App\Services\WalletBalanceService::class)->consolidateOrphanedForOwner( + $customerId, + $ownerType, + $productId + ); + } + $wallet_interface_product = new WalletInterface( + $customerId, + $ownerType, + null, + self::effectiveProductWalletId($productId) + ); + $product = Product::query()->find($productId); + if ( + $wallet_interface_product->wallet + && $wallet_interface_product->wallet->product_id !== null + && $product + && self::isCountBasedProduct(self::productUnitPayload($product)) + ) { + $wallet_interface_product->wallet->gold = intval($wallet_interface_product->wallet->gold); + $wallet_interface_product->wallet->save(); + } new TransactionInterface($wallet_interface_product, [ "gold" => $item->quantity, - "product_id" => $item->product_id, + "product_id" => $productId, 'expire_at' => now(), 'active' => true, 'order_id' => $order->id, diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index aadf1e2..cf27006 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -29,6 +29,7 @@ use App\Services\ProductPriceService; use App\Services\ProductDisplayPriceService; use App\Services\FormulaPriceService; use App\Services\ReferralGiftService; +use App\Services\WalletBalanceService; use App\Services\WalletBroadcastService; @@ -108,8 +109,7 @@ class SingleOrderController extends Controller $data['payment_remind'] = 0; $data['fee'] = $data['difference']; $ownerType = (int) ($customer?->type ?? 2); - $wallet_interface = new WalletInterface($data['customer_id'], $ownerType); - $wallet = $wallet_interface->wallet; + [$wallet, $wallet_interface] = $this->resolveOrderWallet((int) $data['customer_id'], $customer, $ownerType); if (!$wallet) { throw new UnprocessableEntityHttpException('کیف پول یافت نشد.'); } @@ -796,29 +796,86 @@ class SingleOrderController extends Controller ], 1, false, false); } + /** + * کیف سفارش: مثل goldali — اول legacyهای main_wallet_products روی کیف اصلی ادغام، + * بعد موجودی مؤثر (جمع) روی $wallet برای چک خرید/فروش. + * + * @return array{0: Wallet|null, 1: WalletInterface} + */ + private function resolveOrderWallet(int $customerId, ?Customer $customer = null, ?int $ownerType = null): array + { + $walletOwnerType = $ownerType + ?? (int) ($customer?->type ?? 2); + + $balanceService = app(WalletBalanceService::class); + $balanceService->consolidateAllMainWalletProductsForOwner($customerId, $walletOwnerType); + + $walletInterface = new WalletInterface($customerId, $walletOwnerType); + $wallet = Wallet::query() + ->where('owner', $customerId) + ->where('owner_type', $walletOwnerType) + ->whereNull('fund_id') + ->whereNull('product_id') + ->first(); + + if ($wallet === null) { + $wallet = $walletInterface->wallet; + } else { + $wallet->refresh(); + } + + if ($wallet && $balanceService->mainWalletProductIds() !== []) { + $wallet->cash = $balanceService->effectiveMainWalletCash($customerId, $walletOwnerType); + $wallet->gold = $balanceService->effectiveMainWalletGold($customerId, $walletOwnerType); + } + + return [$wallet, $walletInterface]; + } + /** * کیف مؤثر محصول برای تراکنش سفارش. * محصولات متصل به کیف اصلی (main_wallet_products) مستقیماً روی کیف اصلی می‌نشینند. */ private function productWalletInterface(int $productId): WalletInterface { + $customer = auth('customer')->user(); + $customerId = (int) ($customer?->id ?? 0); + $walletOwnerType = (int) ($customer?->type ?? 2); + + if ($customerId > 0 && self::isMainWalletProduct($productId)) { + app(WalletBalanceService::class)->consolidateOrphanedForOwner( + $customerId, + $walletOwnerType, + $productId + ); + } + return new WalletInterface( - auth('customer')->id(), - auth('customer')->user()->type, + $customerId, + $walletOwnerType, null, self::effectiveProductWalletId($productId) ); } - /** رفتار قدیمیِ رُند کردن موجودی فقط برای کیف خود محصول؛ کیف اصلی هرگز رُند نمی‌شود */ + /** + * رُند به عدد صحیح فقط برای محصول تعدادی (سکه/شمش قطعه‌ای). + * روی طلای وزنی/اعشاری و کیف اصلی هرگز اعمال نشود — همان باگ از دست رفتن ۰.۹۱۳ و ۰.۳۹۱. + */ private function normalizeProductWalletGold(WalletInterface $walletInterface): void { - if ($walletInterface->wallet->product_id === null) { + $wallet = $walletInterface->wallet; + if (!$wallet || $wallet->product_id === null) { return; } - $walletInterface->wallet->gold = intval($walletInterface->wallet->gold); - $walletInterface->wallet->save(); + $product = Product::query()->find((int) $wallet->product_id); + if (!$product || !self::isCountBasedProduct(self::productUnitPayload($product))) { + return; + } + + $wallet->gold = intval($wallet->gold); + $wallet->save(); } private function give_order_reward(&$data, $wallet, $wallet_interface) diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 0db0e66..e0201ca 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -2,10 +2,9 @@ namespace App\Services; -use App\Http\Controllers\Controller; use App\Models\Price; -use App\Models\Product; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Rooyesh\Wallet\models\Wallet; /** @@ -77,6 +76,97 @@ class WalletBalanceService return round($mainGold + $orphanedGold, 3); } + /** + * موجودی ریالی مؤثر کیف اصلی = + * cash کیف اصلی + cash کیف‌های legacy محصولات main_wallet_products. + */ + public function effectiveMainWalletCash(int $ownerId, ?int $ownerType = null): float + { + $mainQuery = Wallet::query() + ->where('owner', $ownerId) + ->whereNull('fund_id') + ->whereNull('product_id'); + if ($ownerType !== null) { + $mainQuery->where('owner_type', $ownerType); + } + $mainCash = (float) $mainQuery->sum('cash'); + + $productIds = $this->mainWalletProductIds(); + if ($productIds === []) { + return $mainCash; + } + + $orphanedQuery = Wallet::query() + ->where('owner', $ownerId) + ->whereNull('fund_id') + ->whereIn('product_id', $productIds); + if ($ownerType !== null) { + $orphanedQuery->where('owner_type', $ownerType); + } + $orphanedCash = (float) $orphanedQuery->sum('cash'); + + return $mainCash + $orphanedCash; + } + + /** + * انتقال موجودی کیف legacy یک محصول متصل به کیف اصلی — مثل goldali (idempotent). + * خرید/فروش بعد از این فقط روی یک کیف (اصلی) کسر/واریز می‌شود. + */ + public function consolidateOrphanedForOwner(int $ownerId, int $ownerType, int $productId): bool + { + if (!$this->configService->isMainWalletProduct($productId)) { + return false; + } + + $productWallet = Wallet::query() + ->where('owner', $ownerId) + ->where('owner_type', $ownerType) + ->where('product_id', $productId) + ->whereNull('fund_id') + ->first(); + + if (!$productWallet) { + return false; + } + + $goldToMove = (float) $productWallet->gold; + $cashToMove = (float) $productWallet->cash; + + if (abs($goldToMove) < 1e-12 && abs($cashToMove) < 1e-12) { + return false; + } + + DB::transaction(function () use ($ownerId, $ownerType, $productWallet, $goldToMove, $cashToMove) { + $mainWallet = Wallet::query()->firstOrCreate( + [ + 'owner' => $ownerId, + 'owner_type' => $ownerType, + 'fund_id' => null, + 'product_id' => null, + ], + ['gold' => 0, 'cash' => 0] + ); + + $mainWallet->gold = (float) $mainWallet->gold + $goldToMove; + $mainWallet->cash = (float) $mainWallet->cash + $cashToMove; + $mainWallet->save(); + + $productWallet->gold = 0; + $productWallet->cash = 0; + $productWallet->save(); + }); + + return true; + } + + /** ادغام همهٔ کیف‌های legacy محصولات main_wallet_products برای یک کاربر */ + public function consolidateAllMainWalletProductsForOwner(int $ownerId, int $ownerType): void + { + foreach ($this->mainWalletProductIds() as $productId) { + $this->consolidateOrphanedForOwner($ownerId, $ownerType, (int) $productId); + } + } + /** * طلای داخل قلک‌های یک محصول (یا همهٔ محصولات main_wallet اگر $productId=null و فقط main). */