2026-08-27 14:01:34 +03:30
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
|
|
use App\Models\Customer;
|
|
|
|
|
use App\Models\Product;
|
|
|
|
|
use Illuminate\Console\Command;
|
2026-08-27 14:20:19 +03:30
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Rooyesh\Wallet\interfaces\TransactionInterface;
|
|
|
|
|
use Rooyesh\Wallet\interfaces\WalletInterface;
|
2026-08-27 14:01:34 +03:30
|
|
|
use Rooyesh\Wallet\models\Transaction;
|
|
|
|
|
use Rooyesh\Wallet\models\Wallet;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-27 14:20:19 +03:30
|
|
|
* پیدا کردن / اصلاح مشتریانی که با باگ intval روی کیف محصول وزنی طلا از موجودی پریده.
|
2026-08-27 14:01:34 +03:30
|
|
|
*
|
2026-08-27 14:20:19 +03:30
|
|
|
* --fix : همان مقدار missing را روی همان کیف برمیگرداند (تراکنش داخلی، بدون سند حسابداری بیرونی).
|
|
|
|
|
* تراکنش اصلاح با note=fix:intval-gold-loss از محاسبات اسکن بعدی حذف میشود تا دوباره در لیست نیاید.
|
2026-08-27 14:01:34 +03:30
|
|
|
*/
|
|
|
|
|
class AuditIntvalGoldLossCommand extends Command
|
|
|
|
|
{
|
2026-08-27 14:20:19 +03:30
|
|
|
public const FIX_NOTE = 'fix:intval-gold-loss';
|
|
|
|
|
|
2026-08-27 14:01:34 +03:30
|
|
|
protected $signature = 'wallet:audit-intval-gold-loss
|
|
|
|
|
{--customer= : فقط یک مشتری}
|
|
|
|
|
{--min=0.001 : حداقل اختلاف گرم برای گزارش}
|
2026-08-27 14:20:19 +03:30
|
|
|
{--limit=200 : حداکثر ردیف خروجی}
|
|
|
|
|
{--fix : اصلاح موجودی برای ردیفهای تأییدشدهٔ باگ intval}
|
|
|
|
|
{--force : با --fix بدون تأیید تعاملی}';
|
2026-08-27 14:01:34 +03:30
|
|
|
|
2026-08-27 14:20:19 +03:30
|
|
|
protected $description = 'گزارش/اصلاح کیفهای وزنی آسیبدیده از باگ intval (بدون سند حسابداری بیرونی)';
|
2026-08-27 14:01:34 +03:30
|
|
|
|
|
|
|
|
public function handle(): int
|
|
|
|
|
{
|
|
|
|
|
$min = max(0.000001, (float) $this->option('min'));
|
|
|
|
|
$limit = max(1, (int) $this->option('limit'));
|
|
|
|
|
$onlyCustomer = (int) $this->option('customer');
|
2026-08-27 14:20:19 +03:30
|
|
|
$doFix = (bool) $this->option('fix');
|
2026-08-27 14:01:34 +03:30
|
|
|
|
|
|
|
|
$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 === []) {
|
2026-08-27 14:20:19 +03:30
|
|
|
$this->info('موردی با الگوی باگ intval پیدا نشد.');
|
2026-08-27 14:01:34 +03:30
|
|
|
|
|
|
|
|
return self::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->table(
|
2026-08-27 14:20:19 +03:30
|
|
|
['customer_id', 'name', 'wallet_id', 'product_id', 'tx_net', 'wallet_gold', 'missing'],
|
2026-08-27 14:01:34 +03:30
|
|
|
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();
|
2026-08-27 14:20:19 +03:30
|
|
|
$this->warn('تعداد کیف: '.count($rows));
|
|
|
|
|
$this->warn("جمع طلای کمشده: {$totalMissing} گرم");
|
2026-08-27 14:01:34 +03:30
|
|
|
|
2026-08-27 14:20:19 +03:30
|
|
|
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);
|
2026-08-27 14:01:34 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return list<int> */
|
|
|
|
|
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')
|
2026-08-27 14:20:19 +03:30
|
|
|
->get(['id', 'gold', 'type', 'note', 'created_at']);
|
2026-08-27 14:01:34 +03:30
|
|
|
|
|
|
|
|
if ($txs->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$txNet = 0.0;
|
|
|
|
|
$buggy = 0.0;
|
2026-08-27 14:20:19 +03:30
|
|
|
$hasNonFixGoldTx = false;
|
2026-08-27 14:01:34 +03:30
|
|
|
|
|
|
|
|
foreach ($txs as $tx) {
|
2026-08-27 14:20:19 +03:30
|
|
|
if ($this->isFixTransaction($tx)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 14:01:34 +03:30
|
|
|
$delta = (float) $tx->gold;
|
2026-08-27 14:20:19 +03:30
|
|
|
$hasNonFixGoldTx = true;
|
2026-08-27 14:01:34 +03:30
|
|
|
$txNet += $delta;
|
|
|
|
|
|
|
|
|
|
// الگوی سفارش: قبل از واریز طلای مثبت روی کیف محصول، intval میزد
|
|
|
|
|
if ($delta > 0) {
|
|
|
|
|
$buggy = (float) intval($buggy);
|
|
|
|
|
}
|
|
|
|
|
$buggy += $delta;
|
2026-08-27 14:20:19 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $hasNonFixGoldTx) {
|
|
|
|
|
return null;
|
2026-08-27 14:01:34 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$walletGold = (float) $wallet->gold;
|
2026-08-27 14:20:19 +03:30
|
|
|
// موجودی فعلی منهای اثر تراکنشهای fix (اگر قبلاً خورده) در خود wallet است؛
|
|
|
|
|
// tx_net بدون fix است → missing باید صفر شود وقتی wallet = tx_net
|
2026-08-27 14:01:34 +03:30
|
|
|
$missing = round($txNet - $walletGold, 6);
|
|
|
|
|
|
|
|
|
|
if ($missing < $min) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 14:20:19 +03:30
|
|
|
// فقط وقتی موجودی فعلی با مسیر باگ جور است گزارش/فیکس کن
|
2026-08-27 14:01:34 +03:30
|
|
|
$matchesIntvalBug = abs($walletGold - $buggy) < $min
|
|
|
|
|
|| abs($walletGold - round($buggy, 3)) < $min;
|
|
|
|
|
|
2026-08-27 14:20:19 +03:30
|
|
|
if (! $matchesIntvalBug) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 14:01:34 +03:30
|
|
|
$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),
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-08-27 14:20:19 +03:30
|
|
|
|
|
|
|
|
private function isFixTransaction(Transaction $tx): bool
|
|
|
|
|
{
|
|
|
|
|
$note = trim((string) ($tx->note ?? ''));
|
|
|
|
|
|
|
|
|
|
return $note === self::FIX_NOTE || str_starts_with($note, self::FIX_NOTE);
|
|
|
|
|
}
|
2026-08-27 14:01:34 +03:30
|
|
|
}
|