option('min')); $limit = max(1, (int) $this->option('limit')); $onlyCustomer = (int) $this->option('customer'); $doFix = (bool) $this->option('fix'); $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('موردی با الگوی باگ intval پیدا نشد.'); return self::SUCCESS; } $this->table( ['customer_id', 'name', 'wallet_id', 'product_id', 'tx_net', 'wallet_gold', 'missing'], 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'], ], $rows) ); $totalMissing = round(array_sum(array_column($rows, 'missing')), 3); $this->newLine(); $this->warn('تعداد کیف: '.count($rows)); $this->warn("جمع طلای کم‌شده: {$totalMissing} گرم"); if (! $doFix) { $this->line('برای اصلاح (بدون سند حسابداری): php artisan wallet:audit-intval-gold-loss --fix'); return self::SUCCESS; } if (! $this->option('force') && ! $this->confirm("موجودی {$totalMissing} گرم برای ".count($rows).' کیف اصلاح شود؟')) { $this->warn('لغو شد.'); return self::SUCCESS; } $fixed = 0; $failed = 0; foreach ($rows as $row) { try { DB::transaction(function () use ($row) { $this->applyFix($row); }); $fixed++; $this->info("OK customer={$row['customer_id']} wallet={$row['wallet_id']} +{$row['missing']}g"); } catch (\Throwable $e) { $failed++; $this->error("FAIL customer={$row['customer_id']} wallet={$row['wallet_id']}: ".$e->getMessage()); report($e); } } $this->newLine(); $this->info("اصلاح‌شده: {$fixed} — ناموفق: {$failed}"); $this->line('اسکن دوباره نباید همین ردیف‌ها را بیاورد (تراکنش fix از محاسبه خارج است).'); return $failed > 0 ? self::FAILURE : self::SUCCESS; } /** @param array{customer_id:int,wallet_id:int,product_id:int,missing:float} $row */ private function applyFix(array $row): void { $missing = (float) $row['missing']; if ($missing < 0.000001) { return; } $customer = Customer::query()->findOrFail($row['customer_id']); $ownerType = (int) $customer->type; $productId = (int) $row['product_id']; $walletInterface = new WalletInterface( (int) $customer->id, $ownerType, null, $productId ); // فقط تراکنش داخلی کیف — بدون Withdraw و بدون push حسابداری new TransactionInterface($walletInterface, [ 'cash' => 0, 'gold' => $missing, 'product_id' => $productId, 'expire_at' => now(), 'note' => self::FIX_NOTE, 'active' => true, ], 7, true, false); } /** @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', 'note', 'created_at']); if ($txs->isEmpty()) { return null; } $txNet = 0.0; $buggy = 0.0; $hasNonFixGoldTx = false; foreach ($txs as $tx) { if ($this->isFixTransaction($tx)) { continue; } $delta = (float) $tx->gold; $hasNonFixGoldTx = true; $txNet += $delta; // الگوی سفارش: قبل از واریز طلای مثبت روی کیف محصول، intval می‌زد if ($delta > 0) { $buggy = (float) intval($buggy); } $buggy += $delta; } if (! $hasNonFixGoldTx) { return null; } $walletGold = (float) $wallet->gold; // موجودی فعلی منهای اثر تراکنش‌های fix (اگر قبلاً خورده) در خود wallet است؛ // tx_net بدون fix است → missing باید صفر شود وقتی wallet = tx_net $missing = round($txNet - $walletGold, 6); if ($missing < $min) { return null; } // فقط وقتی موجودی فعلی با مسیر باگ جور است گزارش/فیکس کن $matchesIntvalBug = abs($walletGold - $buggy) < $min || abs($walletGold - round($buggy, 3)) < $min; if (! $matchesIntvalBug) { return null; } $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), 'buggy_replay' => round($buggy, 3), ]; } private function isFixTransaction(Transaction $tx): bool { $note = trim((string) ($tx->note ?? '')); return $note === self::FIX_NOTE || str_starts_with($note, self::FIX_NOTE); } }